From 45620fee9c05751e6e366cdca429f3ac63e42430 Mon Sep 17 00:00:00 2001 From: Andrew Zamojc Date: Thu, 21 May 2026 22:05:33 -0400 Subject: [PATCH 1/2] fix(DT-4039): Workflow query builder for numeric search attributes The query builder wrapped Int/Double search attribute values in double quotes (e.g. `Foo="1"`), which the Visibility backend rejects for numeric types. Stop quoting numeric values in the serializer. The tokenizer also merged unquoted values with their conditional operator (e.g. `Foo=1` tokenized as `Foo`, `=1`), which previously only mattered for booleans and was patched in the parser by stripping `=`. With numerics this breaks down for 2-char operators (`>=`, `<=`, `!=`). Fix the tokenizer to emit the conditional as its own token and to accept operator-style 2-char conditionals without a trailing space. Update the boolean parser to read the value from `tokenTwoAhead` to match the new shape. --- .../query/filter-workflow-query.test.ts | 68 +++++++++++++++++++ .../utilities/query/filter-workflow-query.ts | 6 ++ .../query/to-list-workflow-filters.test.ts | 35 ++++++++++ .../query/to-list-workflow-filters.ts | 2 +- src/lib/utilities/query/tokenize.test.ts | 3 +- src/lib/utilities/query/tokenize.ts | 11 ++- 6 files changed, 121 insertions(+), 4 deletions(-) diff --git a/src/lib/utilities/query/filter-workflow-query.test.ts b/src/lib/utilities/query/filter-workflow-query.test.ts index 30674f48a4..c605267737 100644 --- a/src/lib/utilities/query/filter-workflow-query.test.ts +++ b/src/lib/utilities/query/filter-workflow-query.test.ts @@ -234,6 +234,74 @@ describe('toListWorkflowQueryFromFilters', () => { }, ); + it('should convert an Int filter without quoting the value', () => { + const filters = [ + { + attribute: 'CustomIntField', + type: 'Int', + conditional: '=', + operator: '', + parenthesis: '', + value: '1', + }, + ]; + const query = toListWorkflowQueryFromFilters(filters); + expect(query).toBe('`CustomIntField`=1'); + }); + + it('should convert a Double filter without quoting the value', () => { + const filters = [ + { + attribute: 'CustomDoubleField', + type: 'Double', + conditional: '>', + operator: '', + parenthesis: '', + value: '1.5', + }, + ]; + const query = toListWorkflowQueryFromFilters(filters); + expect(query).toBe('`CustomDoubleField`>1.5'); + }); + + it('should convert numeric filters alongside keyword filters', () => { + const filters = [ + { + attribute: 'CustomIntField', + type: 'Int', + conditional: '>=', + operator: '', + parenthesis: '', + value: '10', + }, + { + attribute: 'WorkflowId', + type: 'Keyword', + conditional: '=', + operator: '', + parenthesis: '', + value: 'abcd', + }, + ]; + const query = toListWorkflowQueryFromFilters(combineFilters(filters)); + expect(query).toBe('`CustomIntField`>=10 AND `WorkflowId`="abcd"'); + }); + + it('should convert an Int filter with is null conditional', () => { + const filters = [ + { + attribute: 'CustomIntField', + type: 'Int', + conditional: 'is', + operator: '', + parenthesis: '', + value: null, + }, + ]; + const query = toListWorkflowQueryFromFilters(filters); + expect(query).toBe('`CustomIntField` is null'); + }); + it('should convert a KeywordList filter', () => { const filters = [ { diff --git a/src/lib/utilities/query/filter-workflow-query.ts b/src/lib/utilities/query/filter-workflow-query.ts index 367764bc6c..6c89e17e00 100644 --- a/src/lib/utilities/query/filter-workflow-query.ts +++ b/src/lib/utilities/query/filter-workflow-query.ts @@ -59,6 +59,12 @@ const formatValue = ({ ) { return value; } + if ( + type === SEARCH_ATTRIBUTE_TYPE.INT || + type === SEARCH_ATTRIBUTE_TYPE.DOUBLE + ) { + return value; + } return `"${value}"`; }; diff --git a/src/lib/utilities/query/to-list-workflow-filters.test.ts b/src/lib/utilities/query/to-list-workflow-filters.test.ts index 971a930ff2..3966237a91 100644 --- a/src/lib/utilities/query/to-list-workflow-filters.test.ts +++ b/src/lib/utilities/query/to-list-workflow-filters.test.ts @@ -45,6 +45,8 @@ const attributes = { 'Custom Keyword Field': 'Keyword', 'Custom Bool Field': 'Bool', CustomKeywordListField: 'KeywordList', + CustomIntField: 'Int', + CustomDoubleField: 'Double', }; describe('toListWorkflowFilters', () => { @@ -270,6 +272,39 @@ describe('toListWorkflowFilters', () => { expect(result).toMatchObject(expectedFilters); }); + it('should parse a query with an unquoted Int value', () => { + const result = toListWorkflowFilters('`CustomIntField`=1', attributes); + const expectedFilters = [ + { + attribute: 'CustomIntField', + type: 'Int', + conditional: '=', + operator: '', + parenthesis: '', + value: '1', + }, + ]; + expect(result).toMatchObject(expectedFilters); + }); + + it('should parse a query with an unquoted Double value and a comparison operator', () => { + const result = toListWorkflowFilters( + '`CustomDoubleField`>=1.5', + attributes, + ); + const expectedFilters = [ + { + attribute: 'CustomDoubleField', + type: 'Double', + conditional: '>=', + operator: '', + parenthesis: '', + value: '1.5', + }, + ]; + expect(result).toMatchObject(expectedFilters); + }); + it('should parse a query with a KeywordList type', () => { const result = toListWorkflowFilters(keywordListQuery, attributes); const expectedFilters = [ diff --git a/src/lib/utilities/query/to-list-workflow-filters.ts b/src/lib/utilities/query/to-list-workflow-filters.ts index 955612f156..c70762bb36 100644 --- a/src/lib/utilities/query/to-list-workflow-filters.ts +++ b/src/lib/utilities/query/to-list-workflow-filters.ts @@ -129,7 +129,7 @@ export const toListWorkflowFilters = ( console.error('Error parsing Datetime field from query'); } } else if (isBoolStatement(filter.type)) { - filter.value = nextToken.replace('=', ''); + filter.value = tokenTwoAhead; filter.conditional = '='; } else { filter.value = tokenTwoAhead; diff --git a/src/lib/utilities/query/tokenize.test.ts b/src/lib/utilities/query/tokenize.test.ts index 65b5f4ab80..3911cb1815 100644 --- a/src/lib/utilities/query/tokenize.test.ts +++ b/src/lib/utilities/query/tokenize.test.ts @@ -101,7 +101,8 @@ describe('tokenize', () => { 'some workflow', 'AND', 'Custom Boolean', - '=true', + '=', + 'true', ]); }); diff --git a/src/lib/utilities/query/tokenize.ts b/src/lib/utilities/query/tokenize.ts index 663f925201..9aeac2f766 100644 --- a/src/lib/utilities/query/tokenize.ts +++ b/src/lib/utilities/query/tokenize.ts @@ -11,6 +11,10 @@ import { type Tokens = string[]; +const OPERATOR_CONDITIONALS = new Set(['>=', '<=', '!=', '==']); +const isOperatorConditional = (value: string): boolean => + OPERATOR_CONDITIONALS.has(value); + export const tokenize = (string: string): Tokens => { const tokens: Tokens = []; const addBufferToTokens = (): void => { @@ -91,9 +95,11 @@ export const tokenize = (string: string): Tokens => { isConditional(minConditional) && (isSpace(string[cursor + 2]) || isQuote(string[cursor + 2]) || - isParenthesis(string[cursor + 2])) + isParenthesis(string[cursor + 2]) || + isOperatorConditional(minConditional)) ) { - // To prevent false positives like "inspect" being a "in" conditional, check for space, quote, or parenthesis after the midConditional + // To prevent false positives like "inspect" being a "in" conditional, check for space, quote, or parenthesis after the midConditional. + // Operator-style conditionals like >=, <=, != never collide with identifiers, so accept them even without a trailing space. buffer += minConditional; addBufferToTokens(); cursor += 2; @@ -101,6 +107,7 @@ export const tokenize = (string: string): Tokens => { } else if (isConditional(character)) { addBufferToTokens(); buffer += character; + addBufferToTokens(); cursor++; continue; } From 8a768a57517441799e6305e7f056bcd61c710fee Mon Sep 17 00:00:00 2001 From: Andrew Zamojc Date: Fri, 22 May 2026 13:21:17 -0400 Subject: [PATCH 2/2] fix(DT-4039): Update integration tests for unquoted numeric search attributes The desktop and mobile workflow-filter specs asserted the previous buggy serialization (`HistoryLength`="10"). Update them to match the new correct shape (`HistoryLength`=10). --- ...workflows-search-attribute-filter.desktop.spec.ts | 12 +++++------- .../workflows-search-attribute-filter.mobile.spec.ts | 4 +--- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/tests/integration/workflows-search-attribute-filter.desktop.spec.ts b/tests/integration/workflows-search-attribute-filter.desktop.spec.ts index 6daf46b86c..65842fb65b 100644 --- a/tests/integration/workflows-search-attribute-filter.desktop.spec.ts +++ b/tests/integration/workflows-search-attribute-filter.desktop.spec.ts @@ -163,9 +163,7 @@ test('it should filter by HistoryLength (number)', async ({ page }) => { .fill('10'); await page.getByTestId('apply-filter-button').click(); - await expect - .poll(() => getQueryParam(page.url())) - .toBe('`HistoryLength`="10"'); + await expect.poll(() => getQueryParam(page.url())).toBe('`HistoryLength`=10'); }); test('it should combine filters', async ({ page }) => { @@ -188,7 +186,7 @@ test('it should combine filters', async ({ page }) => { await expect .poll(() => getQueryParam(page.url())) - .toBe('`ExecutionStatus`="Completed" AND `HistoryLength`="10"'); + .toBe('`ExecutionStatus`="Completed" AND `HistoryLength`=10'); await page.getByTestId('add-filter-button').click(); await page.getByRole('menuitem', { name: 'WorkflowType Keyword' }).click(); @@ -202,7 +200,7 @@ test('it should combine filters', async ({ page }) => { await expect .poll(() => getQueryParam(page.url())) .toBe( - '`ExecutionStatus`="Completed" AND `HistoryLength`="10" AND `WorkflowType`="ExampleWorkflow"', + '`ExecutionStatus`="Completed" AND `HistoryLength`=10 AND `WorkflowType`="ExampleWorkflow"', ); }); @@ -226,7 +224,7 @@ test('it should combine filters and then clear them all', async ({ page }) => { await expect .poll(() => getQueryParam(page.url())) - .toBe('`ExecutionStatus`="Completed" AND `HistoryLength`="10"'); + .toBe('`ExecutionStatus`="Completed" AND `HistoryLength`=10'); await page.getByTestId('add-filter-button').click(); await page.getByRole('menuitem', { name: 'WorkflowType Keyword' }).click(); @@ -240,7 +238,7 @@ test('it should combine filters and then clear them all', async ({ page }) => { await expect .poll(() => getQueryParam(page.url())) .toBe( - '`ExecutionStatus`="Completed" AND `HistoryLength`="10" AND `WorkflowType`="ExampleWorkflow"', + '`ExecutionStatus`="Completed" AND `HistoryLength`=10 AND `WorkflowType`="ExampleWorkflow"', ); await page.getByTestId('clear-all-filters-button').click(); diff --git a/tests/integration/workflows-search-attribute-filter.mobile.spec.ts b/tests/integration/workflows-search-attribute-filter.mobile.spec.ts index 984e5580c1..cea60108c4 100644 --- a/tests/integration/workflows-search-attribute-filter.mobile.spec.ts +++ b/tests/integration/workflows-search-attribute-filter.mobile.spec.ts @@ -177,9 +177,7 @@ test('it should filter by HistoryLength (number)', async ({ page }) => { .fill('10'); await page.getByTestId('apply-filter-button').click(); - await expect - .poll(() => getQueryParam(page.url())) - .toBe('`HistoryLength`="10"'); + await expect.poll(() => getQueryParam(page.url())).toBe('`HistoryLength`=10'); }); test('it should combine filters and then clear them all', async ({ page }) => {