Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 40 additions & 6 deletions redisinsight/ui/src/helpers/constructKeysToTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,47 @@ export const constructKeysToTree = (props: Props): any[] => {
dPattern: string,
pLength: number,
): string[] => {
if (!pLength) {
return name.split(new RegExp(dPattern, 'g'))
const tagStart = name.indexOf('{')
const tagEnd = tagStart === -1 ? -1 : name.indexOf('}', tagStart + 1)
const hasHashTag = tagEnd > tagStart + 1

if (!hasHashTag || !dPattern) {
if (!pLength) {
return name.split(new RegExp(dPattern, 'g'))
}
const prefix = name.substring(0, pLength)
const rest = name.substring(pLength)
const restParts = rest.split(new RegExp(dPattern, 'g'))
return [prefix + restParts[0], ...restParts.slice(1)]
}
const prefix = name.substring(0, pLength)
const rest = name.substring(pLength)
const restParts = rest.split(new RegExp(dPattern, 'g'))
return [prefix + restParts[0], ...restParts.slice(1)]

const regex = new RegExp(dPattern, 'g')
regex.lastIndex = pLength
const parts: string[] = []
let partStart = 0
let match = regex.exec(name)

while (match !== null) {
const { length } = match[0]

if (length === 0) {
regex.lastIndex += 1
} else if (
match.index >= pLength &&
(match.index <= tagStart || match.index + length > tagEnd)
) {
parts.push(name.slice(partStart, match.index))
partStart = match.index + length
} else {
regex.lastIndex = match.index + 1
}

match = regex.exec(name)
}

parts.push(name.slice(partStart))

return parts
}

const keysSymbol = `keys${delimiterPattern}keys`
Expand Down
46 changes: 40 additions & 6 deletions redisinsight/ui/src/helpers/splitWithPrefixThreshold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,45 @@ export const splitWithPrefixThreshold = (
dPattern: string,
pLength: number,
): string[] => {
if (!pLength) {
return name.split(new RegExp(dPattern, 'g'))
const tagStart = name.indexOf('{')
const tagEnd = tagStart === -1 ? -1 : name.indexOf('}', tagStart + 1)
const hasHashTag = tagEnd > tagStart + 1

if (!hasHashTag || !dPattern) {
if (!pLength) {
return name.split(new RegExp(dPattern, 'g'))
}
const prefix = name.substring(0, pLength)
const rest = name.substring(pLength)
const restParts = rest.split(new RegExp(dPattern, 'g'))
return [prefix + restParts[0], ...restParts.slice(1)]
}
const prefix = name.substring(0, pLength)
const rest = name.substring(pLength)
const restParts = rest.split(new RegExp(dPattern, 'g'))
return [prefix + restParts[0], ...restParts.slice(1)]

const regex = new RegExp(dPattern, 'g')
regex.lastIndex = pLength
const parts: string[] = []
let partStart = 0
let match = regex.exec(name)
Comment thread
claude[bot] marked this conversation as resolved.

while (match !== null) {
const { length } = match[0]

if (length === 0) {
regex.lastIndex += 1
} else if (
match.index >= pLength &&
(match.index <= tagStart || match.index + length > tagEnd)
) {
Comment thread
claude[bot] marked this conversation as resolved.
parts.push(name.slice(partStart, match.index))
partStart = match.index + length
} else {
regex.lastIndex = match.index + 1
Comment thread
claude[bot] marked this conversation as resolved.
}

match = regex.exec(name)
}

parts.push(name.slice(partStart))

return parts
}
139 changes: 139 additions & 0 deletions redisinsight/ui/src/helpers/tests/constructKeysToTree.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
delimiterMock,
} from './constructKeysToTreeMockResult'
import { constructKeysToTree } from '../constructKeysToTree'
import { splitWithPrefixThreshold } from '../splitWithPrefixThreshold'
import { KeyTypes } from 'uiSrc/constants'
import { IKeyPropTypes } from 'uiSrc/constants/prop-types/keys'

Expand Down Expand Up @@ -135,3 +136,141 @@ describe('constructKeysToTree with prefixLength', () => {
expect(nodes[0].nameString).toBe('abcdef')
})
})

