#3032·hyperdx

Search mixing AND and OR without parentheses returns wrong rows

Author: sandeep-07Created Aug 30, 2026Updated Sep 16, 2026
Labelsbugexternal

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:3

This 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 } }.

Repro test (drop into packages/common-utils/src/__tests__/ and run yarn jest)
typescript
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
});