Found while implementing #10413 phase 1 (PR #10758). Filed rather than widened into that PR: different defect, different remedy.
What the code says
ObjectQLStrategy runs its cross-object envelope check from two call sites, and they are handed different views of the filter:
execute() — this.planCrossObject(cube, query, filter), where filter is the built engine-filter record.generateSql() — this.planCrossObject(cube, query, Object.fromEntries(collectFilterLeaves(normalizeAnalyticsFilterTree(query)).map(...))), i.e. every member the filter touches, flattened.
planCrossObject inspects only Object.keys(filter):
...Object.keys(filter).map((f)=>({where: 'filter',member: f,field: f})),An AND-ed leaf lands at the top level of the record, so it is seen. Anything structural — an $or, a $not, a nested $and whose operands cannot merge — is pushed onto conjuncts and folded into filter.$and, so the only key the check sees for it is the literal $and, which is never a cross-object field name.
So a query whose cross-object reference sits inside a combinator (where: { $or: [{ 'account.industry': 'x' }, ...] }) is not refused by execute() and reaches engine.aggregate, which cannot join — while the same query on /analytics/sqlis refused, because the echo's call site flattens the leaves. That contradicts the invariant the file states for itself:
generateSql() calls this too, so the preview accepts/rejects the same set.
and the reason the refusal exists at all:
the envelope check rejects cross-object filters, so a member it cannot see is a filter it cannot reject
A loud 400 was chosen over a silent mis-bucket; for this shape neither happens on the execution door.
Scope note
This predates #10413 — the caller's own $or has always taken this route. PR #10758 adds one more producer of the same shape (a dataset's definition-level filter travels as an $and conjunct), so a cross-object dataset filter is now in the same blind spot; it does not create the blind spot.
Confidence
Read from the code, in packages/services/service-analytics/src/strategies/objectql-strategy.ts (the two call sites and planCrossObject's member collection). Not executed — no runtime reproduction was run, and the triage should reproduce before sizing. The fix shape is plausibly one line (hand execute()'s call site the same collectFilterLeaves view the echo uses), but whether the refusal set should widen for real callers today is a judgement, not a mechanical fix.
Found while implementing #10413 phase 1 (PR #10758). Filed rather than widened into that PR: different defect, different remedy.
What the code says
ObjectQLStrategyruns its cross-object envelope check from two call sites, and they are handed different views of the filter:execute()—this.planCrossObject(cube, query, filter), wherefilteris the built engine-filter record.generateSql()—this.planCrossObject(cube, query, Object.fromEntries(collectFilterLeaves(normalizeAnalyticsFilterTree(query)).map(...))), i.e. every member the filter touches, flattened.planCrossObjectinspects onlyObject.keys(filter):An AND-ed leaf lands at the top level of the record, so it is seen. Anything structural — an
$or, a$not, a nested$andwhose operands cannot merge — is pushed ontoconjunctsand folded intofilter.$and, so the only key the check sees for it is the literal$and, which is never a cross-object field name.So a query whose cross-object reference sits inside a combinator (
where: { $or: [{ 'account.industry': 'x' }, ...] }) is not refused byexecute()and reachesengine.aggregate, which cannot join — while the same query on/analytics/sqlis refused, because the echo's call site flattens the leaves. That contradicts the invariant the file states for itself:and the reason the refusal exists at all:
A loud 400 was chosen over a silent mis-bucket; for this shape neither happens on the execution door.
Scope note
This predates #10413 — the caller's own
$orhas always taken this route. PR #10758 adds one more producer of the same shape (a dataset's definition-levelfiltertravels as an$andconjunct), so a cross-object dataset filter is now in the same blind spot; it does not create the blind spot.Confidence
Read from the code, in
packages/services/service-analytics/src/strategies/objectql-strategy.ts(the two call sites andplanCrossObject's member collection). Not executed — no runtime reproduction was run, and the triage should reproduce before sizing. The fix shape is plausibly one line (handexecute()'s call site the samecollectFilterLeavesview the echo uses), but whether the refusal set should widen for real callers today is a judgement, not a mechanical fix.