From 2076a500aba8a4219208a8741b87e43ed30e1663 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Fri, 13 Nov 2026 22:37:25 +0000 Subject: [PATCH 1/3] feat(common-utils): add a compact form of the pattern predicate buildPatternPredicate repeats the separator class between every token. With all ASCII punctuation as delimiters that is about 30 characters per token. The app keeps pattern filters in the URL, and with this form two exclusions of long patterns already pass Node's default 16 KB header limit. buildCompactPatternPredicate builds the same match in a different shape: replaceRegexpAll() turns every separator run in the value into one space, and the regex matches tokens joined by single spaces, so the separator class appears once. On 8 templates mined from the demo logs, 8 exclusions take 11K characters in the URL instead of 104K, and each template matches the same number of events in ClickHouse with either form. The cost is speed: those 8 exclusions over 15 minutes of logs took 0.95 s instead of 0.45 s. parsePatternPredicate reads both forms. Part of FR-385. Co-Authored-By: Claude Opus 5.5 --- .changeset/compact-pattern-predicate.md | 9 ++ .../src/__tests__/pattern-predicate.test.ts | 150 ++++++++++++++---- packages/common-utils/src/drain/index.ts | 1 + .../src/drain/pattern-predicate.ts | 75 ++++++++- 4 files changed, 194 insertions(+), 41 deletions(-) create mode 100644 .changeset/compact-pattern-predicate.md diff --git a/.changeset/compact-pattern-predicate.md b/.changeset/compact-pattern-predicate.md new file mode 100644 index 000000000..f5ded73f6 --- /dev/null +++ b/.changeset/compact-pattern-predicate.md @@ -0,0 +1,9 @@ +--- +'@hyperdx/common-utils': minor +--- + +feat: add `buildCompactPatternPredicate` + +It builds the same match as `buildPatternPredicate` in much shorter SQL, for +pattern filters that are kept in a URL. `parsePatternPredicate` reads both +forms. diff --git a/packages/common-utils/src/__tests__/pattern-predicate.test.ts b/packages/common-utils/src/__tests__/pattern-predicate.test.ts index 85698f99a..84d1d48c2 100644 --- a/packages/common-utils/src/__tests__/pattern-predicate.test.ts +++ b/packages/common-utils/src/__tests__/pattern-predicate.test.ts @@ -1,5 +1,6 @@ import { TemplateMinerConfig } from '@/drain/config'; import { + buildCompactPatternPredicate, buildPatternPredicate, parsePatternPredicate, templateToRegex, @@ -12,14 +13,56 @@ const withPunctuation = { extraDelimiters: PUNCTUATION }; // RE2 spells code points \x{HHHH} and categories \pZ; JS (with the u flag) // spells them \u{HHHH} and \p{Z}, and has no POSIX classes. +function toJsSource(re2: string): string { + return re2 + .replace(/\\x\{([0-9A-Fa-f]+)\}/g, '\\u{$1}') + .replace(/(? + literal.replace(/''/g, "'").replace(/\\\\/g, '\\'); + +// Evaluates a compact predicate the way ClickHouse would: replaceRegexpAll, +// then match. +function matchesCompact(predicate: string, value: string): boolean { + const call = + /^match\(replaceRegexpAll\(.+, '((?:[^'\\]|\\\\|'')*)', ' '\), '((?:[^'\\]|\\\\|'')*)'\)$/s.exec( + predicate, + ); + if (call == null) throw new Error(`not a compact predicate: ${predicate}`); + const separatorRun = RegExp(toJsSource(unescapeSql(call[1])), 'gu'); + return toJsRegex(unescapeSql(call[2])).test(value.replace(separatorRun, ' ')); +} + +const MESSAGES = [ + 'Connection to db-1:5432 timed out after 30s (retry 1/3)', + 'Connection to db-2:5432 timed out after 30s (retry 2/3)', + 'user=alice action=login status=ok', + 'user=bob action=login status=ok', + '[2026-04-17T08:35:57.725Z] "GET /api/products/2ZYF HTTP/1.1" 200 -', + '[2026-04-17T08:36:02.101Z] "GET /api/products/L9EC HTTP/1.1" 200 -', + 'path=C:\\temp\\a.txt mode=[r^w] note={a|b} cost=$5 ~ok', + 'path=C:\\temp\\b.txt mode=[r^w] note={a|b} cost=$7 ~ok', +]; + +function mineClusters(): { template: string; members: string[] }[] { + const config = new TemplateMinerConfig(); + config.drainExtraDelimiters = PUNCTUATION; + const miner = new TemplateMiner(config); + const members = new Map(); + for (const message of MESSAGES) { + const { clusterId } = miner.addLogMessage(message); + members.set(clusterId, [...(members.get(clusterId) ?? []), message]); + } + return [...members].map(([clusterId, clusterMembers]) => ({ + template: miner.drain.getCluster(clusterId)!.getTemplate(), + members: clusterMembers, + })); } describe('templateToRegex', () => { @@ -55,32 +98,61 @@ describe('templateToRegex', () => { }); it('matches every member of every cluster mined with the same delimiters', () => { - const config = new TemplateMinerConfig(); - config.drainExtraDelimiters = PUNCTUATION; - const miner = new TemplateMiner(config); - const messages = [ - 'Connection to db-1:5432 timed out after 30s (retry 1/3)', - 'Connection to db-2:5432 timed out after 30s (retry 2/3)', - 'user=alice action=login status=ok', - 'user=bob action=login status=ok', - '[2026-04-17T08:35:57.725Z] "GET /api/products/2ZYF HTTP/1.1" 200 -', - '[2026-04-17T08:36:02.101Z] "GET /api/products/L9EC HTTP/1.1" 200 -', - 'path=C:\\temp\\a.txt mode=[r^w] note={a|b} cost=$5 ~ok', - 'path=C:\\temp\\b.txt mode=[r^w] note={a|b} cost=$7 ~ok', - ]; - const members = new Map(); - for (const message of messages) { - const { clusterId } = miner.addLogMessage(message); - members.set(clusterId, [...(members.get(clusterId) ?? []), message]); - } - for (const [clusterId, clusterMembers] of members) { - const template = miner.drain.getCluster(clusterId)!.getTemplate(); + for (const { template, members } of mineClusters()) { const regex = toJsRegex(templateToRegex(template, withPunctuation)); - for (const message of messages) { - expect(regex.test(message)).toBe(clusterMembers.includes(message)); + for (const message of MESSAGES) { + expect(regex.test(message)).toBe(members.includes(message)); + } + } + }); +}); + +describe('buildCompactPatternPredicate', () => { + it('matches exactly what the full predicate matches', () => { + const values = [ + ...MESSAGES, + ' GET /api/v1/users/9 404 3ms.', + '', + ' \t ', + 'GET api v1 users 9 404', + ]; + const templates = [ + ...mineClusters().map(c => c.template), + 'GET api v1 users <*> <*> <*>', + '<*>', + '', + ]; + for (const options of [withPunctuation, undefined]) { + for (const template of templates) { + const full = toJsRegex(templateToRegex(template, options)); + const compact = buildCompactPatternPredicate( + 'Body', + template, + options, + )!; + for (const value of values) { + expect([template, value, matchesCompact(compact, value)]).toEqual([ + template, + value, + full.test(value), + ]); + } } } }); + + it('is much shorter than the full predicate for long templates', () => { + const template = Array.from({ length: 40 }, (_, i) => + i % 3 === 0 ? '<*>' : `word${i}`, + ).join(' '); + const full = buildPatternPredicate('Body', template, withPunctuation)!; + const compact = buildCompactPatternPredicate( + 'Body', + template, + withPunctuation, + )!; + expect(compact.length * 4).toBeLessThan(full.length); + }); }); describe('parsePatternPredicate', () => { @@ -88,17 +160,25 @@ describe('parsePatternPredicate', () => { ['Body', 'GET api v1 users <*> <*> <*>'], ["JSONExtractString(Body, 'message')", 'user <*> action login status ok'], ['Body', '<*>'], - ])('reads back what buildPatternPredicate built (%s)', (expr, template) => { - const predicate = buildPatternPredicate(expr, template, withPunctuation)!; - expect(parsePatternPredicate(predicate, withPunctuation)).toEqual({ - valueExpression: expr, - template, - }); + ])('reads back what either builder built (%s)', (expr, template) => { + for (const build of [buildPatternPredicate, buildCompactPatternPredicate]) { + const predicate = build(expr, template, withPunctuation)!; + expect(parsePatternPredicate(predicate, withPunctuation)).toEqual({ + valueExpression: expr, + template, + }); + } }); it('rejects other conditions', () => { expect(parsePatternPredicate("ServiceName IN ('api')")).toBeNull(); expect(parsePatternPredicate("match(Body, 'timed out')")).toBeNull(); + expect( + parsePatternPredicate( + "match(replaceRegexpAll(Body, '[ ]+', ' '), '^ ?timed out ?$')", + withPunctuation, + ), + ).toBeNull(); expect( parsePatternPredicate( buildPatternPredicate('Body', 'Sending <*> rows')!, diff --git a/packages/common-utils/src/drain/index.ts b/packages/common-utils/src/drain/index.ts index e457a7779..e675bf482 100644 --- a/packages/common-utils/src/drain/index.ts +++ b/packages/common-utils/src/drain/index.ts @@ -20,6 +20,7 @@ export { Node } from './node'; export { buildNoisyBodyMatcher, selectNoisyPatterns } from './noisy-matcher'; export type { PatternRegexOptions } from './pattern-predicate'; export { + buildCompactPatternPredicate, buildPatternPredicate, MAX_PREDICATE_TEMPLATE_LENGTH, parsePatternPredicate, diff --git a/packages/common-utils/src/drain/pattern-predicate.ts b/packages/common-utils/src/drain/pattern-predicate.ts index 30b11db1e..806b36e62 100644 --- a/packages/common-utils/src/drain/pattern-predicate.ts +++ b/packages/common-utils/src/drain/pattern-predicate.ts @@ -54,6 +54,10 @@ function separatorClass(options?: PatternRegexOptions): string { ); } +function tokensOf(template: string): string[] { + return template.split(/\s+/).filter(t => t.length > 0); +} + /** Anchored RE2 regex that a value matches when it fits the Drain template. */ export function templateToRegex( template: string, @@ -61,8 +65,7 @@ export function templateToRegex( ): string { const separators = separatorClass(options); const separator = `[${separators}]`; - const tokens = template.split(/\s+/).filter(t => t.length > 0); - const body = tokens + const body = tokensOf(template) .map(t => t === PATTERN_WILDCARD ? `[^${separators}]+` : escapeRe2Literal(t), ) @@ -83,18 +86,78 @@ export function buildPatternPredicate( return `match(${valueExpression}, '${escapeSqlString(templateToRegex(template, options))}')`; } +// Regex over a value whose separator runs were replaced by single spaces. +function compactRegex(template: string): string { + const body = tokensOf(template) + .map(t => (t === PATTERN_WILDCARD ? '[^ ]+' : escapeRe2Literal(t))) + .join(' '); + return `^ ?${body} ?$`; +} + +/** + * The same match as buildPatternPredicate in much shorter SQL: separator runs + * in the value become single spaces first, so the separator class appears + * once instead of between every token. For conditions that are stored or + * shared, such as search filters kept in the URL. + */ +export function buildCompactPatternPredicate( + valueExpression: string, + template: string, + options?: PatternRegexOptions, +): string | null { + if (template.length > MAX_PREDICATE_TEMPLATE_LENGTH) return null; + const separatorRun = `[${separatorClass(options)}]+`; + const normalized = `replaceRegexpAll(${valueExpression}, '${escapeSqlString(separatorRun)}', ' ')`; + return `match(${normalized}, '${escapeSqlString(compactRegex(template))}')`; +} + +const unescapeSql = (literal: string) => + literal.replace(/''/g, "'").replace(/\\\\/g, '\\'); +const unescapeRe2Literal = (token: string) => token.replace(/\\(.)/g, '$1'); + +function parseCompactPredicate( + condition: string, + options?: PatternRegexOptions, +): { valueExpression: string; template: string } | null { + const call = + /^match\(replaceRegexpAll\((.+), '((?:[^'\\]|\\\\|'')*)', ' '\), '((?:[^'\\]|\\\\|'')*)'\)$/s.exec( + condition, + ); + if (call == null) return null; + const [, valueExpression, separatorLiteral, regexLiteral] = call; + if (unescapeSql(separatorLiteral) !== `[${separatorClass(options)}]+`) { + return null; + } + const regex = unescapeSql(regexLiteral); + if (!regex.startsWith('^ ?') || !regex.endsWith(' ?$')) return null; + const inner = regex.slice('^ ?'.length, regex.length - ' ?$'.length); + // The wildcard `[^ ]+` itself contains a space, so match tokens rather + // than splitting on spaces. + const template = (inner.match(/\[\^ \]\+|[^ ]+/g) ?? []) + .map(part => + part === '[^ ]+' ? PATTERN_WILDCARD : unescapeRe2Literal(part), + ) + .join(' '); + return compactRegex(template) === regex + ? { valueExpression, template } + : null; +} + /** - * Reverse of buildPatternPredicate: the value expression and template of a - * predicate it built with the same options, or null for any other condition. + * Reverse of buildPatternPredicate and buildCompactPatternPredicate: the + * value expression and template of a predicate either built with the same + * options, or null for any other condition. */ export function parsePatternPredicate( condition: string, options?: PatternRegexOptions, ): { valueExpression: string; template: string } | null { + const compact = parseCompactPredicate(condition, options); + if (compact != null) return compact; const call = /^match\((.+), '((?:[^'\\]|\\\\|'')*)'\)$/s.exec(condition); if (call == null) return null; const [, valueExpression, literal] = call; - const regex = literal.replace(/''/g, "'").replace(/\\\\/g, '\\'); + const regex = unescapeSql(literal); const separators = separatorClass(options); const separator = `[${separators}]`; @@ -107,7 +170,7 @@ export function parsePatternPredicate( .map(part => part === `[^${separators}]+` ? PATTERN_WILDCARD - : part.replace(/\\(.)/g, '$1'), + : unescapeRe2Literal(part), ) .join(' '); // Only what this module builds counts, so a hand-written match() isn't From e6804d465d87313a414fc7b1085ce7224c517884 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Fri, 13 Nov 2026 23:15:42 +0000 Subject: [PATCH 2/3] fix(common-utils): address review on the compact pattern predicate - Run the compact-vs-full and round-trip tests under every option set (default, punctuation, a partial delimiter set, Python whitespace), with values that use non-ASCII separators. - Test a hand-written condition that only the round trip rejects. - Share the compact regex's start, end and wildcard between the builder and the parser. - Document when the two forms agree, and that valueExpression goes into the SQL as is. Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/pattern-predicate.test.ts | 44 ++++++++++++++++--- .../src/drain/pattern-predicate.ts | 30 ++++++++++--- 2 files changed, 60 insertions(+), 14 deletions(-) diff --git a/packages/common-utils/src/__tests__/pattern-predicate.test.ts b/packages/common-utils/src/__tests__/pattern-predicate.test.ts index ec1970a41..fe2924f89 100644 --- a/packages/common-utils/src/__tests__/pattern-predicate.test.ts +++ b/packages/common-utils/src/__tests__/pattern-predicate.test.ts @@ -136,6 +136,14 @@ describe('templateToRegex', () => { }); }); +// The option sets the equivalence and round-trip tests run under. +const OPTION_SETS: (PatternRegexOptions | undefined)[] = [ + withPunctuation, + undefined, + { extraDelimiters: ['/', ':', '-', ']', '\\', '^'] }, + { extraDelimiters: PUNCTUATION, whitespace: 'python' }, +]; + describe('buildCompactPatternPredicate', () => { it('matches exactly what the full predicate matches', () => { const values = [ @@ -144,6 +152,10 @@ describe('buildCompactPatternPredicate', () => { '', ' \t ', 'GET api v1 users 9 404', + // Separators outside ASCII, and ones only one whitespace set has. + 'GET\u00a0api\u2003v1\x0busers\u3000 9 404 3ms', + 'GET api\ufeffv1 users 9 404 3ms', + 'GET api v1 users 9\x1e404\x853ms', ]; const templates = [ ...mineClusters().map(c => c.template), @@ -151,7 +163,7 @@ describe('buildCompactPatternPredicate', () => { '<*>', '', ]; - for (const options of [withPunctuation, undefined]) { + for (const options of OPTION_SETS) { for (const template of templates) { const full = toJsRegex(templateToRegex(template, options)); const compact = buildCompactPatternPredicate( @@ -198,12 +210,17 @@ describe('parsePatternPredicate', () => { ["JSONExtractString(Body, 'message')", 'user <*> action login status ok'], ['Body', '<*>'], ])('reads back what either builder built (%s)', (expr, template) => { - for (const build of [buildPatternPredicate, buildCompactPatternPredicate]) { - const predicate = build(expr, template, withPunctuation)!; - expect(parsePatternPredicate(predicate, withPunctuation)).toEqual({ - valueExpression: expr, - template, - }); + for (const options of OPTION_SETS) { + for (const build of [ + buildPatternPredicate, + buildCompactPatternPredicate, + ]) { + const predicate = build(expr, template, options)!; + expect(parsePatternPredicate(predicate, options)).toEqual({ + valueExpression: expr, + template, + }); + } } }); @@ -225,6 +242,19 @@ describe('parsePatternPredicate', () => { withPunctuation, ), ).toBeNull(); + // The real separator class with a hand-written regex: only the round trip + // through compactRegex rejects it. + const compact = buildCompactPatternPredicate( + 'Body', + 'timed out', + withPunctuation, + )!; + const handWritten = compact.replace( + "'^ ?timed out ?$'", + "'^ ?timed.* out ?$'", + ); + expect(handWritten).not.toBe(compact); + expect(parsePatternPredicate(handWritten, withPunctuation)).toBeNull(); expect( parsePatternPredicate( buildPatternPredicate('Body', 'Sending <*> rows')!, diff --git a/packages/common-utils/src/drain/pattern-predicate.ts b/packages/common-utils/src/drain/pattern-predicate.ts index d17037450..4439581db 100644 --- a/packages/common-utils/src/drain/pattern-predicate.ts +++ b/packages/common-utils/src/drain/pattern-predicate.ts @@ -102,12 +102,18 @@ export function buildPatternPredicate( return `match(${valueExpression}, '${escapeSqlString(templateToRegex(template, options))}')`; } +// The compact regex: tokens joined by single spaces, at most one space at +// each end, and one run of non-spaces per wildcard. +const COMPACT_START = '^ ?'; +const COMPACT_END = ' ?$'; +const COMPACT_WILDCARD = '[^ ]+'; + // Regex over a value whose separator runs were replaced by single spaces. function compactRegex(template: string): string { const body = tokensOf(template) - .map(t => (t === PATTERN_WILDCARD ? '[^ ]+' : escapeRe2Literal(t))) + .map(t => (t === PATTERN_WILDCARD ? COMPACT_WILDCARD : escapeRe2Literal(t))) .join(' '); - return `^ ?${body} ?$`; + return `${COMPACT_START}${body}${COMPACT_END}`; } /** @@ -115,6 +121,11 @@ function compactRegex(template: string): string { * in the value become single spaces first, so the separator class appears * once instead of between every token. For conditions that are stored or * shared, such as search filters kept in the URL. + * + * The two forms agree as long as no template token contains a separator, + * which holds for templates mined with the same options. As in + * buildPatternPredicate, `valueExpression` goes into the SQL as is: pass a + * column expression, never user input. */ export function buildCompactPatternPredicate( valueExpression: string, @@ -145,13 +156,18 @@ function parseCompactPredicate( return null; } const regex = unescapeSql(regexLiteral); - if (!regex.startsWith('^ ?') || !regex.endsWith(' ?$')) return null; - const inner = regex.slice('^ ?'.length, regex.length - ' ?$'.length); - // The wildcard `[^ ]+` itself contains a space, so match tokens rather - // than splitting on spaces. + if (!regex.startsWith(COMPACT_START) || !regex.endsWith(COMPACT_END)) { + return null; + } + const inner = regex.slice( + COMPACT_START.length, + regex.length - COMPACT_END.length, + ); + // COMPACT_WILDCARD itself contains a space, so match tokens rather than + // splitting on spaces. const template = (inner.match(/\[\^ \]\+|[^ ]+/g) ?? []) .map(part => - part === '[^ ]+' ? PATTERN_WILDCARD : unescapeRe2Literal(part), + part === COMPACT_WILDCARD ? PATTERN_WILDCARD : unescapeRe2Literal(part), ) .join(' '); return compactRegex(template) === regex From efd34a5410265378f65a8f3f2ce371b1600ffd1b Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Fri, 13 Nov 2026 23:40:12 +0000 Subject: [PATCH 3/3] fix(common-utils): guard and test the compact predicate's tokens - buildCompactPatternPredicate returns null for a template whose token holds one of the extra delimiters: normalizing would split the token, so the two forms would disagree. - Test templates with regex and SQL syntax in their tokens, so escaping in the compact form and its parser is covered. Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/pattern-predicate.test.ts | 55 ++++++++++++++++++- .../src/drain/pattern-predicate.ts | 13 +++-- 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/packages/common-utils/src/__tests__/pattern-predicate.test.ts b/packages/common-utils/src/__tests__/pattern-predicate.test.ts index 28fc0cf52..8791957ab 100644 --- a/packages/common-utils/src/__tests__/pattern-predicate.test.ts +++ b/packages/common-utils/src/__tests__/pattern-predicate.test.ts @@ -147,11 +147,15 @@ describe('templateToRegex', () => { }); }); +const PARTIAL: PatternRegexOptions = { + extraDelimiters: ['/', ':', '-', ']', '\\', '^'], +}; + // The option sets the equivalence and round-trip tests run under. const OPTION_SETS: (PatternRegexOptions | undefined)[] = [ withPunctuation, undefined, - { extraDelimiters: ['/', ':', '-', ']', '\\', '^'] }, + PARTIAL, { extraDelimiters: PUNCTUATION, whitespace: 'python' }, ]; @@ -205,6 +209,55 @@ describe('buildCompactPatternPredicate', () => { )!; expect(compact.length * 4).toBeLessThan(full.length); }); + + it('escapes regex and SQL syntax in tokens', () => { + // Mined without punctuation delimiters, tokens keep their punctuation. + const cases: [PatternRegexOptions | undefined, string][] = [ + [undefined, 'GET /api/v1.2 (ok) $5 a|b'], + [undefined, "user=<*> it's C:\\tmp 3ms."], + [PARTIAL, 'GET api v1.2 (ok) $5 a|b'], + ]; + const values = [ + 'GET /api/v1.2 (ok) $5 a|b', + 'GET /api/v1x2 (ok) $5 a|b', + 'GET api v1.2 (ok) $5 a|b', + 'GET/api/v1.2 (ok) $5 a|b', + "user=<*> it's C:\\tmp 3ms.", + "user=<*> it's C:\\tmp 3msX", + ]; + for (const [options, template] of cases) { + const full = toJsRegex(templateToRegex(template, options)); + const compact = buildCompactPatternPredicate('Body', template, options)!; + for (const value of values) { + expect([template, value, matchesCompact(compact, value)]).toEqual([ + template, + value, + full.test(value), + ]); + } + expect(parsePatternPredicate(compact, options)?.template).toBe(template); + } + // `.` is a literal, so only the template's own text matches. + const compact = buildCompactPatternPredicate( + 'Body', + 'GET /api/v1.2 (ok) $5 a|b', + )!; + expect(matchesCompact(compact, 'GET /api/v1.2 (ok) $5 a|b')).toBe(true); + expect(matchesCompact(compact, 'GET /api/v1x2 (ok) $5 a|b')).toBe(false); + }); + + it('builds no predicate for a token that holds a delimiter', () => { + // Mined without delimiters, then built with them: normalizing would split + // `db-1:5432`, so the compact form can't match what the full one does. + const template = 'Connection to db-1:5432 timed out'; + expect( + buildCompactPatternPredicate('Body', template, withPunctuation), + ).toBeNull(); + expect(buildPatternPredicate('Body', template, withPunctuation)).not.toBe( + null, + ); + expect(buildCompactPatternPredicate('Body', template)).not.toBeNull(); + }); }); describe('buildPatternPredicate', () => { diff --git a/packages/common-utils/src/drain/pattern-predicate.ts b/packages/common-utils/src/drain/pattern-predicate.ts index 41d2f91e7..8b5ed5623 100644 --- a/packages/common-utils/src/drain/pattern-predicate.ts +++ b/packages/common-utils/src/drain/pattern-predicate.ts @@ -125,10 +125,10 @@ function compactRegex(template: string, options?: PatternRegexOptions): string { * once instead of between every token. For conditions that are stored or * shared, such as search filters kept in the URL. * - * The two forms agree as long as no template token contains a separator, - * which holds for templates mined with the same options. As in - * buildPatternPredicate, `valueExpression` goes into the SQL as is: pass a - * column expression, never user input. + * Null when a template token holds one of the extra delimiters: normalizing + * would split it, so the two forms would disagree. Templates mined with the + * same options never have one. As in buildPatternPredicate, `valueExpression` + * goes into the SQL as is: pass a column expression, never user input. */ export function buildCompactPatternPredicate( valueExpression: string, @@ -136,6 +136,11 @@ export function buildCompactPatternPredicate( options?: PatternRegexOptions, ): string | null { if (template.length > MAX_PREDICATE_TEMPLATE_LENGTH) return null; + const delimiters = options?.extraDelimiters ?? []; + const splitsToken = tokensOf(template, options).some( + t => t !== PATTERN_WILDCARD && [...t].some(c => delimiters.includes(c)), + ); + if (splitsToken) return null; const separatorRun = `[${separatorClass(options)}]+`; const normalized = `replaceRegexpAll(${valueExpression}, '${escapeSqlString(separatorRun)}', ' ')`; return `match(${normalized}, '${escapeSqlString(compactRegex(template, options))}')`;