Repository navigation
test(compliance): add portability rungs M4-M6 and the FILTER (WHERE) form of M3 - #631
MrBlack1995 wants to merge 12 commits into
Conversation
One dataset with YEAR and DATE_TRUNC fields and an AVG(DATEDIFF(...)) metric, using the part-first DATEDIFF(part, start, end) order from expression_language.md. Adds one construct on top of the baseline. Co-authored-by: Isaac <no-reply@databricks.com>
expression_language.md makes the aggregate FILTER (WHERE ...) clause REQUIRED. Two variants (qualified and bare) express the same filtered aggregate as the existing CASE WHEN fixtures, so a difference between the two forms isolates handling of the clause itself. Co-authored-by: Isaac <no-reply@databricks.com>
Two datasets joined by a declared relationship and one model-scoped metric that aggregates across the join (SUM over one dataset divided by a COUNT DISTINCT over the other), referencing fields as dataset.field. Co-authored-by: Isaac <no-reply@databricks.com>
A model-scoped metric (avg_order_value) composed from two other model-scoped metrics referenced by name, per Metric Scoping in core-spec/spec.md. Co-authored-by: Isaac <no-reply@databricks.com>
Lists the M3 FILTER (WHERE ...) form and rungs M4-M6 in the README table, notes that M7 and M8 wait on the window ORDER BY NULL-placement default, and records that the fixtures live in this repository. Co-authored-by: Isaac <no-reply@databricks.com>
|
@kayemkim would you be able to approve the workflows and take a look when you have a moment? Happy to adjust anything. |
jbonofre
left a comment
There was a problem hiding this comment.
LGTM! Thanks @MrBlack1995!
I checked out the branch and ran the grader test and validate.py on the new and changed M3-M6 fixtures, including SQL validation, and all pass. I also graded the M4-M6 fixtures against themselves, and every finding comes back PORTABLE.
I see two potential follow-ups:
test_identity_on_real_m0_m3_fixtures_is_all_portableonly loops overm0-m3, so the new M4-M6 fixtures are not covered by the regression test. Please add"m4", "m5", "m6"to the loop and update the "M0-M3" wording ingrader/README.md.- M5 has
reference_form_primary: qualified, but its only variant isreference_form: metric-name. - M6's
element_under_testiskind: fields and metricwith a comma-joined name, so lookups by kind and name won't find it. Splitting it into separate elements, or picking one primary element, would fix that.
…t by kind and name Follow-ups from the review of apache#631: - The identity regression test looped over M0-M3 only; it now covers M0-M6, and the grader README says so. - M5's reference_form_primary said "qualified", but its only variant (the composite under test) references metric names; it is now "metric-name". - M6 named "fields and metric" with a comma-joined name, and M2 named "metrics" without a name, so a lookup by kind and name found neither. Every rung now lists elements_under_test, each with one kind (metric or field) and one name. Two new tests keep the manifest honest: every listed element is found by kind and name in each variant of its rung, and each primary variant uses the rung's primary reference form. Both fail on the previous manifest. Co-authored-by: Isaac <no-reply@databricks.com>
A metric expression cannot refer to other metrics (core-spec/spec.md, Metric Scoping; expression_language.md), and since apache#608 validate.py warns when one does. M5's composite, avg_order_value = total_amount / order_count, referenced the two metrics by name. The "Referenced as metric_name" row in the Metric Scoping table is how a query addresses a metric, not what an expression may contain. M5 now carries the composition the way a converter has to emit it when the source tool composes measures: the parts inlined, over qualified dataset.field references, SUM(sales.amount) / NULLIF(COUNT(sales.order_id), 0), NULL when there are no orders. The two base metrics stay alongside it. The fixture validates with no warnings on main's validator, and its primary reference form is qualified again. Co-authored-by: Isaac <no-reply@databricks.com>
|
Thanks a lot for checking it out and running everything! All three points are addressed:
While checking M5 against current main I found one more thing: since #608, One note for later, not for this PR: since #486 and #608, the bare variants of M0, M2 and M3 get "is not qualified" warnings, because a model-scoped metric must use |
|
@jbonofre thanks again for the review and for running the fixtures yourself, the follow-ups made the manifest a lot more useful. Pushing them dismissed your approval, so whenever you have a moment for another look: the changes are the two commits after 9e425fe, summarized in my comment above. Thanks! |
…form means expression_language.md (Conditional Aggregations) states that FILTER (WHERE ...) is equivalent to a CASE with no ELSE for an aggregate that ignores NULL, and that an ELSE 0 "would count as a real row". M3's CASE fixtures wrote ELSE 0, so on rows that exist but do not match they give 0 where the FILTER variants added here give NULL; they were not the same aggregate the rung describes. Found by idk-arsh's execution check on apache/ossie-converters#24. Both CASE fixtures now read SUM(CASE WHEN ... THEN amount END), which also matches DAX (CALCULATE over no rows is BLANK). All four M3 fixtures validate on main. Co-authored-by: Isaac <no-reply@databricks.com>
|
One more commit since my last note: 4baea94 drops |
…grade them Since apache#486 a model-scoped metric must reference fields as dataset.field, and since apache#608 validate.py warns on the bare variants of M0, M2 and M3 ("is not qualified"). Metric Scoping in core-spec/spec.md gives the bare form its place: a dataset-scoped metric (datasets[].metrics) references its own dataset's fields by single-part name. The four bare fixtures now carry their metrics there, and validate without warnings on main. The grader read only the model-root metrics, so a dataset-scoped metric was not graded at all, and the identity test still passed. It now collects datasets[].metrics too, keyed dataset.metric as the spec references them, and the identity test asserts that every metric in a fixture is graded. New tests: a dataset-scoped metric graded PORTABLE, dropped, and moved to the model (REWRITTEN, identity not preserved). All four fail without the grader change. Co-authored-by: Isaac <no-reply@databricks.com>
|
Thanks @MrBlack1995! I'm doing a new pass 😄 |
| expression: | ||
| dialects: | ||
| - dialect: ANSI_SQL | ||
| expression: SUM(amount) FILTER (WHERE status = 'completed') |
There was a problem hiding this comment.
This metric sits in the model-root metrics. Since #486, the spec says model-scoped metric expressions must qualify field names as dataset.field, "even when only one dataset has a field with that name" (spec.md, Metric Scoping rule 3 and the Name Resolution examples).
So SUM(amount) FILTER (WHERE status = 'completed') is not a valid document here, even though validate.py lets it through. A converter that follows the spec and rejects or requalifies it would be scored REJECTED or REWRITTEN for doing the right thing.
With #343 closed, bare references are valid only in dataset-scoped metric (datasets[].metrics).
Could we move the bare variants there, under sales? The grader's _collect and the test's _names only read root metrics today, so they would need to pick up dataset-scoped metrics as well.
The same applies to m3_filtered_agg_bare.ossie.yaml, which this PR also edits.
The M0/M2 bare variants already on main have the same problem.
I'm fine with fixing those in follow-up.
There was a problem hiding this comment.
Agreed, and I had the follow-up ready, so it is in this PR now (f0ae6ca) rather than later: all four bare variants (M0, M2 and both M3) carry their metrics under sales in datasets[].metrics, and validate on main without the "is not qualified" warnings. The grader's _collect now also reads dataset-scoped metrics, keyed sales.<metric> as the spec references them (it skipped them before, and the identity test still passed, so that test now also asserts every metric was graded). Three new tests: a dataset-scoped metric graded PORTABLE, dropped, and moved to the model root (REWRITTEN).
| expression: | ||
| dialects: | ||
| - dialect: ANSI_SQL | ||
| expression: AVG(DATEDIFF('day', sales.order_date, sales.ship_date)) |
There was a problem hiding this comment.
The spec writes the date part as a bar keyword: DATEDIFF(day, start_date, end_date) (expression_language.md Date Functions).
DATEDIFF('day', ...) with quotes is the Tableau spelling in the Date Function Mapping table.
A converter that returns the spec form would be graded REWRITTEN ("normalized trees differ"), because sqlglot parses 'day' as a string literal and day as a column.
| expression: AVG(DATEDIFF('day', sales.order_date, sales.ship_date)) | |
| expression: AVG(DATEDIFF(day, sales.order_date, sales.ship_date)) |
DATE_TRUNC('month', order_date) is fine as it is, since the spec quotes the part there.
There was a problem hiding this comment.
Thanks, fixed in 008c947: AVG(DATEDIFF(day, sales.order_date, sales.ship_date)). DATE_TRUNC('month', ...) stays quoted, as the spec writes it.
| description: date and time scalars from the portable core (YEAR, DATE_TRUNC, DATEDIFF part-first) | ||
| elements_under_test: | ||
| - kind: field | ||
| name: order_year |
There was a problem hiding this comment.
The grader keys fields as dataset.field, but the manifest names them bare: order_year and order_month_start here, and amount_with_tax in M1.
Anyone matching manifest elements to report.findings by (kind, name) won't find a result for any M1 or M6 field.
The new test doesn't catch this because _names in test_grade.py builds its own bare-name set instead of using the grader's keys.
Could we name fields the way the grader does (sales.order_year and so on) and have the test reuse _collect, so the two can't drift apart? For example:
def _names(document, kind):
return {name for (element_kind, name) in grade._collect(document) if element_kind == kind}There was a problem hiding this comment.
Done in 008c947: fields are named as the grader reports them (sales.amount_with_tax, sales.order_year, sales.order_month_start), and _names is now your version on top of grade._collect. Since the bare variants now hold dataset-scoped metrics (sales.total_amount in the grader), those four variants say metrics_in: sales, and the test expects <dataset>.<metric> for them. The test fails on the previous manifest; 18 grader tests pass.
…pelling in M6 Follow-ups from the second review of apache#631: - The manifest named fields bare (amount_with_tax, order_year, order_month_start), but the grader reports a field as dataset.field, so matching manifest elements to findings found no result for M1 or M6. Fields are now named sales.<field>, and a variant that carries the rung's metrics as dataset-scoped metrics says so with metrics_in: sales (the grader reports those as sales.<metric>). The manifest test now uses the grader's own _collect keys instead of building a name set of its own, so the two cannot drift apart; it fails on the previous manifest. - M6 wrote DATEDIFF('day', ...), the Tableau spelling in the mapping table; the spec writes the part as a keyword, DATEDIFF(day, ...). Co-authored-by: Isaac <no-reply@databricks.com>
…ters#24 Issue #576 was transferred to apache/ossie-converters#24 with the converters; the ladder, grader and manifest still cited the old number, which now resolves to nothing. Co-authored-by: Isaac <no-reply@databricks.com>
|
@MrBlack1995 I'm back on this one 😄 |
| expression: | ||
| dialects: | ||
| - dialect: ANSI_SQL | ||
| expression: SUM(sales.amount) / COUNT(DISTINCT customers.customer_id) |
There was a problem hiding this comment.
The grader can't tell this expression apart from the two ways a converter is most likely to break it.
_equivalent_tree drops every column table qualifier before comparing. Until now every rung had a single dataset, so that only erased spelling. Here customer_id is declared by both sales and customers, so the qualifier carries meaning. Against main grader, all three of these come back REEWRITTEN "cosmetic (normalized trees equal)":
SUM(sales.amount) / COUNT(DISTINCT customers.customer_id): as authored.SUM(sales.amount) / COUNT(DISTINCT sales.customer_id): join collapsed, now also counts customer ids with no customer row.SUM(amount) / COUNT(DISTINCT customer_id): unqualified, ambiguous at model scope (validate.pyonly warns).
interop/portability.py in ossie-converters counts a "cosmetic" REWRITTEN as portable. So a converter that loses the cross-dataset reference scores as portable on the rung that exists to measure it. _repair_by_expression has the same blind spot: a metric that is renamed and also re-pointed to sales.customer_id is paired as "expression equivalent".
To fix that, I suggest to keep the qualifier for any field name that more than one dataset in the input declares, and strip it as before otherwise. M0-M3 each have one dataset, so their grades don't change.
def _ambiguous_fields(document):
"""Field names declared by more than one dataset. For these the dataset
qualifier says which column is meant, so normalization must keep it."""
owners = {}
if isinstance(document, dict):
for dataset in document.get("datasets") or []:
if not isinstance(dataset, dict):
continue
for field_def in dataset.get("fields") or []:
if isinstance(field_def, dict) and field_def.get("name"):
owners.setdefault(str(field_def["name"]).lower(), set()).add(dataset.get("name"))
return frozenset(name for name, datasets in owners.items() if len(datasets) > 1)In _equivalent_tree(left, right, keep_qualified=frozenset()):
for column in tree.find_all(exp.Column):
if column.name.lower() in keep_qualified:
# Declared by more than one dataset: the qualifier is meaning.
continue
# Drop the table qualifier so sales.amount and amount compare equal.
column.set("table", None)then compute keep_qualified = _ambiguous_fields(input_document) once in grade() and pass it through _classify_present and _repair_by_expression to both _equivalent_tree calls. I ran this against main grader: the existing tests still pass, and the M4 cases come back as review.
There was a problem hiding this comment.
Thanks, good catch: M4 is the first rung where the qualifier carries meaning, and the grader erased it. Applied your approach in 3505677: _ambiguous_fields() reads the field names more than one input dataset declares, grade() computes it once and passes it through _classify_present and _repair_by_expression to both _equivalent_tree calls. M0-M3 grade as before (one dataset each). I also checked the converters side: interop/portability.py in ossie-converters#141 and the #65 runner both go through this grader and do no comparison of their own, so they pick the fix up with the next submodule bump.
| # not a drop plus an unrelated addition. | ||
| assert outcomes[("metric", "sales.total_amount")] == grade.REWRITTEN | ||
| assert ("metric", "total_amount") not in outcomes | ||
|
|
There was a problem hiding this comment.
Could we add regression tests for M4 with the fix? On the current grader, the first and third tests below fail, and with the change above all three pass. The second makes sure that requoting a shared field still counts as as cosmetic, and that amount (declared by only one dataset) still loses its qualifier.
def _m4_document(metric_expression="SUM(sales.amount) / COUNT(DISTINCT customers.customer_id)"):
def field(name):
return {"name": name, "expression": {"dialects": [{"dialect": "ANSI_SQL", "expression": name}]}}
return {
"datasets": [
{"name": "sales", "fields": [field("amount"), field("customer_id")]},
{"name": "customers", "fields": [field("customer_id")]},
],
"metrics": [_metric("revenue_per_customer", "ANSI_SQL", metric_expression)],
}
def _m4_metric_finding(returned_expression):
report = grade.grade(_m4_document(), _m4_document(returned_expression))
return next(f for f in report.findings if f.name == "revenue_per_customer")
def test_a_field_shared_by_two_datasets_keeps_its_qualifier():
# customer_id is declared by sales and customers, so the dataset is meaning.
for returned in (
"SUM(sales.amount) / COUNT(DISTINCT sales.customer_id)",
"SUM(amount) / COUNT(DISTINCT customer_id)",
):
finding = _m4_metric_finding(returned)
assert finding.outcome == grade.REWRITTEN, returned
assert finding.detail.startswith("review"), (returned, finding.detail)
def test_respelling_a_shared_field_is_still_cosmetic():
finding = _m4_metric_finding('SUM("sales"."amount") / COUNT(DISTINCT "customers"."customer_id")')
assert finding.detail.startswith("cosmetic"), finding.detail
# amount belongs to one dataset only, so its qualifier is still spelling.
finding = _m4_metric_finding("SUM(amount) / COUNT(DISTINCT customers.customer_id)")
assert finding.detail.startswith("cosmetic"), finding.detail
def test_a_renamed_metric_moved_to_another_dataset_is_not_repaired():
returned = _m4_document()
returned["metrics"] = [_metric("rpc", "ANSI_SQL", "SUM(sales.amount) / COUNT(DISTINCT sales.customer_id)")]
outcomes = {f.name: f.outcome for f in grade.grade(_m4_document(), returned).findings}
assert outcomes["revenue_per_customer"] == grade.DROPPED_SILENT
assert outcomes["rpc"] == grade.ADDEDThere was a problem hiding this comment.
…eclare M4 is the first rung with two datasets, and customer_id is declared by both sales and customers. The grader stripped every table qualifier before comparing trees, so a converter that re-pointed COUNT(DISTINCT customers.customer_id) to sales.customer_id, or dropped the qualifiers, was graded as a cosmetic rewrite, and _repair_by_expression paired such a renamed metric as expression-equivalent. _ambiguous_fields() reads the field names that more than one input dataset declares; for those the qualifier is kept, for all others it is stripped as before (M0-M3 have one dataset each, so their grades do not change). Three regression tests; two fail on the previous grader. Suggested-by: Jean-Baptiste Onofré <jbonofre@apache.org> Co-authored-by: Isaac <no-reply@databricks.com>
Co-authored-by: Isaac <no-reply@databricks.com>
…ty grader The core grader compares normalized sqlglot trees, so a rewritten metric whose trees differ can only be marked for review. portability_execution.py runs the authored and the returned expression of each metric the grader marks REWRITTEN in DuckDB on the same small edge-case data and reports SAME_ON_TEST_DATA (evidence, not proof), DIFFERENT (with the smallest row set found and both results on it) or NOT_RUN (with the reason). The grader is loaded from the pinned ossie submodule. check() takes the grader's report for the round trip when the caller already has one, so a portability step and this check read the same findings; a single pair can also be checked with python -m interop.portability_execution. It is report-only: exit 0 whatever the findings, 2 when a document or the grader cannot be loaded. DuckDB runs in memory with external access and extension loading off and its configuration locked. Division by zero is NULL (ieee_floating_point_ops = false, plus error_on_division_by_zero = false on DuckDB 2, which otherwise raises), so a rewrite that only adds a NULLIF guard agrees, as discussed on apache#24. Tests set the M3 ELSE 0 form themselves rather than reading it from the fixture, which apache/ossie#631 changes. Refs apache#24.
Summary
Follow-up to #596: the next portability rungs, one commit each, on top of the M0-M3 set. Each rung still adds one construct, so a difference in a converter's output localizes to it. Happy to drop or split any of these.
FILTER (WHERE ...)clause, whichexpression_language.mdmakes REQUIRED, qualified and bare. The existingCASEfixtures now omitELSE 0(SUM(CASE WHEN ... THEN amount END)), so both forms are the same aggregate, as the spec's note underFILTER (WHERE ...)states; withELSE 0they gave 0 whereFILTERgives NULL (found by @idk-arsh's execution check on apache/ossie-converters#24)m3/m3_filter_clause_qualified.ossie.yaml,m3/m3_filter_clause_bare.ossie.yamlm4/m4_cross_dataset.ossie.yamlSUM(sales.amount) / NULLIF(COUNT(sales.order_id), 0)), since a metric expression cannot refer to other metrics (Metric Scoping incore-spec/spec.md)m5/m5_metric_ref.ossie.yamlYEAR,DATE_TRUNC, part-firstDATEDIFF(part, start, end)m6/m6_date_scalars.ossie.yamlmanifest.yamlgets one entry per rung (and the two M3 variants), and the README table lists M0-M6. Each rung lists itselements_under_testwith one kind (metric or field) and the name the grader reports it under (a field asdataset.field); a variant whose metrics are dataset-scoped says so withmetrics_in: sales.Bare variants (M0, M2, M3) are dataset-scoped metrics now. Since #486 a model-scoped metric must qualify
dataset.field, so the bare form moves todatasets[].metrics, where the single-part name is the required form. The grader read only the model-rootmetricsand skipped dataset-scoped ones entirely (and the identity test still passed), so it now collectsdatasets[].metricsas well, keyeddataset.metricas the spec references them; the identity test asserts every metric in a fixture is graded. M6 writesDATEDIFF(day, ...), the spec's spelling.M7 and M8 (window and offset functions) are not included: they wait for the default NULL placement in window
ORDER BY(#587).Validation
validation/validate.pyon current main with no warnings (including the reference checks from feat(validation): check metric references and field shadowing against Name Resolution #608)._collect) in each variant, and that each primary variant uses the rung's primary reference form. Three tests cover a dataset-scoped metric graded PORTABLE, dropped, and moved to the model root (REWRITTEN).Related Issues
Refs apache/ossie-converters#24 (portability measurement). Follows #596.