Skip to content

Commit de9038e

Browse files
authored
feat: Distribute exact-match lucene variable references (#2987)
## Summary This PR enhances support for variables in lucene by rewriting `Field:"$var"` to `(Field:"A" OR Field"B")` (when A and B are selected for $var. This preserves exact-match semantics. The basic expansion `Field:("A" OR "B")` is a substring condition `Field ILIKE "%A%" OR Field ILIKE "%B%". The quoted form was chosen for exact-match semantics because `Field:"A"` is exact match. `Field:$var` continues using the basic expansion, matching `Field:A` substring semantics. ## How The rewrite is done through the following process: 1. Tokenize the lucene input string using the existing macro/variable tokenizer. This turns the text into a stream of Variable/Macro/Text tokens. 2. Replace all Variable type tokens with unique placeholders `__hdx_sentinel_N` in a sentinelString, and track the locations of the placeholders within the sentinelString. We do this to ensure that the sentinelString can be parsed as valid lucene (${var} reference are not valid single lucene terms). 3. Parse the sentinelString as lucene 4. Rewrite the terms representing Field:"Value" terms (from the AST), where Value is a tracked placeholder string in the sentinelString. The transform is a lucene --> lucene transformation, so the resulting lucene still goes through the existing lucene --> SQL transpiler and inherits all of its optimizations. ### Screenshots or video Some examples: <img width="1546" height="906" alt="Screenshot 2026-08-24 at 3 02 01 PM" src="https://github.com/user-attachments/assets/42ff6723-8f20-4d8b-a140-8a3805390f45" /> <img width="1554" height="887" alt="Screenshot 2026-08-24 at 3 03 07 PM" src="https://github.com/user-attachments/assets/cdbcda38-4158-4985-8e8d-a64e06152a85" /> <img width="1551" height="866" alt="Screenshot 2026-08-24 at 3 02 52 PM" src="https://github.com/user-attachments/assets/93ae488e-2bf5-484f-85a2-9ce018e1a686" /> <img width="1548" height="921" alt="Screenshot 2026-08-24 at 3 02 34 PM" src="https://github.com/user-attachments/assets/0144e937-c926-4461-8ce6-df57dc338952" /> <img width="1547" height="923" alt="Screenshot 2026-08-24 at 3 02 15 PM" src="https://github.com/user-attachments/assets/f0d862f2-957e-443f-8d7a-9a24e34660b0" /> ### How to test on Vercel preview - Create a dashboard and add some variables via the Edit Filters and Variables button - Create a chart and reference lucene variables. Inspect the SQL generated for various conditions ### References - Linear Issue: Closes HDX-5156 - Related PRs:
1 parent f11038e commit de9038e

8 files changed

Lines changed: 776 additions & 81 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@hyperdx/app': patch
3+
'@hyperdx/common-utils': patch
4+
---
5+
6+
feat: Distribute exact-match lucene variable references

packages/app/src/components/SQLEditor/__tests__/variableCompletions.test.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -95,13 +95,11 @@ describe('buildLuceneVariableSuggestions', () => {
9595
// No braced or explicit-format forms either — in a Lucene input the bare
9696
// reference already renders in the lucene format.
9797
expect(buildLuceneVariableSuggestions([SERVICE])).toEqual([
98-
{
99-
value: '$service',
100-
label: '$service',
101-
description:
102-
'The selected values of service. Expands to: ("api" OR "web")',
103-
},
98+
expect.objectContaining({ value: '$service', label: '$service' }),
10499
]);
100+
expect(buildLuceneVariableSuggestions([SERVICE])[0].description).toContain(
101+
'Expands to: ("api" OR "web")',
102+
);
105103
});
106104

107105
it('previews the empty selection as the term that drops out', () => {
@@ -131,6 +129,14 @@ describe('expandLuceneVariablesForEnglishDisplay', () => {
131129
);
132130
});
133131

132+
it('expands a quoted reference to an exact match per value', () => {
133+
// Quoting is how an author opts into exact matching, so the English
134+
// summary has to show that shape rather than the substring one above.
135+
expect(expand('ServiceName:"$service"', [SERVICE])).toBe(
136+
'(ServiceName:"api" OR ServiceName:"web")',
137+
);
138+
});
139+
134140
it('leaves an unselected variable as written', () => {
135141
// `("")` reads as `'ServiceName' is <blank>` once serialized to English,
136142
// which is worse than naming the placeholder that has no value yet.
@@ -148,6 +154,15 @@ describe('expandLuceneVariablesForEnglishDisplay', () => {
148154
).toBe('ServiceName:("api" OR "web") AND Env:$env');
149155
});
150156

157+
it('leaves a half-typed format as written', () => {
158+
// `${service:l}` is a keystroke on the way to `${service:lucene}`, and
159+
// expanding it throws. This runs on every keystroke, so it has to be
160+
// survivable rather than take the input down with it.
161+
expect(expand('ServiceName:${service:l}', [SERVICE])).toBe(
162+
'ServiceName:${service:l}',
163+
);
164+
});
165+
151166
it('leaves unknown references and the variable macros alone', () => {
152167
expect(
153168
expand('$nope AND $__filter(ServiceName, $service)', [SERVICE]),

packages/app/src/components/SQLEditor/variableCompletions.tsx

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
} from '@hyperdx/common-utils/dist/macros';
66
import { ChartVariable } from '@hyperdx/common-utils/dist/types';
77
import {
8-
substituteVariables,
8+
substituteWithContext,
99
VARIABLE_FORMATS,
1010
VariableFormat,
1111
} from '@hyperdx/common-utils/dist/variables';
@@ -17,17 +17,22 @@ const VARIABLE_FORMAT_DESCRIPTIONS: Record<VariableFormat, string> = {
1717
sqlstring: "Quoted and comma-separated, escaped for SQL. e.g. 'a', 'b', 'c'",
1818
csv: 'Comma-separated and unquoted. Not SQL-escaped. e.g. a,b,c',
1919
regex: 'A regex alternation. Regex escaped. e.g. (a|b|c)',
20-
lucene: 'An OR of quoted terms, for Lucene inputs. e.g. ("a" OR "b" OR "c")',
20+
lucene:
21+
'An OR of quoted terms, for Lucene inputs. e.g. ("a" OR "b" OR "c"). Quote the reference (field:"$var") for exact-match behavior. Leave unquoted (field:$var) for substring matching.',
2122
};
2223

23-
/** What `snippet` expands to against the variable's current selection. */
24-
function describeVariableExpansion(
24+
/** What `snippet` expands to in SQL against the variable's current selection. */
25+
function describeSqlVariableExpansion(
2526
snippet: string,
2627
variable: ChartVariable,
2728
): string | undefined {
2829
let expansion: string;
2930
try {
30-
expansion = substituteVariables(snippet, [variable]);
31+
expansion = substituteWithContext(snippet, {
32+
variables: [variable],
33+
defaultFormat: 'sqlstring',
34+
inputLanguage: 'sql',
35+
});
3136
} catch {
3237
return undefined;
3338
}
@@ -77,7 +82,7 @@ function referenceCompletions(variable: ChartVariable): SQLCompletion[] {
7782

7883
/** A static description and an expansion preview given the current selection */
7984
const help = (snippet: string, description: string) => {
80-
const expansion = describeVariableExpansion(snippet, variable);
85+
const expansion = describeSqlVariableExpansion(snippet, variable);
8186
return expansion ? completionInfo(description, expansion) : description;
8287
};
8388

@@ -153,9 +158,10 @@ export type LuceneVariableSuggestion = {
153158

154159
/** Expand references the way a Lucene expression is expanded at query time. */
155160
const substituteLucene = (text: string, variables: ChartVariable[]) =>
156-
substituteVariables(text, variables, {
161+
substituteWithContext(text, {
162+
variables,
157163
defaultFormat: 'lucene',
158-
disableMacros: true,
164+
inputLanguage: 'lucene',
159165
});
160166

161167
/**
@@ -174,7 +180,7 @@ export function buildLuceneVariableSuggestions(
174180
return {
175181
value: reference,
176182
label: reference,
177-
description: `The selected values of ${variable.name}. Expands to: ${expansion}`,
183+
description: `The selected values of ${variable.name}. Expands to: ${expansion} by default, or (Field:"value1" OR Field:"value2") when quoted like Field:"$${variable.name}".`,
178184
};
179185
});
180186
}
@@ -188,6 +194,10 @@ export function buildLuceneVariableSuggestions(
188194
* `("")`, which the English serializer reads as `'field' is <blank>` even
189195
* though that form filters nothing; leaving the reference as written is the
190196
* honest rendering of "no value chosen yet".
197+
*
198+
* Expansion can throw on a reference that is well-formed but not yet valid —
199+
* `${name:l}` is a keystroke on the way to `${name:lucene}` — and this runs on
200+
* every keystroke, so a failure falls back to the text as written.
191201
*/
192202
export function expandLuceneVariablesForEnglishDisplay(
193203
text: string,
@@ -196,7 +206,12 @@ export function expandLuceneVariablesForEnglishDisplay(
196206
const selected = (variables ?? []).filter(
197207
variable => variable.values.length > 0,
198208
);
199-
return selected.length > 0 ? substituteLucene(text, selected) : text;
209+
if (selected.length === 0) return text;
210+
try {
211+
return substituteLucene(text, selected);
212+
} catch {
213+
return text;
214+
}
200215
}
201216

202217
/** Context providing in-scope dashboard variables for descendant inputs. */

packages/common-utils/src/__tests__/queryParser.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { ClickhouseClient } from '@/clickhouse/node';
33
import { getMetadata } from '@/core/metadata';
44
import {
55
CustomSchemaSQLSerializerV2,
6+
decodeSpecialTokensToSource,
7+
encodeSpecialTokens,
68
genEnglishExplanation,
79
parseKvItemsCastExpression,
810
parseKvItemsExpression,
@@ -20,6 +22,24 @@ afterAll(() => {
2022
jest.restoreAllMocks();
2123
});
2224

25+
describe('special token encoding', () => {
26+
it('decodeSpecialTokensToSource is a lossless inverse of encodeSpecialTokens', () => {
27+
const queries = [
28+
'Url:http://example.com',
29+
'Url:https://example.com/path',
30+
'Host:localhost:3000',
31+
'Body:path\\\\to\\\\file',
32+
'foo\\:bar:baz',
33+
'Url:http://localhost:8080 AND Body:a\\\\b AND foo\\:bar:x',
34+
];
35+
for (const query of queries) {
36+
expect(decodeSpecialTokensToSource(encodeSpecialTokens(query))).toBe(
37+
query,
38+
);
39+
}
40+
});
41+
});
42+
2343
describe('CustomSchemaSQLSerializerV2 - json', () => {
2444
const metadata = getMetadata(
2545
new ClickhouseClient({ host: 'http://localhost:8123' }),
@@ -177,6 +197,43 @@ describe('CustomSchemaSQLSerializerV2 - json', () => {
177197
sql: "(((ServiceName ILIKE '%foo bar baz%')))",
178198
english: '(ServiceName contains "foo bar baz")',
179199
},
200+
// The shapes the `lucene` variable format expands a field-scoped reference
201+
// into. Distributing the field is what makes each value an exact match:
202+
// the grouped `ServiceName:("a" OR "b")` above is a substring match.
203+
{
204+
lucene: '(ServiceName:"a" OR ServiceName:"b")',
205+
sql: "(((ServiceName = 'a') OR (ServiceName = 'b')))",
206+
english: "('ServiceName' is a OR 'ServiceName' is b)",
207+
},
208+
{
209+
lucene: '(ServiceName:"a")',
210+
sql: "(((ServiceName = 'a')))",
211+
english: "('ServiceName' is a)",
212+
},
213+
{
214+
// A Map key distributes to exact equality per value AND keeps the
215+
// per-term index hint. Contrast the grouped form above, which is a
216+
// substring match.
217+
lucene:
218+
'(LogAttributes.error.message:"a" OR LogAttributes.error.message:"b")',
219+
sql: "(((`LogAttributes`['error.message'] = 'a' AND indexHint(mapContains(`LogAttributes`, 'error.message'))) OR (`LogAttributes`['error.message'] = 'b' AND indexHint(mapContains(`LogAttributes`, 'error.message')))))",
220+
english:
221+
"('LogAttributes.error.message' is a OR 'LogAttributes.error.message' is b)",
222+
},
223+
{
224+
// Same for a JSON path: exact equality per value, where the grouped form
225+
// above compiles to ILIKE.
226+
lucene:
227+
'(ResourceAttributesJSON.error.message:"a" OR ResourceAttributesJSON.error.message:"b")',
228+
sql: "(((toString(`ResourceAttributesJSON`.`error`.`message`) = 'a') OR (toString(`ResourceAttributesJSON`.`error`.`message`) = 'b')))",
229+
english:
230+
"('ResourceAttributesJSON.error.message' is a OR 'ResourceAttributesJSON.error.message' is b)",
231+
},
232+
{
233+
lucene: 'NOT (ServiceName:"a" OR ServiceName:"b")',
234+
sql: "(NOT ((ServiceName = 'a') OR (ServiceName = 'b')))",
235+
english: "NOT ('ServiceName' is a OR 'ServiceName' is b)",
236+
},
180237
{
181238
lucene: 'ServiceName:(abc def)',
182239
sql: "(((ServiceName ILIKE '%abc%') AND (ServiceName ILIKE '%def%')))",
@@ -503,12 +560,42 @@ describe('CustomSchemaSQLSerializerV2 - json', () => {
503560
['ServiceName:("")', '(((1=1)))'],
504561
['("")', '(((1=1)))'],
505562
['ServiceName:""', "((ServiceName = ''))"],
563+
// A Map key drops out the same way a plain column does.
564+
['LogAttributes.error.message:("")', '(((1=1)))'],
506565
])('renders the empty lucene term %s as %s', async (lucene, expected) => {
507566
expect(await new SearchQueryBuilder(lucene, serializer).build()).toBe(
508567
expected,
509568
);
510569
});
511570

571+
// Two empty-selection shapes that do NOT cleanly drop out. Pinned as-is so a
572+
// fix shows up here as a deliberate change rather than a surprise; see the
573+
// note on each.
574+
it.each([
575+
[
576+
// A JSON path renders as a match-anything ILIKE rather than `1=1`. Whether
577+
// this really matches every row depends on what `toString` yields for an
578+
// absent path — if that is NULL, rows missing the attribute are filtered
579+
// out while nothing is selected.
580+
'ResourceAttributesJSON.error.message:("")',
581+
"(((toString(`ResourceAttributesJSON`.`error`.`message`) ILIKE '%%')))",
582+
],
583+
[
584+
// `NOT (1=1)` matches NOTHING. A negated reference — `-ServiceName:$svc`
585+
// or `-ServiceName:"$svc"` — therefore empties the tile until a value is
586+
// selected, which is the opposite of the no-op the empty state intends.
587+
'-ServiceName:("")',
588+
'(NOT ((1=1)))',
589+
],
590+
])(
591+
'renders the empty lucene term %s as %s, which is not a no-op',
592+
async (lucene, expected) => {
593+
expect(await new SearchQueryBuilder(lucene, serializer).build()).toBe(
594+
expected,
595+
);
596+
},
597+
);
598+
512599
it('correctly searches multi-column implicit field', async () => {
513600
const serializer = new CustomSchemaSQLSerializerV2({
514601
metadata,

0 commit comments

Comments
 (0)