Search mixing AND and OR without parentheses returns wrong rows
What happened?
A search that mixes AND and OR without explicit parentheses returns the wrong rows, silently. The generated ClickHouse SQL groups the operators differently from how the search was parsed.
Example search:
a:1 AND b:2 OR c:3This is parsed as a AND (b OR c) — i.e. a:1 is required. But the generated SQL drops the grouping and comes out flat, so ClickHouse (where AND binds tighter than OR) evaluates it as (a AND b) OR c. In that version a:1 is not required — any row matching just c:3 is returned. There's no error; the results are just wrong.
Real-world shape: status:error AND service:api OR level:debug is meant to be "errors from the api service, or-ing the debug condition inside that scope", but executes as "(error AND api) OR any debug log anywhere", pulling in debug logs from every other service.
Steps to reproduce
Generate the SQL for a:"1" AND b:"2" OR c:"3" (any String columns):
((a = '1') AND (b = '2') OR (c = '3'))Compare with the explicitly-parenthesized a:"1" AND (b:"2" OR c:"3"):
((a = '1') AND ((b = '2') OR (c = '3')))The parenthesized version keeps the inner grouping; the unparenthesized one doesn't — even though the parser produces the same grouping for both.
How are you running HyperDX?
Reproduced against packages/common-utils at commit 5fc33413 (Node 22.23.1 per .nvmrc, yarn 4.13.0), via SearchQueryBuilder(query, serializer).build() and @hyperdx/lucene's parse(). No running stack needed. Full repro test below.
Where does it show up?
Query generation in packages/common-utils/src/queryParser.ts — the binary-AST branch of serialize() (~line 2148). Parentheses are emitted based on whether the source was parenthesized, not on whether a child node is a binary expression whose operator differs from its parent's. Affects every log / trace / dashboard search that mixes AND and OR without explicit parentheses.
Logs
Verbatim from the repro test:
PARSED AS : a AND (b OR c)
EMITTED SQL : ((a = '1') AND (b = '2') OR (c = '3'))
WITH PARENS : ((a = '1') AND ((b = '2') OR (c = '3')))
row a=F,c=T parsed a AND (b OR c) = false (should NOT match)
row a=F,c=T emitted (a AND b) OR c = true (DOES match — wrong)@hyperdx/lucene AST for a:1 AND b:2 OR c:3 is right-nested: { left: a, operator: 'AND', right: { left: b, operator: 'OR', right: c } }.
packages/common-utils/src/__tests__/ and run yarn jest)import lucene from '@hyperdx/lucene';
import { ClickhouseClient } from '@/clickhouse/node';
import { getMetadata } from '@/core/metadata';
import { CustomSchemaSQLSerializerV2, SearchQueryBuilder } from '@/queryParser';
function makeSerializer() {
const metadata = getMetadata(new ClickhouseClient({ host: 'http://localhost:8123' }));
const m = metadata as any;
m.getColumn = jest.fn(async ({ column }: any) => ({ name: column, type: 'String' }));
m.getColumns = jest.fn(async () => []);
m.getMaterializedColumnsLookupTable = jest.fn(async () => new Map());
m.getSkipIndices = jest.fn(async () => []);
m.getSetting = jest.fn(async () => '0');
m.getServerVersion = jest.fn(async () => undefined);
m.isClickHouseCloud = jest.fn(async () => false);
m.getMapTextIndexKeyValues = jest.fn(async () => new Map());
return new CustomSchemaSQLSerializerV2({
metadata, databaseName: 'd', tableName: 't', connectionId: 'c',
implicitColumnExpression: 'Body',
});
}
it('emitted SQL loses the grouping the parser produced', async () => {
const ser = makeSerializer();
const flat = await new SearchQueryBuilder('a:"1" AND b:"2" OR c:"3"', ser).build();
expect(flat).toBe("((a = '1') AND (b = '2') OR (c = '3'))");
// What it SHOULD keep — the inner OR grouped:
expect(flat).toContain("((b = '2') OR (c = '3'))"); // fails today; passes once fixed
});Source: hyperdxio/hyperdx