Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/lucene-variable-field-distribution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@hyperdx/app': patch
'@hyperdx/common-utils': patch
---

feat: Distribute exact-match lucene variable references
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,11 @@ describe('buildLuceneVariableSuggestions', () => {
// No braced or explicit-format forms either — in a Lucene input the bare
// reference already renders in the lucene format.
expect(buildLuceneVariableSuggestions([SERVICE])).toEqual([
{
value: '$service',
label: '$service',
description:
'The selected values of service. Expands to: ("api" OR "web")',
},
expect.objectContaining({ value: '$service', label: '$service' }),
]);
expect(buildLuceneVariableSuggestions([SERVICE])[0].description).toContain(
'Expands to: ("api" OR "web")',
);
});

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

it('expands a quoted reference to an exact match per value', () => {
// Quoting is how an author opts into exact matching, so the English
// summary has to show that shape rather than the substring one above.
expect(expand('ServiceName:"$service"', [SERVICE])).toBe(
'(ServiceName:"api" OR ServiceName:"web")',
);
});

it('leaves an unselected variable as written', () => {
// `("")` reads as `'ServiceName' is <blank>` once serialized to English,
// which is worse than naming the placeholder that has no value yet.
Expand All @@ -148,6 +154,15 @@ describe('expandLuceneVariablesForEnglishDisplay', () => {
).toBe('ServiceName:("api" OR "web") AND Env:$env');
});

it('leaves a half-typed format as written', () => {
// `${service:l}` is a keystroke on the way to `${service:lucene}`, and
// expanding it throws. This runs on every keystroke, so it has to be
// survivable rather than take the input down with it.
expect(expand('ServiceName:${service:l}', [SERVICE])).toBe(
'ServiceName:${service:l}',
);
});

it('leaves unknown references and the variable macros alone', () => {
expect(
expand('$nope AND $__filter(ServiceName, $service)', [SERVICE]),
Expand Down
35 changes: 25 additions & 10 deletions packages/app/src/components/SQLEditor/variableCompletions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
} from '@hyperdx/common-utils/dist/macros';
import { ChartVariable } from '@hyperdx/common-utils/dist/types';
import {
substituteVariables,
substituteWithContext,
VARIABLE_FORMATS,
VariableFormat,
} from '@hyperdx/common-utils/dist/variables';
Expand All @@ -17,17 +17,22 @@ const VARIABLE_FORMAT_DESCRIPTIONS: Record<VariableFormat, string> = {
sqlstring: "Quoted and comma-separated, escaped for SQL. e.g. 'a', 'b', 'c'",
csv: 'Comma-separated and unquoted. Not SQL-escaped. e.g. a,b,c',
regex: 'A regex alternation. Regex escaped. e.g. (a|b|c)',
lucene: 'An OR of quoted terms, for Lucene inputs. e.g. ("a" OR "b" OR "c")',
lucene:
'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.',
};

/** What `snippet` expands to against the variable's current selection. */
function describeVariableExpansion(
/** What `snippet` expands to in SQL against the variable's current selection. */
function describeSqlVariableExpansion(
snippet: string,
variable: ChartVariable,
): string | undefined {
let expansion: string;
try {
expansion = substituteVariables(snippet, [variable]);
expansion = substituteWithContext(snippet, {
variables: [variable],
defaultFormat: 'sqlstring',
inputLanguage: 'sql',
});
} catch {
return undefined;
}
Expand Down Expand Up @@ -77,7 +82,7 @@ function referenceCompletions(variable: ChartVariable): SQLCompletion[] {

/** A static description and an expansion preview given the current selection */
const help = (snippet: string, description: string) => {
const expansion = describeVariableExpansion(snippet, variable);
const expansion = describeSqlVariableExpansion(snippet, variable);
return expansion ? completionInfo(description, expansion) : description;
};

Expand Down Expand Up @@ -153,9 +158,10 @@ export type LuceneVariableSuggestion = {

/** Expand references the way a Lucene expression is expanded at query time. */
const substituteLucene = (text: string, variables: ChartVariable[]) =>
substituteVariables(text, variables, {
substituteWithContext(text, {
variables,
defaultFormat: 'lucene',
disableMacros: true,
inputLanguage: 'lucene',
});

/**
Expand All @@ -174,7 +180,7 @@ export function buildLuceneVariableSuggestions(
return {
value: reference,
label: reference,
description: `The selected values of ${variable.name}. Expands to: ${expansion}`,
description: `The selected values of ${variable.name}. Expands to: ${expansion} by default, or (Field:"value1" OR Field:"value1") when quoted like Field:"$${variable.name}".`,
};
});
}
Expand All @@ -188,6 +194,10 @@ export function buildLuceneVariableSuggestions(
* `("")`, which the English serializer reads as `'field' is <blank>` even
* though that form filters nothing; leaving the reference as written is the
* honest rendering of "no value chosen yet".
*
* Expansion can throw on a reference that is well-formed but not yet valid —
* `${name:l}` is a keystroke on the way to `${name:lucene}` — and this runs on
* every keystroke, so a failure falls back to the text as written.
*/
export function expandLuceneVariablesForEnglishDisplay(
text: string,
Expand All @@ -196,7 +206,12 @@ export function expandLuceneVariablesForEnglishDisplay(
const selected = (variables ?? []).filter(
variable => variable.values.length > 0,
);
return selected.length > 0 ? substituteLucene(text, selected) : text;
if (selected.length === 0) return text;
try {
return substituteLucene(text, selected);
} catch {
return text;
}
}

/** Context providing in-scope dashboard variables for descendant inputs. */
Expand Down
87 changes: 87 additions & 0 deletions packages/common-utils/src/__tests__/queryParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { ClickhouseClient } from '@/clickhouse/node';
import { getMetadata } from '@/core/metadata';
import {
CustomSchemaSQLSerializerV2,
decodeSpecialTokensToSource,
encodeSpecialTokens,
genEnglishExplanation,
parseKvItemsCastExpression,
parseKvItemsExpression,
Expand All @@ -20,6 +22,24 @@ afterAll(() => {
jest.restoreAllMocks();
});

describe('special token encoding', () => {
it('decodeSpecialTokensToSource is a lossless inverse of encodeSpecialTokens', () => {
const queries = [
'Url:http://example.com',
'Url:https://example.com/path',
'Host:localhost:3000',
'Body:path\\\\to\\\\file',
'foo\\:bar:baz',
'Url:http://localhost:8080 AND Body:a\\\\b AND foo\\:bar:x',
];
for (const query of queries) {
expect(decodeSpecialTokensToSource(encodeSpecialTokens(query))).toBe(
query,
);
}
});
});

describe('CustomSchemaSQLSerializerV2 - json', () => {
const metadata = getMetadata(
new ClickhouseClient({ host: 'http://localhost:8123' }),
Expand Down Expand Up @@ -177,6 +197,43 @@ describe('CustomSchemaSQLSerializerV2 - json', () => {
sql: "(((ServiceName ILIKE '%foo bar baz%')))",
english: '(ServiceName contains "foo bar baz")',
},
// The shapes the `lucene` variable format expands a field-scoped reference
// into. Distributing the field is what makes each value an exact match:
// the grouped `ServiceName:("a" OR "b")` above is a substring match.
{
lucene: '(ServiceName:"a" OR ServiceName:"b")',
sql: "(((ServiceName = 'a') OR (ServiceName = 'b')))",
english: "('ServiceName' is a OR 'ServiceName' is b)",
},
{
lucene: '(ServiceName:"a")',
sql: "(((ServiceName = 'a')))",
english: "('ServiceName' is a)",
},
{
// A Map key distributes to exact equality per value AND keeps the
// per-term index hint. Contrast the grouped form above, which is a
// substring match.
lucene:
'(LogAttributes.error.message:"a" OR LogAttributes.error.message:"b")',
sql: "(((`LogAttributes`['error.message'] = 'a' AND indexHint(mapContains(`LogAttributes`, 'error.message'))) OR (`LogAttributes`['error.message'] = 'b' AND indexHint(mapContains(`LogAttributes`, 'error.message')))))",
english:
"('LogAttributes.error.message' is a OR 'LogAttributes.error.message' is b)",
},
{
// Same for a JSON path: exact equality per value, where the grouped form
// above compiles to ILIKE.
lucene:
'(ResourceAttributesJSON.error.message:"a" OR ResourceAttributesJSON.error.message:"b")',
sql: "(((toString(`ResourceAttributesJSON`.`error`.`message`) = 'a') OR (toString(`ResourceAttributesJSON`.`error`.`message`) = 'b')))",
english:
"('ResourceAttributesJSON.error.message' is a OR 'ResourceAttributesJSON.error.message' is b)",
},
{
lucene: 'NOT (ServiceName:"a" OR ServiceName:"b")',
sql: "(NOT ((ServiceName = 'a') OR (ServiceName = 'b')))",
english: "NOT ('ServiceName' is a OR 'ServiceName' is b)",
},
{
lucene: 'ServiceName:(abc def)',
sql: "(((ServiceName ILIKE '%abc%') AND (ServiceName ILIKE '%def%')))",
Expand Down Expand Up @@ -503,12 +560,42 @@ describe('CustomSchemaSQLSerializerV2 - json', () => {
['ServiceName:("")', '(((1=1)))'],
['("")', '(((1=1)))'],
['ServiceName:""', "((ServiceName = ''))"],
// A Map key drops out the same way a plain column does.
['LogAttributes.error.message:("")', '(((1=1)))'],
])('renders the empty lucene term %s as %s', async (lucene, expected) => {
expect(await new SearchQueryBuilder(lucene, serializer).build()).toBe(
expected,
);
});

// Two empty-selection shapes that do NOT cleanly drop out. Pinned as-is so a
// fix shows up here as a deliberate change rather than a surprise; see the
// note on each.
it.each([
[
// A JSON path renders as a match-anything ILIKE rather than `1=1`. Whether
// this really matches every row depends on what `toString` yields for an
// absent path — if that is NULL, rows missing the attribute are filtered
// out while nothing is selected.
'ResourceAttributesJSON.error.message:("")',
"(((toString(`ResourceAttributesJSON`.`error`.`message`) ILIKE '%%')))",
],
[
// `NOT (1=1)` matches NOTHING. A negated reference — `-ServiceName:$svc`
// or `-ServiceName:"$svc"` — therefore empties the tile until a value is
// selected, which is the opposite of the no-op the empty state intends.
'-ServiceName:("")',
'(NOT ((1=1)))',
],
])(
'renders the empty lucene term %s as %s, which is not a no-op',
async (lucene, expected) => {
expect(await new SearchQueryBuilder(lucene, serializer).build()).toBe(
expected,
);
},
);

it('correctly searches multi-column implicit field', async () => {
const serializer = new CustomSchemaSQLSerializerV2({
metadata,
Expand Down
Loading
Loading