[core-spec]: add FILTER (WHERE ...) aggregate modifier - #382
christianeu-db wants to merge 2 commits into
Conversation
Define the SQL-standard FILTER (WHERE <predicate>) postfix modifier on aggregate expressions in the Conditional Aggregations (REQUIRED) section of core-spec/expression_language.md. The clause has SQL:2003 <filter clause> semantics (optional feature T612): the aggregate is computed over only the rows where the predicate is TRUE, with the aggregate's own empty-input value when no row matches. The change is additive and backward compatible. It adds no schema node and no field to Metric, Field, or Dataset; the existing CASE form stays valid. The predicate MUST reference only fields of the same dataset as the aggregate's arguments. Clarifies that FILTER (WHERE ...) is an aggregate modifier, distinct from the unsupported standalone WHERE clause. Co-authored-by: Isaac <no-reply@databricks.com>
|
Two things from running this against actual engines, plus one from the compiler side. First, a confirmation. On PostgreSQL 18 and DuckDB 1.5, with a four-row Second, something the text could state so that two consumers do not disagree. The Under #354 these are two different, both legitimate results: an intrinsic Third, on "Engines without native |
|
Thanks for the feedback! Replies below:
Glad you flagged this divergence in #29 as well. Originally, this PR was motivated by filter composition but the subtle issues with case are another reason to push towards a standard solution.
I can update the description / documentation to cover that the filter acts inside the aggregate / doesn't remove aggregate groups. This was a bit of a tricky balance - we mostly wanted to defer to the standard rather than restate it (to avoid diverging). In terms of lowering / optimization, that is up to the engine so long as it doesn't change the output. There are certainly some cases where it is safe to push the filter down into a standard WHERE clause but, to your point, that's not always the case.
Good callout - it's worth putting one example of the different lowering to give a flavor to engines. |
- State that FILTER is applied to the aggregate's input, not a query WHERE, so it never removes output groups. - Add two CASE-lowering examples (value aggregate and COUNT(*)). - Soften "<predicate> is TRUE" to "<predicate> succeeds" so engines with truthy (non-strict-TRUE) evaluation are covered. Co-authored-by: Isaac <no-reply@databricks.com>
Summary
Adds the SQL-standard
FILTER (WHERE <predicate>)modifier to aggregate expressions inOssie_SQL_2026, extending the filtered-aggregation capability already marked REQUIRED by the expression language. A metric MAY attach a per-aggregate predicate, for exampleSUM(store_sales.ss_ext_sales_price) FILTER (WHERE store_sales.ss_ext_sales_price > 0).The change is confined to
core-spec/expression_language.md— specifically its Conditional Aggregations (REQUIRED) section. It adds no new schema node and no new field toMetric,Field, orDataset. Filtered aggregation was already listed as REQUIRED in the expression language ("All aggregations should support filtered aggregation"), but the only spelled-out form was theCASE WHENrewrite.The change is additive and backward compatible. Every existing model remains valid, and the
CASE WHENform remains supported.This PR was motivated by the "declare a filter on a metric, then layer filters on top" pain point from discussion #342. Query-time layering itself is enabled via
MEASURE()and is defined here for single-aggregate metrics; see Composition.Motivation
Many metrics include a filter as a core part of their definitions: "valid sales only", "domestic customers", "completed orders". Ossie metrics expose a single
expression, so today filter logic has to be folded into the aggregate withCASE WHEN:Three problems follow from this:
CASEbody. The filter has no explicit syntactic role.CASEexpression that repeats both predicates.CASE ... ELSE 0idiom is subtly wrong for some aggregates. It works forSUM, butAVG,COUNT,MIN, andMAXneedELSE NULL(and careful reasoning about what a substituted zero does to the result), which is a common source of incorrect metrics.FILTER (WHERE ...)addresses these as a composable building block. On its own it fixes problems 1 and 3: the predicate has an explicit syntactic role rather than being encoded indirectly in aCASEexpression, and its empty-input behavior is the aggregate's own well-defined behavior rather than a hand-chosen sentinel. Problem 2 — layering a context filter onto an existing metric — is solved not byFILTERalone but byMEASURE(m) FILTER (WHERE ...)where the Relational Query Interface (#354) is available; this iteration defines that layering only for single-aggregate metrics (see Composition).Prior art
FILTER (WHERE ...)is defined in SQL:2003 as the<filter clause>on a set function — optional feature T612 — the same standard the Ossie expression language is based on. Engines split into those that support it natively and those that require aCASErewrite:FILTER (WHERE ...)CASErewriteEngines in the second group have a mechanical, well-known
CASE-rewrite, so a converter can always lower the clause to a supported form. This is the same portability posture the expression language already takes for constructs such asAPPROX_COUNT_DISTINCT.Why an expression-language extension rather than a first-class
filtersnode. Discussion #342 opened with a proposal for a top-levelfiltersobject. This PR intentionally addresses aggregate-local filtering through the existing expression language: a predicate on an aggregate is already within the expression language's remit and needs no new node or resolution rule. Named / shared filters, cross-dataset filters, and parameterized filters require additional modeling and resolution semantics and remain out of scope.Proposed change
Extend
core-spec/expression_language.mdto define the aggregateFILTERclause. The clause is a postfix modifier on an aggregate expression:FILTERMAY modify an aggregate expression. This includes ordinary aggregate function calls (SUM,COUNT, ...) and, where the Relational Query Interface (#354) is supported,MEASURE(...).The clause is added under the existing Conditional Aggregations (REQUIRED) section, alongside the
DISTINCTmodifier and theCASE-based form the section already describes, so that filtered aggregation has one explicit, portable spelling. The default dialect (Ossie_SQL_2026) supports it; dialect-specific expressions MAY continue to use their engine's native form.Because the language's Not Supported table lists a bare
WHEREclause as unsupported ("Use filter property instead"), the spec text will state explicitly thatFILTER (WHERE ...)is a postfix aggregate modifier and is not the unsupported standaloneWHEREclause, to avoid a reading conflict.Aggregate FILTER semantics
The
FILTER (WHERE <predicate>)clause has the semantics of the SQL<filter clause>(SQL:2003 optional feature T612). FILTER is applied to the aggregate's input, not as a query WHERE, so it never removes output groups.The one behavior worth calling out against the
CASErewrite: when no row matches, the aggregate sees a genuinely empty input and returns its correct empty-input value automatically, whereasCASE ... ELSE <sentinel>forces the author to hand-pick that value.Composition with metrics and MEASURE()
The following rules are normative and specific to Ossie. They apply only where the Relational Query Interface (#354) is available, since they concern layering a
FILTERclause onto an existing metric viaMEASURE(). Ossie has not yet defined general measure composition, so this iteration defines contextual filtering only for the single-aggregate case; the composed / multi-aggregate case is left to a future measure-composition proposal.FILTERclause written into a metric definition is its intrinsic filter. AFILTERclause layered onto that metric at query time viaMEASURE()is a contextual filter. Both are ordinaryFILTER (WHERE ...)clauses; the terms name where the clause was written, not two different constructs.AND. This iteration defines contextual filtering only for a metric whose definition is a single aggregate expression that MAY carry an intrinsicFILTER. For such a metricm— saym = SUM(x) FILTER (WHERE p)— a referenceMEASURE(m) FILTER (WHERE q)MUST evaluate exactly asSUM(x) FILTER (WHERE p AND q). Whenmhas no intrinsic filter, the effective predicate is justq. A contextual filter MUST NOT weaken or replace an intrinsic filter. Because composition is byAND, a contextual predicate that contradicts the intrinsic one simply produces empty filtered input, which returns the aggregate's ordinary empty-input value — no special-casing is required.MEASURE()interaction.MEASURE(m)behaves as an aggregate, soMEASURE(m) FILTER (WHERE q)MUST applyqper rule 2.Scope and resolution constraints
The following is an Ossie modeling restriction for this iteration, not part of SQL
FILTERsemantics:FILTER (WHERE ...)predicate MUST reference only fields of the same dataset as the base aggregate. A predicate that references another dataset is out of scope for this iteration and MUST be rejected. This avoids coupling filtered aggregation to relationship-path resolution, which is being specified separately.Examples
Intrinsic filter in a metric definition (uses
FILTERalone; noMEASURE()required):Reusing a boolean field on the dataset as the predicate (reference an existing field instead of re-authoring the condition). Here
store_sales.is_valid_saleis a boolean field on thestore_salesdataset — the only kind of reuse this iteration supports. The same pattern should extend to a boolean dimension in the future, once Ossie defines how to build measures from dimensions:Contextual filter layered at query time (requires the Relational Query Interface, #354). This is well-defined because
northern_salesis a single aggregate; the effective predicate isss_store_region = 'North' AND ss_category = 'Toys':Open questions
FILTER (WHERE ...)support be REQUIRED of the default dialect. Whether it should instead be only RECOMMENDED — with theCASE-rewrite as the portable fallback for engines and converters that do not implement it natively — is open for discussion. Filtered aggregation is already REQUIRED; this question is specifically about whether theFILTERspelling itself must be supported.Related Issues
Discussion: #342, #5. Related PRs: #354 (Relational Query Interface /
MEASURE()), #246 (Foundational Semantics).Checklist
Specification
core-spec/and follow the existing structure —core-spec/expression_language.md[DISCUSS]thread ondev@ossie.apache.orgis still to comeOntology
ontology/are consistent with spec changes — N/A, no ontology changeConverters
converters/is updated to reflect spec or ontology changesValidation
validation/are updated if the spec changedDocumentation
docs/is updated to reflect any user-facing changes — the change is itself a spec documentCONTRIBUTING.mdis updated if the contribution process changed — N/AExamples
examples/are added or updated for any new spec constructs or converter support — add a filtered-metric example to the TPC-DS modelTests
pytest/ CI green)Compliance
ossie/filter-where-spec.mdAI disclosure
Per the ASF Generative Tooling Guidance, this contribution was prepared with AI assistance. All specification decisions and design choices are mine. I have reviewed and verified every change.