Found while implementing #10298 (PR #10411), which repairs this on NativeSQLStrategy. Filed rather than widened into that PR: the remedy here is a different one and touches a contract that card does not own.
What is measured
#10298 was reported and reproduced against the native-SQL path — the returned sql field is what made it visible. AnalyticsService has a second strategy, ObjectQLStrategy, which serves any deployment whose driver reports objectqlAggregate but not nativeSql (MongoDB, the memory driver, anything routed through engine.aggregate rather than raw SQL). That path drops the same declarations, and no sql echo makes it visible.
Measured on origin/main at 801296050, with a dataset carrying both a definition-level filter and a per-measure filter, and a service whose capabilities are { nativeSql: false, objectqlAggregate: true } so NativeSQLStrategy declines:
DatasetSchema.parse({
name: 'opportunity_metrics', object: 'crm_opportunity',
filter: { is_deleted: false },
measures: [
{ name: 'opp_count', label: 'Opportunities', aggregate: 'count' },
{ name: 'won_count', label: 'Won Deals', aggregate: 'count', filter: { stage: 'closed_won' } },
{ name: 'won_amount', label: 'Won Revenue', aggregate: 'sum', field: 'amount',
filter: { stage: 'closed_won' } },
],
});
svc.query({ cube: 'opportunity_metrics', measures: ['opp_count','won_count','won_amount'] }) reaches engine.aggregate as:
{
"obj": "crm_opportunity",
"options": {
"aggregations": [
{ "field": "*", "method": "count", "alias": "opp_count" },
{ "field": "*", "method": "count", "alias": "won_count" },
{ "field": "amount", "method": "sum", "alias": "won_amount" }
]
}
}
No filter key at all — neither the dataset's is_deleted: false scope nor either measure's stage: 'closed_won'. won_count is a plain row count and won_amount sums every row, exactly the arithmetic #10298 reported from the SQL door. The dashboard door on the same deployment still answers the filtered numbers, because DatasetExecutor fans out per filtered measure and applies them itself — so the two-door disagreement #10298 describes survives on this path with #10411 merged.
Why it was not fixed in #10411
The native-SQL repair emits one conditional aggregate per filtered measure (COUNT(CASE WHEN … THEN … END)) inside a single statement. engine.aggregate's contract has no equivalent: an aggregation is { field, method, alias } and the only filter is one predicate for the whole call, so per-measure scoping cannot be expressed there at all.
That leaves two shapes, and choosing between them is a decision rather than a mechanical fix:
- Widen the aggregate contract with a per-aggregation filter (
@objectstack/objectql + the executeAggregate bridge on StrategyContext, which lives in packages/spec), then lower each measure filter into it. Correct once, for every driver — and a public-surface change.
- Fan out inside
ObjectQLStrategy, one engine.aggregate call per filtered measure merged by dimension key — which is what DatasetExecutor.runMeasurePass already does one layer up. No contract change, N extra reads per query, and a second implementation of a rule that already exists.
The dataset-level filter is the easier half and may not need the same decision: it is one predicate for the whole call, which engine.aggregate already accepts, so it could be merged into the strategy's existing filter object with no contract movement.
Reproduction
The probe above, run against packages/services/service-analytics/src/analytics-service.ts with a stub executeAggregate that records its arguments. The behaviour is in ObjectQLStrategy.execute, which builds aggregations from resolveMeasureAggregation(cube, measure) and its filter from normalizeAnalyticsFilterTree(query) — the caller's where and the time windows only; nothing consults the dataset registry.
Related: #10298 · PR #10411.
Found while implementing #10298 (PR #10411), which repairs this on
NativeSQLStrategy. Filed rather than widened into that PR: the remedy here is a different one and touches a contract that card does not own.What is measured
#10298was reported and reproduced against the native-SQL path — the returnedsqlfield is what made it visible.AnalyticsServicehas a second strategy,ObjectQLStrategy, which serves any deployment whose driver reportsobjectqlAggregatebut notnativeSql(MongoDB, the memory driver, anything routed throughengine.aggregaterather than raw SQL). That path drops the same declarations, and nosqlecho makes it visible.Measured on
origin/mainat801296050, with a dataset carrying both a definition-levelfilterand a per-measurefilter, and a service whose capabilities are{ nativeSql: false, objectqlAggregate: true }soNativeSQLStrategydeclines:svc.query({ cube: 'opportunity_metrics', measures: ['opp_count','won_count','won_amount'] })reachesengine.aggregateas:{ "obj": "crm_opportunity", "options": { "aggregations": [ { "field": "*", "method": "count", "alias": "opp_count" }, { "field": "*", "method": "count", "alias": "won_count" }, { "field": "amount", "method": "sum", "alias": "won_amount" } ] } }No
filterkey at all — neither the dataset'sis_deleted: falsescope nor either measure'sstage: 'closed_won'.won_countis a plain row count andwon_amountsums every row, exactly the arithmetic #10298 reported from the SQL door. The dashboard door on the same deployment still answers the filtered numbers, becauseDatasetExecutorfans out per filtered measure and applies them itself — so the two-door disagreement #10298 describes survives on this path with #10411 merged.Why it was not fixed in #10411
The native-SQL repair emits one conditional aggregate per filtered measure (
COUNT(CASE WHEN … THEN … END)) inside a single statement.engine.aggregate's contract has no equivalent: an aggregation is{ field, method, alias }and the onlyfilteris one predicate for the whole call, so per-measure scoping cannot be expressed there at all.That leaves two shapes, and choosing between them is a decision rather than a mechanical fix:
@objectstack/objectql+ theexecuteAggregatebridge onStrategyContext, which lives inpackages/spec), then lower each measure filter into it. Correct once, for every driver — and a public-surface change.ObjectQLStrategy, oneengine.aggregatecall per filtered measure merged by dimension key — which is whatDatasetExecutor.runMeasurePassalready does one layer up. No contract change, N extra reads per query, and a second implementation of a rule that already exists.The dataset-level
filteris the easier half and may not need the same decision: it is one predicate for the whole call, whichengine.aggregatealready accepts, so it could be merged into the strategy's existingfilterobject with no contract movement.Reproduction
The probe above, run against
packages/services/service-analytics/src/analytics-service.tswith a stubexecuteAggregatethat records its arguments. The behaviour is inObjectQLStrategy.execute, which buildsaggregationsfromresolveMeasureAggregation(cube, measure)and itsfilterfromnormalizeAnalyticsFilterTree(query)— the caller'swhereand the time windows only; nothing consults the dataset registry.Related: #10298 · PR #10411.