describe('constructKeysToTree with hash tags', () => {
const buildTree = (names: string[], prefixLength = 0, delimiter = ':') =>
removeIds(
constructKeysToTree({
items: names.map((nameString) => ({
nameString,
type: KeyTypes.Hash,
ttl: -1,
size: 0,
})) as unknown as IKeyPropTypes[],
delimiterPattern: delimiter,
delimiters: [delimiter],
prefixLength,
}),
)

it('keeps keys with different hash tags in separate folders', () => {
const nodes = buildTree([
'{portal2:co}:something',
'{portal2:tb}:something',
])

expect(nodes.map((node: any) => node.nameString)).toEqual([
'{portal2:co}',
'{portal2:tb}',
])
expect(nodes[0].children[0].isLeaf).toBe(true)
})

it('groups keys sharing a hash tag under one folder', () => {
const nodes = buildTree(['{portal2:co}:something', '{portal2:co}:other'])

expect(nodes).toHaveLength(1)
expect(nodes[0].nameString).toBe('{portal2:co}')
expect(nodes[0].keyCount).toBe(2)
expect(
nodes[0].children.map((child: any) => child.nameString).sort(),
).toEqual(['{portal2:co}:other', '{portal2:co}:something'])
expect(
nodes[0].children
.map((child: any) =>
splitWithPrefixThreshold(child.nameString, ':', 0).pop(),
)
.sort(),
).toEqual(['other', 'something'])
})

it('leaves keys without a usable hash tag untouched', () => {
expect(buildTree(['{user}:1:2'])[0].nameString).toBe('{user}')
expect(buildTree(['foo{}:bar:baz'])[0].nameString).toBe('foo{}')
expect(buildTree(['foo{bar:baz'])[0].nameString).toBe('foo{bar')
expect(buildTree(['foo}bar{baz:qux'])[0].nameString).toBe('foo}bar{baz')
expect(buildTree(['user:1:name'])[0].nameString).toBe('user')
})

it('treats only the first brace pair as a hash tag', () => {
const nodes = buildTree(['a{b:c}:d:{e:f}'])

expect(nodes[0].nameString).toBe('a{b:c}')
expect(nodes[0].children[0].nameString).toBe('d')
expect(nodes[0].children[0].children[0].nameString).toBe('{e')
})

it('ignores every configured delimiter inside a hash tag', () => {
const nodes = removeIds(
constructKeysToTree({
items: [
{ nameString: '{a:b_c}:d_e', type: KeyTypes.Hash, ttl: -1, size: 0 },
] as unknown as IKeyPropTypes[],
delimiterPattern: ':|_',
delimiters: [':', '_'],
}),
)

expect(nodes[0].nameString).toBe('{a:b_c}')
expect(nodes[0].children[0].nameString).toBe('d')
})

it('keeps the hash tag together when a prefix length is set', () => {
const nodes = buildTree(['{portal2:co}:something'], 5)

expect(nodes[0].nameString).toBe('{portal2:co}')
expect(nodes[0].children[0].isLeaf).toBe(true)
})

it('lets a prefix length extend the first folder past the hash tag', () => {
const nodes = buildTree(['{tenant:x}:app:resource'], 11)

expect(nodes[0].nameString).toBe('{tenant:x}:app')
expect(nodes[0].children[0].isLeaf).toBe(true)
})
it('finds an overlapping delimiter match after the prefix threshold', () => {
const nodes = buildTree(['aaa{x}:z'], 1, 'aa')

expect(nodes[0].nameString).toBe('a')
expect(nodes[0].isLeaf).toBeUndefined()
expect(nodes[0].children).toHaveLength(1)
expect(nodes[0].children[0].isLeaf).toBe(true)
})

it('keeps prefix behaviour when the threshold lands on a delimiter', () => {
const nodes = buildTree(['{a:b}:c:d'], 6)

expect(nodes[0].nameString).toBe('{a:b}:c')
expect(nodes[0].children[0].isLeaf).toBe(true)
})

it('finds an overlapping delimiter match after a rejected one', () => {
const nodes = removeIds(
constructKeysToTree({
items: [
{ nameString: '{aa}:x', type: KeyTypes.Hash, ttl: -1, size: 0 },
] as unknown as IKeyPropTypes[],
delimiterPattern: 'aa|a}',
delimiters: ['aa', 'a}'],
}),
)

expect(nodes[0].nameString).toBe('{a')
expect(nodes[0].children[0].isLeaf).toBe(true)
})

it('keeps overlapping matches rejected while they stay inside the hash tag', () => {
const nodes = removeIds(
constructKeysToTree({
items: [
{ nameString: '{aab}aay', type: KeyTypes.Hash, ttl: -1, size: 0 },
] as unknown as IKeyPropTypes[],
delimiterPattern: 'aa|ab',
delimiters: ['aa', 'ab'],
}),
)

expect(nodes[0].nameString).toBe('{aab}')
expect(nodes[0].children[0].isLeaf).toBe(true)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { splitWithPrefixThreshold } from '../splitWithPrefixThreshold'

const COLON = ':'
const COLON_OR_UNDERSCORE = ':|_'

const hashTagTests: [string, string, string[]][] = [
['{portal2:co}:something', COLON, ['{portal2:co}', 'something']],
['{user}:1:2', COLON, ['{user}', '1', '2']],
['a{b:c}:d:{e:f}', COLON, ['a{b:c}', 'd', '{e', 'f}']],
['foo{}:bar:baz', COLON, ['foo{}', 'bar', 'baz']],
['foo{}{bar:baz}:x', COLON, ['foo{}{bar', 'baz}', 'x']],
['foo{bar:baz', COLON, ['foo{bar', 'baz']],
['foo}bar{baz:qux', COLON, ['foo}bar{baz', 'qux']],
['user:1:name', COLON, ['user', '1', 'name']],
['{a:b_c}:d_e', COLON_OR_UNDERSCORE, ['{a:b_c}', 'd', 'e']],
]

describe('splitWithPrefixThreshold', () => {
it.each(hashTagTests)(
'splits %s on %s into %s',
(name, dPattern, expected) => {
expect(splitWithPrefixThreshold(name, dPattern, 0)).toEqual(expected)
},
)

it('does not split inside a hash tag when a prefix length is set', () => {
expect(
splitWithPrefixThreshold('{portal2:co}:something', COLON, 5),
).toEqual(['{portal2:co}', 'something'])
})

it('lets the prefix length push the first level past the hash tag', () => {
expect(
splitWithPrefixThreshold('{tenant:x}:app:resource', COLON, 11),
).toEqual(['{tenant:x}:app', 'resource'])
})

it('keeps the whole name in one part when the prefix length covers it', () => {
expect(splitWithPrefixThreshold('{a:b}:c:d', COLON, 8)).toEqual([
'{a:b}:c:d',
])
})

it('merges the prefix into the first part when there is no hash tag', () => {
expect(splitWithPrefixThreshold('tenant:app:resource', COLON, 7)).toEqual([
'tenant:app',
'resource',
])
})

it('splits on every character when no delimiter is configured', () => {
expect(splitWithPrefixThreshold('{a:b}', '', 0)).toEqual([
'{',
'a',
':',
'b',
'}',
])
})
it('finds an overlapping delimiter match after the prefix threshold', () => {
expect(splitWithPrefixThreshold('aaa{x}:z', 'aa', 1)).toEqual([
'a',
'{x}:z',
])
})

it('keeps prefix behaviour when the threshold lands on a delimiter', () => {
expect(splitWithPrefixThreshold('{a:b}:c:d', COLON, 5)).toEqual([
'{a:b}',
'c',
'd',
])
expect(splitWithPrefixThreshold('{a:b}:c:d', COLON, 6)).toEqual([
'{a:b}:c',
'd',
])
})

it('finds an overlapping delimiter match after a rejected one', () => {
expect(splitWithPrefixThreshold('{aa}:x', 'aa|a}', 0)).toEqual(['{a', ':x'])
})

it('keeps overlapping matches rejected while they stay inside the hash tag', () => {
expect(splitWithPrefixThreshold('{aab}aay', 'aa|ab', 0)).toEqual([
'{aab}',
'y',
])
})
})
Loading