Skip to content

Commit a2801fe

Browse files
[Streams][StreamLang] Add support for arrays with single values to conditionToPainless (elastic#248102)
Resolves elastic/streams-program#618 ## Summary This expands `conditionToPainless` to handle fields containing single-element arrays. When a field value is a List with exactly one element, it gets unwrapped so comparisons work the same as with scalar values. ## Details The generated Painless now includes variable declarations with unwrapping logic: ``` def val_foo = $('foo', null);> if (val_foo instanceof List && val_foo.size() == 1) { val_foo = val_foo[0]; }> ``` To implement this efficiently, we: 1. Extract all unique field names from the condition tree before generating the statement 2. Generate a variable declaration block with the List unwrapping logic for each field 3. Use variable references throughout the condition instead of repeated inline $() accessors This approach avoids duplicating the unwrapping logic for each field reference in the generated script. ## Question for reviewers - Should this be backported? --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
1 parent a3dcfd0 commit a2801fe

5 files changed

Lines changed: 267 additions & 36 deletions

File tree

x-pack/platform/packages/shared/kbn-streamlang-tests/test/scout/api/tests/cross_compatibility/filter_conditions.spec.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -969,4 +969,57 @@ apiTest.describe('Cross-compatibility - Filter Conditions', { tag: ['@ess', '@sv
969969
})
970970
);
971971
});
972+
973+
apiTest('should handle single-element arrays in filter conditions', async ({ testBed, esql }) => {
974+
const streamlangDSL: StreamlangDSL = {
975+
steps: [
976+
{
977+
action: 'set',
978+
to: 'attributes.matched',
979+
value: 'yes',
980+
where: {
981+
field: 'attributes.tag',
982+
eq: 'important',
983+
},
984+
} as SetProcessor,
985+
],
986+
};
987+
988+
const { processors } = transpileIngestPipeline(streamlangDSL);
989+
const { query } = transpileEsql(streamlangDSL);
990+
991+
const mappingDoc = { attributes: { tag: 'null', matched: 'null' } };
992+
const docs = [
993+
{ attributes: { tag: ['important'] } }, // single-element array - should match
994+
{ attributes: { tag: 'important' } }, // regular string - should match
995+
{ attributes: { tag: ['other'] } }, // single-element array - should NOT match
996+
{ attributes: { tag: 'other' } }, // regular string - should NOT match
997+
];
998+
999+
await testBed.ingest('ingest-single-array', docs, processors);
1000+
const ingestResult = await testBed.getDocsOrdered('ingest-single-array');
1001+
1002+
await testBed.ingest('esql-single-array', [mappingDoc, ...docs]);
1003+
const esqlResult = await esql.queryOnIndex('esql-single-array', query);
1004+
1005+
// Single-element array ['important'] should match like 'important'
1006+
expect(ingestResult[0].attributes).toStrictEqual(expect.objectContaining({ matched: 'yes' }));
1007+
expect(ingestResult[1].attributes).toStrictEqual(expect.objectContaining({ matched: 'yes' }));
1008+
expect(ingestResult[2].attributes).not.toHaveProperty('matched');
1009+
expect(ingestResult[3].attributes).not.toHaveProperty('matched');
1010+
1011+
// ES|QL should produce the same results
1012+
expect(esqlResult.documentsOrdered[1]).toStrictEqual(
1013+
expect.objectContaining({ 'attributes.matched': 'yes' })
1014+
);
1015+
expect(esqlResult.documentsOrdered[2]).toStrictEqual(
1016+
expect.objectContaining({ 'attributes.matched': 'yes' })
1017+
);
1018+
expect(esqlResult.documentsOrdered[3]).toStrictEqual(
1019+
expect.objectContaining({ 'attributes.matched': null })
1020+
);
1021+
expect(esqlResult.documentsOrdered[4]).toStrictEqual(
1022+
expect.objectContaining({ 'attributes.matched': null })
1023+
);
1024+
});
9721025
});

x-pack/platform/packages/shared/kbn-streamlang/src/conditions/condition_to_painless.test.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,31 @@ const operatorConditionAndResults = [
9191
];
9292

