diff --git a/redisinsight/ui/src/helpers/constructKeysToTree.ts b/redisinsight/ui/src/helpers/constructKeysToTree.ts index e300fea6e0..4beb3b4d26 100644 --- a/redisinsight/ui/src/helpers/constructKeysToTree.ts +++ b/redisinsight/ui/src/helpers/constructKeysToTree.ts @@ -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` diff --git a/redisinsight/ui/src/helpers/splitWithPrefixThreshold.ts b/redisinsight/ui/src/helpers/splitWithPrefixThreshold.ts index a49306277c..2deb94d4ce 100644 --- a/redisinsight/ui/src/helpers/splitWithPrefixThreshold.ts +++ b/redisinsight/ui/src/helpers/splitWithPrefixThreshold.ts @@ -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) + + 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 } diff --git a/redisinsight/ui/src/helpers/tests/constructKeysToTree.spec.ts b/redisinsight/ui/src/helpers/tests/constructKeysToTree.spec.ts index 727604b435..0261a02c05 100644 --- a/redisinsight/ui/src/helpers/tests/constructKeysToTree.spec.ts +++ b/redisinsight/ui/src/helpers/tests/constructKeysToTree.spec.ts @@ -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' @@ -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) + }) +}) diff --git a/redisinsight/ui/src/helpers/tests/splitWithPrefixThreshold.spec.ts b/redisinsight/ui/src/helpers/tests/splitWithPrefixThreshold.spec.ts new file mode 100644 index 0000000000..68f3f74ce6 --- /dev/null +++ b/redisinsight/ui/src/helpers/tests/splitWithPrefixThreshold.spec.ts @@ -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', + ]) + }) +})