From 8d169efa99f32db24ab21ea27edd14bb636ca687 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 11:50:37 +0000 Subject: [PATCH 1/4] fix(ui): keep Redis hash tags in a single Browser tree node The Browser Tree view split key names on the configured delimiter with no awareness of hash tags, so a hash tag spanning several delimiter-separated groups (`{portal2:co}:something`) was torn apart into a `{portal2` folder holding `co}` and `tb}` children. Delimiters that fall inside a hash tag are no longer split points. The hash tag is resolved exactly like Redis does in `keyHashSlot` (cluster.c): the first `{`, the first `}` after it, and only when there is at least one character in between. Keys with no braces, unbalanced braces or an empty `{}` keep the previous behaviour, as do keys grouped with a prefix length. Both the tree structure and the leaf labels go through `splitWithPrefixThreshold`, so the change lives there and in the copy inlined into `constructKeysToTree`, which is stringified into a Web Worker Blob and cannot reference module scope. --- .../ui/src/helpers/constructKeysToTree.ts | 48 +++++++-- .../src/helpers/splitWithPrefixThreshold.ts | 58 +++++++++-- .../helpers/tests/constructKeysToTree.spec.ts | 98 +++++++++++++++++++ .../tests/splitWithPrefixThreshold.spec.ts | 69 +++++++++++++ 4 files changed, 261 insertions(+), 12 deletions(-) create mode 100644 redisinsight/ui/src/helpers/tests/splitWithPrefixThreshold.spec.ts diff --git a/redisinsight/ui/src/helpers/constructKeysToTree.ts b/redisinsight/ui/src/helpers/constructKeysToTree.ts index e300fea6e0..573453d203 100644 --- a/redisinsight/ui/src/helpers/constructKeysToTree.ts +++ b/redisinsight/ui/src/helpers/constructKeysToTree.ts @@ -26,13 +26,49 @@ export const constructKeysToTree = (props: Props): any[] => { dPattern: string, pLength: number, ): string[] => { - if (!pLength) { - return name.split(new RegExp(dPattern, 'g')) + // A Redis hash tag is the first `{`, then the first `}` after it, and only + // when there is at least one character in between (keyHashSlot, cluster.c). + 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)] + + // Delimiters before the prefix threshold or inside the hash tag are not + // split points, so the hash tag stays in a single tree node. + const regex = new RegExp(dPattern, 'g') + const parts: string[] = [] + let partStart = 0 + let match = regex.exec(name) + + while (match !== null) { + const { length } = match[0] + + if (length === 0) { + // never let a zero-length match stall the scan + 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 + } + + 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..e07a4560ec 100644 --- a/redisinsight/ui/src/helpers/splitWithPrefixThreshold.ts +++ b/redisinsight/ui/src/helpers/splitWithPrefixThreshold.ts @@ -1,16 +1,62 @@ // NOTE: constructKeysToTree keeps its own inline copy of this function — // it is stringified into a Web Worker Blob by useDisposableWebworker, so it // cannot reference this module. Keep both implementations in sync. +// +// Splits a key name into the parts that become tree levels. +// +// Two things stop a delimiter from being a split point: +// - it starts before `pLength`, so the first level always spans at least the +// requested prefix (the existing "prefix length" tree setting); +// - it falls inside a Redis hash tag, so a hash tag spanning several +// delimiter-separated groups stays in one tree node instead of being torn +// apart (e.g. `{portal2:co}:something`). +// +// The hash tag is resolved exactly like Redis does in `keyHashSlot` (cluster.c): +// the first `{`, then the first `}` after it, and only when there is at least +// one character in between. Key names without such a span — no braces, +// unbalanced braces, an empty `{}` — are split as before. export const splitWithPrefixThreshold = ( name: string, 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') + const parts: string[] = [] + let partStart = 0 + let match = regex.exec(name) + + while (match !== null) { + const { length } = match[0] + + if (length === 0) { + // never let a zero-length match stall the scan + 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 + } + + 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..d192dfdf2a 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,100 @@ describe('constructKeysToTree with prefixLength', () => { expect(nodes[0].nameString).toBe('abcdef') }) }) + +// These exercise the copy of splitWithPrefixThreshold that is inlined into +// constructKeysToTree for the Web Worker, not the exported helper. +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) + // leaf nameString is the full key name; VirtualTree derives the visible + // label from it with splitWithPrefixThreshold(...).pop() + 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) + }) +}) 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..e8fcb369a6 --- /dev/null +++ b/redisinsight/ui/src/helpers/tests/splitWithPrefixThreshold.spec.ts @@ -0,0 +1,69 @@ +import { splitWithPrefixThreshold } from '../splitWithPrefixThreshold' + +const COLON = ':' +const COLON_OR_UNDERSCORE = ':|_' + +const hashTagTests: [string, string, string[]][] = [ + // a hash tag containing a delimiter stays in one part + ['{portal2:co}:something', COLON, ['{portal2:co}', 'something']], + // a hash tag with no delimiter inside it behaves as before + ['{user}:1:2', COLON, ['{user}', '1', '2']], + // only the first `{`...`}` pair is a hash tag + ['a{b:c}:d:{e:f}', COLON, ['a{b:c}', 'd', '{e', 'f}']], + // an empty `{}` is not a hash tag + ['foo{}:bar:baz', COLON, ['foo{}', 'bar', 'baz']], + // `}` closes the first `{` even when it leaves the tag empty + ['foo{}{bar:baz}:x', COLON, ['foo{}{bar', 'baz}', 'x']], + // no closing brace + ['foo{bar:baz', COLON, ['foo{bar', 'baz']], + // the only `}` comes before the first `{` + ['foo}bar{baz:qux', COLON, ['foo}bar{baz', 'qux']], + // no braces at all + ['user:1:name', COLON, ['user', '1', 'name']], + // every configured delimiter is ignored inside the hash tag + ['{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', + '}', + ]) + }) +}) From e91127886e3cb60b7af9064539e311b535451bf3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 12:39:48 +0000 Subject: [PATCH 2/4] fix(ui): start the key-name scan at the prefix threshold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A delimiter match rejected for starting inside the prefix length had already been consumed by the global regex, so an overlapping match just after the threshold was never found. With delimiter `aa`, prefix length 1 and key `aaa{x}:z`, `/aa/g` matched at index 0, the predicate rejected it, and the real match at index 1 was skipped — the whole key came back as a single part instead of `['a', '{x}:z']`. Setting `lastIndex` to the prefix threshold before scanning fixes it. A match starting before the threshold was rejected anyway, and one straddling it is rejected too, so no previously accepted split point changes; a rejected match simply can no longer swallow a real one. `partStart` stays at 0 so the first part still includes the prefix. Only reachable with a self-overlapping multi-character delimiter, a prefix length landing mid-overlap and a valid hash tag, but it was a behaviour change against the previous suffix-based split, so it is restored here. Applied to the copy inlined into constructKeysToTree as well, which is stringified into a Web Worker Blob and cannot reference module scope. --- .../ui/src/helpers/constructKeysToTree.ts | 4 ++++ .../src/helpers/splitWithPrefixThreshold.ts | 4 ++++ .../helpers/tests/constructKeysToTree.spec.ts | 15 +++++++++++++ .../tests/splitWithPrefixThreshold.spec.ts | 21 +++++++++++++++++++ 4 files changed, 44 insertions(+) diff --git a/redisinsight/ui/src/helpers/constructKeysToTree.ts b/redisinsight/ui/src/helpers/constructKeysToTree.ts index 573453d203..5041b0e6cc 100644 --- a/redisinsight/ui/src/helpers/constructKeysToTree.ts +++ b/redisinsight/ui/src/helpers/constructKeysToTree.ts @@ -44,7 +44,11 @@ export const constructKeysToTree = (props: Props): any[] => { // Delimiters before the prefix threshold or inside the hash tag are not // split points, so the hash tag stays in a single tree node. + // The scan starts at the prefix threshold: a match rejected for starting + // inside the prefix must not consume an overlapping match that follows it. + // The `match.index >= pLength` guard below is kept as an explicit invariant. const regex = new RegExp(dPattern, 'g') + regex.lastIndex = pLength const parts: string[] = [] let partStart = 0 let match = regex.exec(name) diff --git a/redisinsight/ui/src/helpers/splitWithPrefixThreshold.ts b/redisinsight/ui/src/helpers/splitWithPrefixThreshold.ts index e07a4560ec..968f487345 100644 --- a/redisinsight/ui/src/helpers/splitWithPrefixThreshold.ts +++ b/redisinsight/ui/src/helpers/splitWithPrefixThreshold.ts @@ -34,7 +34,11 @@ export const splitWithPrefixThreshold = ( return [prefix + restParts[0], ...restParts.slice(1)] } + // The scan starts at the prefix threshold: a match rejected for starting + // inside the prefix must not consume an overlapping match that follows it. + // The `match.index >= pLength` guard below is kept as an explicit invariant. const regex = new RegExp(dPattern, 'g') + regex.lastIndex = pLength const parts: string[] = [] let partStart = 0 let match = regex.exec(name) diff --git a/redisinsight/ui/src/helpers/tests/constructKeysToTree.spec.ts b/redisinsight/ui/src/helpers/tests/constructKeysToTree.spec.ts index d192dfdf2a..105139fcda 100644 --- a/redisinsight/ui/src/helpers/tests/constructKeysToTree.spec.ts +++ b/redisinsight/ui/src/helpers/tests/constructKeysToTree.spec.ts @@ -232,4 +232,19 @@ describe('constructKeysToTree with hash tags', () => { 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) + }) }) diff --git a/redisinsight/ui/src/helpers/tests/splitWithPrefixThreshold.spec.ts b/redisinsight/ui/src/helpers/tests/splitWithPrefixThreshold.spec.ts index e8fcb369a6..d61567c8b5 100644 --- a/redisinsight/ui/src/helpers/tests/splitWithPrefixThreshold.spec.ts +++ b/redisinsight/ui/src/helpers/tests/splitWithPrefixThreshold.spec.ts @@ -66,4 +66,25 @@ describe('splitWithPrefixThreshold', () => { '}', ]) }) + // A rejected match must not consume an overlapping eligible one: `/aa/g` + // matches at index 0, which the prefix threshold rejects, and the real + // match at index 1 must still be found. + 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', + ]) + }) }) From 4adf0e037225e56bb202196f73832ee7cfaba38a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 12:58:28 +0000 Subject: [PATCH 3/4] fix(ui): rescan after a rejected delimiter match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A match rejected for sitting inside the hash tag had already advanced the global regex past its whole length, so an overlapping match starting one character later was never seen. With delimiters `aa` and `a}` and key `{aa}:x`, the `aa` at index 1 was rejected for being inside the tag, which consumed index 2 and hid the eligible `a}` there — the key collapsed to a single part instead of splitting at the closing brace. Rejected matches now resume the scan from one character after their start rather than past their end. Accepted matches still advance normally, every rejection advances by at least one character so the scan stays O(n), and the zero-length guard is untouched. This is the hash-tag counterpart of the prefix-threshold rescan: the two guards that can reject a match no longer discard input as a side effect. A `{aa}:x` key now yields `['{a', ':x']`, the documented behaviour for a delimiter straddling the closing brace. Applied to the copy inlined into constructKeysToTree as well, which is stringified into a Web Worker Blob and cannot reference module scope. --- .../ui/src/helpers/constructKeysToTree.ts | 4 +++ .../src/helpers/splitWithPrefixThreshold.ts | 4 +++ .../helpers/tests/constructKeysToTree.spec.ts | 30 +++++++++++++++++++ .../tests/splitWithPrefixThreshold.spec.ts | 16 ++++++++++ 4 files changed, 54 insertions(+) diff --git a/redisinsight/ui/src/helpers/constructKeysToTree.ts b/redisinsight/ui/src/helpers/constructKeysToTree.ts index 5041b0e6cc..f35a6707fe 100644 --- a/redisinsight/ui/src/helpers/constructKeysToTree.ts +++ b/redisinsight/ui/src/helpers/constructKeysToTree.ts @@ -65,6 +65,10 @@ export const constructKeysToTree = (props: Props): any[] => { ) { parts.push(name.slice(partStart, match.index)) partStart = match.index + length + } else { + // A rejected match must not consume the characters it spans: an + // overlapping match one character later can still be a split point. + regex.lastIndex = match.index + 1 } match = regex.exec(name) diff --git a/redisinsight/ui/src/helpers/splitWithPrefixThreshold.ts b/redisinsight/ui/src/helpers/splitWithPrefixThreshold.ts index 968f487345..d44831c562 100644 --- a/redisinsight/ui/src/helpers/splitWithPrefixThreshold.ts +++ b/redisinsight/ui/src/helpers/splitWithPrefixThreshold.ts @@ -55,6 +55,10 @@ export const splitWithPrefixThreshold = ( ) { parts.push(name.slice(partStart, match.index)) partStart = match.index + length + } else { + // A rejected match must not consume the characters it spans: an + // overlapping match one character later can still be a split point. + regex.lastIndex = match.index + 1 } match = regex.exec(name) diff --git a/redisinsight/ui/src/helpers/tests/constructKeysToTree.spec.ts b/redisinsight/ui/src/helpers/tests/constructKeysToTree.spec.ts index 105139fcda..21b98e4dfb 100644 --- a/redisinsight/ui/src/helpers/tests/constructKeysToTree.spec.ts +++ b/redisinsight/ui/src/helpers/tests/constructKeysToTree.spec.ts @@ -247,4 +247,34 @@ describe('constructKeysToTree with hash tags', () => { 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 index d61567c8b5..9c6653b5f8 100644 --- a/redisinsight/ui/src/helpers/tests/splitWithPrefixThreshold.spec.ts +++ b/redisinsight/ui/src/helpers/tests/splitWithPrefixThreshold.spec.ts @@ -87,4 +87,20 @@ describe('splitWithPrefixThreshold', () => { 'd', ]) }) + + // `aa` matches at index 1 and is rejected for sitting inside the hash tag; + // the eligible `a}` at index 2 straddles the closing brace and must still + // be found, so a rejected match may not consume what it spans. + it('finds an overlapping delimiter match after a rejected one', () => { + expect(splitWithPrefixThreshold('{aa}:x', 'aa|a}', 0)).toEqual(['{a', ':x']) + }) + + // `aa` at index 1 and the overlapping `ab` at index 2 both sit inside the + // hash tag and stay rejected; only `aa` at index 5 is a split point. + it('keeps overlapping matches rejected while they stay inside the hash tag', () => { + expect(splitWithPrefixThreshold('{aab}aay', 'aa|ab', 0)).toEqual([ + '{aab}', + 'y', + ]) + }) }) From 30c83db825d5a41010027c31b5349658022e6214 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 13:15:01 +0000 Subject: [PATCH 4/4] refactor(ui): drop redundant comments from key-name splitting Remove the inline comments and doc block added alongside the hash-tag splitting fix. The behaviour is covered by the test descriptions, and the comments obscured the code more than they explained it. The pre-existing notes that constructKeysToTree keeps an inlined copy of splitWithPrefixThreshold for the Web Worker are left in place, as is the existing commentary in constructKeysToTree.spec.ts. Comment-only change: no non-comment line is touched. --- .../ui/src/helpers/constructKeysToTree.ts | 10 ---------- .../src/helpers/splitWithPrefixThreshold.ts | 20 ------------------- .../helpers/tests/constructKeysToTree.spec.ts | 4 ---- .../tests/splitWithPrefixThreshold.spec.ts | 17 ---------------- 4 files changed, 51 deletions(-) diff --git a/redisinsight/ui/src/helpers/constructKeysToTree.ts b/redisinsight/ui/src/helpers/constructKeysToTree.ts index f35a6707fe..4beb3b4d26 100644 --- a/redisinsight/ui/src/helpers/constructKeysToTree.ts +++ b/redisinsight/ui/src/helpers/constructKeysToTree.ts @@ -26,8 +26,6 @@ export const constructKeysToTree = (props: Props): any[] => { dPattern: string, pLength: number, ): string[] => { - // A Redis hash tag is the first `{`, then the first `}` after it, and only - // when there is at least one character in between (keyHashSlot, cluster.c). const tagStart = name.indexOf('{') const tagEnd = tagStart === -1 ? -1 : name.indexOf('}', tagStart + 1) const hasHashTag = tagEnd > tagStart + 1 @@ -42,11 +40,6 @@ export const constructKeysToTree = (props: Props): any[] => { return [prefix + restParts[0], ...restParts.slice(1)] } - // Delimiters before the prefix threshold or inside the hash tag are not - // split points, so the hash tag stays in a single tree node. - // The scan starts at the prefix threshold: a match rejected for starting - // inside the prefix must not consume an overlapping match that follows it. - // The `match.index >= pLength` guard below is kept as an explicit invariant. const regex = new RegExp(dPattern, 'g') regex.lastIndex = pLength const parts: string[] = [] @@ -57,7 +50,6 @@ export const constructKeysToTree = (props: Props): any[] => { const { length } = match[0] if (length === 0) { - // never let a zero-length match stall the scan regex.lastIndex += 1 } else if ( match.index >= pLength && @@ -66,8 +58,6 @@ export const constructKeysToTree = (props: Props): any[] => { parts.push(name.slice(partStart, match.index)) partStart = match.index + length } else { - // A rejected match must not consume the characters it spans: an - // overlapping match one character later can still be a split point. regex.lastIndex = match.index + 1 } diff --git a/redisinsight/ui/src/helpers/splitWithPrefixThreshold.ts b/redisinsight/ui/src/helpers/splitWithPrefixThreshold.ts index d44831c562..2deb94d4ce 100644 --- a/redisinsight/ui/src/helpers/splitWithPrefixThreshold.ts +++ b/redisinsight/ui/src/helpers/splitWithPrefixThreshold.ts @@ -1,20 +1,6 @@ // NOTE: constructKeysToTree keeps its own inline copy of this function — // it is stringified into a Web Worker Blob by useDisposableWebworker, so it // cannot reference this module. Keep both implementations in sync. -// -// Splits a key name into the parts that become tree levels. -// -// Two things stop a delimiter from being a split point: -// - it starts before `pLength`, so the first level always spans at least the -// requested prefix (the existing "prefix length" tree setting); -// - it falls inside a Redis hash tag, so a hash tag spanning several -// delimiter-separated groups stays in one tree node instead of being torn -// apart (e.g. `{portal2:co}:something`). -// -// The hash tag is resolved exactly like Redis does in `keyHashSlot` (cluster.c): -// the first `{`, then the first `}` after it, and only when there is at least -// one character in between. Key names without such a span — no braces, -// unbalanced braces, an empty `{}` — are split as before. export const splitWithPrefixThreshold = ( name: string, dPattern: string, @@ -34,9 +20,6 @@ export const splitWithPrefixThreshold = ( return [prefix + restParts[0], ...restParts.slice(1)] } - // The scan starts at the prefix threshold: a match rejected for starting - // inside the prefix must not consume an overlapping match that follows it. - // The `match.index >= pLength` guard below is kept as an explicit invariant. const regex = new RegExp(dPattern, 'g') regex.lastIndex = pLength const parts: string[] = [] @@ -47,7 +30,6 @@ export const splitWithPrefixThreshold = ( const { length } = match[0] if (length === 0) { - // never let a zero-length match stall the scan regex.lastIndex += 1 } else if ( match.index >= pLength && @@ -56,8 +38,6 @@ export const splitWithPrefixThreshold = ( parts.push(name.slice(partStart, match.index)) partStart = match.index + length } else { - // A rejected match must not consume the characters it spans: an - // overlapping match one character later can still be a split point. regex.lastIndex = match.index + 1 } diff --git a/redisinsight/ui/src/helpers/tests/constructKeysToTree.spec.ts b/redisinsight/ui/src/helpers/tests/constructKeysToTree.spec.ts index 21b98e4dfb..0261a02c05 100644 --- a/redisinsight/ui/src/helpers/tests/constructKeysToTree.spec.ts +++ b/redisinsight/ui/src/helpers/tests/constructKeysToTree.spec.ts @@ -137,8 +137,6 @@ describe('constructKeysToTree with prefixLength', () => { }) }) -// These exercise the copy of splitWithPrefixThreshold that is inlined into -// constructKeysToTree for the Web Worker, not the exported helper. describe('constructKeysToTree with hash tags', () => { const buildTree = (names: string[], prefixLength = 0, delimiter = ':') => removeIds( @@ -174,8 +172,6 @@ describe('constructKeysToTree with hash tags', () => { expect(nodes).toHaveLength(1) expect(nodes[0].nameString).toBe('{portal2:co}') expect(nodes[0].keyCount).toBe(2) - // leaf nameString is the full key name; VirtualTree derives the visible - // label from it with splitWithPrefixThreshold(...).pop() expect( nodes[0].children.map((child: any) => child.nameString).sort(), ).toEqual(['{portal2:co}:other', '{portal2:co}:something']) diff --git a/redisinsight/ui/src/helpers/tests/splitWithPrefixThreshold.spec.ts b/redisinsight/ui/src/helpers/tests/splitWithPrefixThreshold.spec.ts index 9c6653b5f8..68f3f74ce6 100644 --- a/redisinsight/ui/src/helpers/tests/splitWithPrefixThreshold.spec.ts +++ b/redisinsight/ui/src/helpers/tests/splitWithPrefixThreshold.spec.ts @@ -4,23 +4,14 @@ const COLON = ':' const COLON_OR_UNDERSCORE = ':|_' const hashTagTests: [string, string, string[]][] = [ - // a hash tag containing a delimiter stays in one part ['{portal2:co}:something', COLON, ['{portal2:co}', 'something']], - // a hash tag with no delimiter inside it behaves as before ['{user}:1:2', COLON, ['{user}', '1', '2']], - // only the first `{`...`}` pair is a hash tag ['a{b:c}:d:{e:f}', COLON, ['a{b:c}', 'd', '{e', 'f}']], - // an empty `{}` is not a hash tag ['foo{}:bar:baz', COLON, ['foo{}', 'bar', 'baz']], - // `}` closes the first `{` even when it leaves the tag empty ['foo{}{bar:baz}:x', COLON, ['foo{}{bar', 'baz}', 'x']], - // no closing brace ['foo{bar:baz', COLON, ['foo{bar', 'baz']], - // the only `}` comes before the first `{` ['foo}bar{baz:qux', COLON, ['foo}bar{baz', 'qux']], - // no braces at all ['user:1:name', COLON, ['user', '1', 'name']], - // every configured delimiter is ignored inside the hash tag ['{a:b_c}:d_e', COLON_OR_UNDERSCORE, ['{a:b_c}', 'd', 'e']], ] @@ -66,9 +57,6 @@ describe('splitWithPrefixThreshold', () => { '}', ]) }) - // A rejected match must not consume an overlapping eligible one: `/aa/g` - // matches at index 0, which the prefix threshold rejects, and the real - // match at index 1 must still be found. it('finds an overlapping delimiter match after the prefix threshold', () => { expect(splitWithPrefixThreshold('aaa{x}:z', 'aa', 1)).toEqual([ 'a', @@ -88,15 +76,10 @@ describe('splitWithPrefixThreshold', () => { ]) }) - // `aa` matches at index 1 and is rejected for sitting inside the hash tag; - // the eligible `a}` at index 2 straddles the closing brace and must still - // be found, so a rejected match may not consume what it spans. it('finds an overlapping delimiter match after a rejected one', () => { expect(splitWithPrefixThreshold('{aa}:x', 'aa|a}', 0)).toEqual(['{a', ':x']) }) - // `aa` at index 1 and the overlapping `ab` at index 2 both sit inside the - // hash tag and stay rejected; only `aa` at index 5 is a split point. it('keeps overlapping matches rejected while they stay inside the hash tag', () => { expect(splitWithPrefixThreshold('{aab}aay', 'aa|ab', 0)).toEqual([ '{aab}',