9393
describe('conditionToPainless', () => {
94+
describe('single-element array unwrapping', () => {
95+
test('should unwrap single-element arrays before comparison', () => {
96+
const condition = { field: 'foo', eq: 'bar' };
97+
const result = conditionToPainless(condition);
98+
99+
// Should contain List instanceof check and size check
100+
expect(result).toContain('instanceof List');
101+
expect(result).toContain('.size() == 1');
102+
});
103+
104+
test('should handle multiple fields with array unwrapping', () => {
105+
const condition = {
106+
and: [
107+
{ field: 'foo', eq: 'bar' },
108+
{ field: 'baz', eq: 'qux' },
109+
],
110+
};
111+
const result = conditionToPainless(condition);
112+
113+
// Should contain List checks for both fields
114+
expect(result).toContain('instanceof List');
115+
expect(result).toContain('.size() == 1');
116+
});
117+
});
118+
94119
describe('conditionToStatement', () => {
95120
describe('operators', () => {
96121
operatorConditionAndResults.forEach((setup) => {
@@ -300,7 +325,11 @@ describe('conditionToPainless', () => {
300325
expect(conditionToPainless(condition)).toMatchInlineSnapshot(`
301326
"
302327
try {
303-
if ($('log', null) !== null) {
328+
329+
def val_log = $('log', null); if (val_log instanceof List && val_log.size() == 1) { val_log = val_log[0]; }
330+
331+
332+
if (val_log !== null) {
304333
return true;
305334
}
306335
return false;
@@ -326,7 +355,12 @@ describe('conditionToPainless', () => {
326355
expect(conditionToPainless(condition)).toMatchInlineSnapshot(`
327356
"
328357
try {
329-
if (($('log.logger.name', null) !== null && (($('log.logger.name', null) instanceof Number && $('log.logger.name', null).toString() == \\"nginx_proxy\\") || $('log.logger.name', null) == \\"nginx_proxy\\")) && (($('log.level', null) !== null && (($('log.level', null) instanceof Number && $('log.level', null).toString() == \\"error\\") || $('log.level', null) == \\"error\\")) || ($('log.level', null) !== null && (($('log.level', null) instanceof Number && $('log.level', null).toString() == \\"ERROR\\") || $('log.level', null) == \\"ERROR\\")))) {
358+
359+
def val_log_logger_name = $('log.logger.name', null); if (val_log_logger_name instanceof List && val_log_logger_name.size() == 1) { val_log_logger_name = val_log_logger_name[0]; }
360+
def val_log_level = $('log.level', null); if (val_log_level instanceof List && val_log_level.size() == 1) { val_log_level = val_log_level[0]; }
361+
362+
363+
if ((val_log_logger_name !== null && ((val_log_logger_name instanceof Number && val_log_logger_name.toString() == \\"nginx_proxy\\") || val_log_logger_name == \\"nginx_proxy\\")) && ((val_log_level !== null && ((val_log_level instanceof Number && val_log_level.toString() == \\"error\\") || val_log_level == \\"error\\")) || (val_log_level !== null && ((val_log_level instanceof Number && val_log_level.toString() == \\"ERROR\\") || val_log_level == \\"ERROR\\")))) {
330364
return true;
331365
}
332366
return false;

x-pack/platform/packages/shared/kbn-streamlang/src/conditions/condition_to_painless.ts

Lines changed: 84 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,48 @@ import { painlessFieldAccessor } from '../../types/utils';
1919
import { encodeValue } from '../../types/utils';
2020
import { evaluateDateMath } from './painless_date_math_helpers';
2121

22-
// Utility: get the field name from a filter condition
23-
function safePainlessField(conditionOrField: FilterCondition | string) {
24-
if (typeof conditionOrField === 'string') {
25-
return painlessFieldAccessor(conditionOrField);
22+
// Type for mapping field names to variable names
23+
type FieldVarMap = Map<string, string>;
24+
25+
// Extract all unique field names from a condition recursively
26+
function extractFieldNames(condition: Condition, fields: Set<string> = new Set()): Set<string> {
27+
if ('field' in condition && typeof condition.field === 'string') {
28+
fields.add(condition.field);
29+
}
30+
if ('and' in condition && Array.isArray(condition.and)) {
31+
condition.and.forEach((c) => extractFieldNames(c, fields));
32+
}
33+
if ('or' in condition && Array.isArray(condition.or)) {
34+
condition.or.forEach((c) => extractFieldNames(c, fields));
2635
}
27-
return painlessFieldAccessor(conditionOrField.field);
36+
if ('not' in condition && condition.not) {
37+
extractFieldNames(condition.not, fields);
38+
}
39+
return fields;
40+
}
41+
42+
// Convert a field name to a valid Painless variable name
43+
function fieldToVarName(field: string): string {
44+
// Replace special characters with underscores and prefix with 'val_'
45+
return 'val_' + field.replace(/[^a-zA-Z0-9]/g, '_');
46+
}
47+
48+
// Generate variable declaration with single-element List unwrapping
49+
function generateFieldDeclaration(field: string, varName: string): string {
50+
return `def ${varName} = $('${field}', null); if (${varName} instanceof List && ${varName}.size() == 1) { ${varName} = ${varName}[0]; }`;
51+
}
52+
53+
// Utility: get the field accessor - uses varMap if provided, otherwise inline accessor
54+
function safePainlessField(conditionOrField: FilterCondition | string, varMap?: FieldVarMap) {
55+
const fieldName =
56+
typeof conditionOrField === 'string' ? conditionOrField : conditionOrField.field;
57+
58+
// If we have a varMap and it contains this field, use the variable name
59+
if (varMap && varMap.has(fieldName)) {
60+
return varMap.get(fieldName)!;
61+
}
62+
63+
return painlessFieldAccessor(fieldName);
2864
}
2965

3066
function generateRangeComparisonClauses(
@@ -64,8 +100,11 @@ function generateRangeComparisonClauses(
64100
}
65101

66102
// Convert a shorthand binary filter condition to painless
67-
function shorthandBinaryToPainless(condition: ShorthandBinaryFilterCondition) {
68-
const safeFieldAccessor = safePainlessField(condition);
103+
function shorthandBinaryToPainless(
104+
condition: ShorthandBinaryFilterCondition,
105+
varMap?: FieldVarMap
106+
) {
107+
const safeFieldAccessor = safePainlessField(condition, varMap);
69108
// Find which operator is present
70109
const op = BINARY_OPERATORS.find((k) => condition[k] !== undefined);
71110

@@ -161,12 +200,12 @@ function shorthandBinaryToPainless(condition: ShorthandBinaryFilterCondition) {
161200
}
162201

163202
// Convert a shorthand unary filter condition to painless
164-
function shorthandUnaryToPainless(condition: ShorthandUnaryFilterCondition) {
203+
function shorthandUnaryToPainless(condition: ShorthandUnaryFilterCondition, varMap?: FieldVarMap) {
165204
if ('exists' in condition) {
166205
if (typeof condition.exists === 'boolean') {
167206
return condition.exists
168-
? `${safePainlessField(condition)} !== null`
169-
: `${safePainlessField(condition)} == null`;
207+
? `${safePainlessField(condition, varMap)} !== null`
208+
: `${safePainlessField(condition, varMap)} == null`;
170209
} else {
171210
throw new Error('Invalid value for exists operator, expected boolean');
172211
}
@@ -176,27 +215,36 @@ function shorthandUnaryToPainless(condition: ShorthandUnaryFilterCondition) {
176215
}
177216

178217
// Main recursive conversion to painless
179-
export function conditionToStatement(condition: Condition, nested = false): string {
218+
export function conditionToStatement(
219+
condition: Condition,
220+
nested = false,
221+
varMap?: FieldVarMap
222+
): string {
180223
if ('field' in condition && typeof condition.field === 'string') {
181224
// Shorthand unary
182225
if ('exists' in condition) {
183-
return shorthandUnaryToPainless(condition as ShorthandUnaryFilterCondition);
226+
return shorthandUnaryToPainless(condition as ShorthandUnaryFilterCondition, varMap);
184227
}
185228
// Shorthand binary
186-
return `(${safePainlessField(condition)} !== null && ${shorthandBinaryToPainless(
187-
condition as ShorthandBinaryFilterCondition
229+
return `(${safePainlessField(condition, varMap)} !== null && ${shorthandBinaryToPainless(
230+
condition as ShorthandBinaryFilterCondition,
231+
varMap
188232
)})`;
189233
}
190234
if ('and' in condition && Array.isArray(condition.and)) {
191-
const and = condition.and.map((filter) => conditionToStatement(filter, true)).join(' && ');
235+
const and = condition.and
236+
.map((filter) => conditionToStatement(filter, true, varMap))
237+
.join(' && ');
192238
return nested ? `(${and})` : and;
193239
}
194240
if ('or' in condition && Array.isArray(condition.or)) {
195-
const or = condition.or.map((filter) => conditionToStatement(filter, true)).join(' || ');
241+
const or = condition.or
242+
.map((filter) => conditionToStatement(filter, true, varMap))
243+
.join(' || ');
196244
return nested ? `(${or})` : or;
197245
}
198246
if ('not' in condition && condition.not) {
199-
return `!(${conditionToStatement(condition.not, true)})`;
247+
return `!(${conditionToStatement(condition.not, true, varMap)})`;
200248
}
201249
// Always/never conditions (if you have them)
202250
if ('always' in condition) {
@@ -217,9 +265,27 @@ export function conditionToPainless(condition: Condition): string {
217265
return `return true`;
218266
}
219267

268+
// Extract all field names and create variable mappings
269+
const fields = extractFieldNames(condition);
270+
const varMap: FieldVarMap = new Map();
271+
const declarations: string[] = [];
272+
273+
for (const field of fields) {
274+
const varName = fieldToVarName(field);
275+
varMap.set(field, varName);
276+
declarations.push(generateFieldDeclaration(field, varName));
277+
}
278+
279+
// declarationsBlock will look like this:
280+
// def val_field1 = $('field1', null); if (val_field1 instanceof List && val_field1.size() == 1) { val_field1 = val_field1[0]; }
281+
const declarationsBlock = declarations.length > 0 ? declarations.join('\n ') + '\n ' : '';
282+
220283
return `
221284
try {
222-
if (${conditionToStatement(condition)}) {
285+
286+
${declarationsBlock}
287+
288+
if (${conditionToStatement(condition, false, varMap)}) {
223289
return true;
224290
}
225291
return false;

0 commit comments

Comments
 (0)