Skip to content

feat(validation): check metric references and field shadowing against Name Resolution - #608

Merged
jbonofre merged 1 commit into
apache:mainfrom
kayemkim:validation/field-reference-checks
Oct 29, 2026
Merged

jbonofre merged 1 commit into
apache:mainfrom
kayemkim:validation/field-reference-checks

Conversation

@kayemkim

@kayemkim kayemkim commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Summary

The Name Resolution section of spec.md says a model-scoped metric names fields as dataset.field and a bare name in a field expression is a column of the dataset's source. Since #649, validate_references holds keys and relationship columns to declared fields; nothing checks the expressions, so a metric reading SUM(orders.o_totalprice) against a field called total_amount still passes. This adds the other two checks #602 asks for.

validate_metric_references reports a metric column that is unqualified, names another metric, names an unknown dataset or names an undeclared field, each told apart in the message. validate_field_shadowing reports a field expression whose bare name is also the name of another field in the same dataset, where the author may have meant the field but the spec reads the source column. That one stays quiet when the other field is the same-named column passed through, read from its parsed definition, since both readings then agree, when it has no SQL definition to compare, and when a field names its own source column, as in the spec's amount example. Dataset-scoped metrics are left to validate_metric_scoping, which #633 is changing. Names are compared case-insensitively with casefold, like dataset_field_names.

The checks read expressions through iter_expressions and _parse_sql_expression, like validate_sql: a top-level sql is checked as OSSIE_SQL_2026, an OSSIE_SQL_2026 dialect entry that a sql supersedes is skipped, a dialect outside SQLGLOT_DIALECTS is left to the unsupported-dialect warning, and an expression that does not parse is left to the SQL check. Columns are read through iter_columns: * and the unit of a date function reference no field, so day in DATEDIFF(day, a, b), which the default parser reads as a column, is not reported. The same parser leaves the last DATEDIFF or DATE_ADD argument in unit, as a Var when it is bare; when the written unit sits in this as a column and that Var is an identifier that is not a date part, iter_columns reads it back as the column it is.

Both are warnings. The three portability fixtures written in the bare form (m0_sum_bare, m2_aggregates_bare, m3_filtered_agg_bare) get one per bare metric reference, nine in all, and still pass; compliance/portability/README.md calls the bare form a sibling variant pending the name-resolution decisions. The shadowing check reports nothing in the tree.

examples/renamed_fields.yaml is the spec example as a complete model: every field name differs from its source column, and validation-ci validates it.

Verification

  • validation/tests/: 368 passed, 76 of them new (main: 292), also in random order, one of them through the CLI. validation/test_validate.py OK. The validation workflow run locally against current main (89e2e17) on Python 3.11 to 3.14.
  • Mutations tried in place, each failing only the tests meant for it: the unit-slot read-back dropped or its bare-unit, qualified-first-argument, literal and identifier conditions dropped, DATE_ADD left out of it, the date-part skip's qualified and two-argument guards dropped, empty definitions read, duplicates reported. The module imports without sqlglot on 3.11 and 3.12.
  • examples/renamed_fields.yaml, examples/flights.semantic_model.yaml and examples/tpcds_semantic_model.yaml pass clean; the YAML documents under examples/ and compliance/ compared with main differ only in the nine fixture warnings above.

Related Issues

#602. Follows #486 (Name Resolution), #617 (iter_expressions) and #649 (keys and relationship columns).

Checklist

Validation

  • Validation rules in validation/ are updated if the spec changed
  • New validation cases are covered by tests

Examples

  • examples/ are added or updated for any new spec constructs or converter support

Tests

  • All existing tests pass (pytest / CI green)
  • New functionality is covered by tests

Compliance

  • ASF license headers are present on all new source files
  • No third-party dependencies are added without PMC/IPMC approval

Comment thread validation/validate.py Outdated
anonymous = isinstance(parent, exp.Anonymous) and parent.name.upper() in DATE_PART_FUNCTIONS
if column.table or not (isinstance(parent, typed) or anonymous):
return False
units = exp.TimeUnit.UNABBREVIATED_UNIT_NAME

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

exp.TimeUnit.UNABBREVIATED_UNIT_NAME has no MONTH. Its keys are single letters (D, H, M, Q, ...) and its values are DAY, HOUR, MINUTE, QUARTER, WEEK, YEAR, etc. So DATEDIFF(month, orders.a, orders.b), DATEADD(month, 1, orders.a) and TIMESTAMPDIFF(MONTH, ...) all warn "'month' is not qualified". DATE_PART(month, ...) warns the same way on DATABRICKS, and so do dd and days.

sqlglot already has the full alias table in Dialect.DATE_PART_MAPPING:

Suggested change
units = exp.TimeUnit.UNABBREVIATED_UNIT_NAME
parts = Dialect.DATE_PART_MAPPING
return column.name.upper() in parts or column.name.upper() in parts.values()

This needs from sqlglot.dialects.dialect import Dialect next to the other sqlglot imports.

Could we also add tests for month, dd and days? The current tests only use day, MINUTE and year.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 8c29e26: Dialect.DATE_PART_MAPPING, and only in the unit position, since that table also has q, w and ms; the Snowflake argument order is handled too. month, days, dd and DATE_PART(month) are in the tests.

Comment thread validation/validate.py Outdated
tree = parse_sql(expr, dialect)
if tree is None:
continue
names = [c.name for c in tree.find_all(sqlglot.exp.Column) if not c.table]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This collects every unqualified column without the is_date_part filter that validate_metric_references applies.
Take a dataset with a field day (EXTRACT(DAY FROM order_date)) and a field days_open (DATEDIFF(day, opened_at, closed_at)). It warns "'day' is the source column, not the field 'day' ...". Date dimensions often have fields named day, month or year.

Suggested change
names = [c.name for c in tree.find_all(sqlglot.exp.Column) if not c.table]
names = [
c.name for c in tree.find_all(sqlglot.exp.Column)
if not c.table and not is_date_part(c)
]

For month it depends on the is_date_part fix above.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 8c29e26, with a day / days_open case.

Comment thread validation/validate.py Outdated
f"'{column.name}' is not qualified; metric expressions refer to fields "
f"as dataset.field"
)
elif column.table not in fields:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Names are compared exactly here. The same happens in undeclared(), other_metrics, by_name and passes_through. But expression_language.md (in "Namespacing and Identifier Resolution") says regular, unquoted identifiers are case-insensitive: id matches Id and ID.
As written, SUM(ORDERS.TOTAL_USD) over dataset orders warns "references unknown dataset 'ORDERS'". And Total_USD * 2 next to a field total_usd misses the shadowing this check is meant to catch.

Could we compare normalized names everywhere? For example:

def fold(name: str) -> str:
    """Regular identifiers are case-insensitive (expression_language.md)."""
    return name.upper()

Then apply fold() to both sides of the dataset and field lookups, the metric-name set, by_name and passes_through. The spec leaves quoted versus unquoted comparison to each database, so folding everything seems the safer choice for a warning, and it keeps "amount" working as a pass-through.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 8c29e26: fold() on both sides of every lookup; messages keep the spelling as written.

Comment thread validation/tests/test_validate.py Outdated
assert _field_reference_findings(document) == []


def test_pass_through_compares_names_as_written() -> None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Under the spec's case rule, a field amount defined as AMOUNT is a pass-through, so this test should expect no finding.

Could we flip it? Also we could add a case where the casing differs and a warning is still expected, such as Total_USD * 2 next to a field total_usd.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flipped in 8c29e26, and Total_USD * 0.9 next to total_usd is the case that still warns.

Comment thread validation/validate.py
return errors


def declared_fields(model: dict) -> dict[str, set[str]]:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess we have to merge #486 first to be consistent here.

@kayemkim

Copy link
Copy Markdown
Member Author

Pushed a commit for the first four; the fifth waits for #486.

  • is_date_part reads Dialect.DATE_PART_MAPPING, keys and values, so month, dd, days and the other aliases are units in DATEDIFF, DATEADD, TIMESTAMPDIFF and DATE_PART. Since that table also holds short names such as q, w and ms, the check now looks at the position too: only the first argument of DATEDIFF and TIMESTAMPDIFF, and of the functions sqlglot leaves anonymous, is a unit, and DATE_ADD and DATE_TRUNC leave the list because sqlglot never parses their unit as a column. So DATEDIFF(day, q, closed_at) still sees the column q. Parsers that know the function, Snowflake and Databricks among them, read the unit themselves and put the last date argument where the default parser puts the unit, so the check tells the two apart by whether unit names a date part; DATEDIFF(day, orders.a, d) under SNOWFLAKE reports d. The shadowing check applies the same filter, so a field named day next to DATEDIFF(day, ...) is quiet. Tests cover month, days, dd, DATE_PART(month), the q / ms / d cases and the Snowflake order.
  • fold() upper-cases both sides of every lookup: the declared fields, the dataset names, the metric-name set, by_name and passes_through. SUM(ORDERS.TOTAL_USD) resolves, Total_USD * 0.9 next to total_usd is reported, "amount" still passes through, and a metric referenced as revenue when it is declared Revenue is still a metric reference. Messages keep the author's spelling for the reference and the declared spelling for what it resolves to. Two datasets, or two fields, whose names differ only by case pool their fields here rather than hide each other; whether they may coexist stays with validate_unique_names, which still compares as written. One note: feat(validation): cross-check a mapping document against the documents it references #595 normalizes the same way for regular identifiers but keeps a quoted one as written, since there the check is an error; when both land I will fold the two into one helper.
  • The amount / AMOUNT test now expects no finding, and the case-differing shadowing case is added.

300 tests; the validation workflow passes locally on current main.

@kayemkim
kayemkim requested a review from jbonofre October 21, 2026 09:47
@christianeu-db

Copy link
Copy Markdown
Contributor

@kayemkim FYI #486 just merged

@ekut

ekut commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Once this rebases onto #591, validate_metric_scoping has the same false positive that is_date_part fixes here. It walks tree.find_all(exp.Column) without that filter. So a dataset-scoped metric in the spec's own DATEDIFF form is rejected:

datasets:
  - name: devices
    source: devices
    fields:
      - name: last_contact_at
        expression: {dialects: [{dialect: ANSI_SQL, expression: last_contact_at}]}
    metrics:
      - name: minutes_since_last_contact
        expression: {dialects: [{dialect: ANSI_SQL, expression: "DATEDIFF(MINUTE, MAX(last_contact_at), CURRENT_TIMESTAMP)"}]}

On main (7205e15) with sqlglot 30.24.0:

[Scope] Dataset-scoped metric 'devices.minutes_since_last_contact' in model 'm' (ANSI_SQL) references undeclared field(s) 'MINUTE'. A dataset-scoped metric may reference only the dataset's declared fields.

With is_date_part from 8c29e26 in that loop, MINUTE reads as the unit under ANSI_SQL and OSSIE_SQL_2026, and last_contact_at is still checked. Could #608 skip date parts there too? If you'd rather keep its scope, I can send that as a follow-up after #608 lands.

@kayemkim

Copy link
Copy Markdown
Member Author

Reproduced on main, and it belongs here rather than in a follow-up: the scoping walk and the reference checks read the same columns, so they should skip the same units. With is_date_part in that loop your DATEDIFF metric passes and last_contact_at is still checked, so I will fold it in when #608 rebases after #617 lands, with your case as the test.

One thing the filter does not cover, in either check: under the default parser the third argument lands in unit as a Var, so DATEDIFF(MINUTE, MAX(last_contact_at), nope) passes on main and DATEDIFF(day, orders.a, nope) passes here, while nope in the second slot is reported. When unit does not name a date part it is the column, so the same rebase will read it as one.

@kayemkim
kayemkim force-pushed the validation/field-reference-checks branch from 8c29e26 to ed11583 Compare October 26, 2026 04:35
@kayemkim

Copy link
Copy Markdown
Member Author

Rebased on main with #617 and #486 in; the two reviewed commits are unchanged apart from the renamed dialect sets, and two commits follow. The first moves the three checks onto iter_expressions and _parse_sql_expression, so a top-level sql is checked and an unsupported dialect goes to the warning rather than the ANSI parser.

The second is the fifth item from the first round and @ekut's report: validate_metric_scoping and these checks now read columns through one iter_columns, so DATEDIFF(MINUTE, MAX(last_contact_at), CURRENT_TIMESTAMP) passes in a dataset-scoped metric, and the last DATEDIFF argument the default parser hides in unit is checked again.

From the sweep: the three *_bare portability fixtures now get a [Reference] warning per bare reference and still pass, which looks like a fixture follow-up. Details and numbers are in the body.

@K-CHANHO

Copy link
Copy Markdown

I ran this branch (ed11583) against a model a code generator wrote rather than one written by hand: the ElastiCORE ossie-export of a production Spring Boot application, 52 datasets, 764 fields, 66 relationships and 12 model-scoped metrics.

  • The model as generated: one warning, and it is a real defect in the generator. unique_keys names seqCd, the DSL field name, where the declared field and the database column are seq_cd. The generator copies a JPA @Index(columnList = ...) entry verbatim, and the naming strategy maps it to seq_cd in the database. validate.py on main passes the file. I will fix it on the generator side.
  • Seeded faults in metric expressions: each was reported, and each passes on main.
    • SUM(... Sale.total_amt ...): "'Sale.total_amt' is not a declared field of dataset 'Sale'"
    • COUNT(BidNoticeX.id): "references unknown dataset 'BidNoticeX'"
    • COUNT(id) at model scope: "'id' is not qualified; metric expressions refer to fields as dataset.field"
  • Shadowing: no warnings. Every field is a pass-through of the same-named column, which check 3 exempts.
  • The generator's two sample exports (2 and 3 domains) pass with no warnings.

Warnings first looks right from here: the one hit in a real generated model would have failed a CI that treated it as an error before the generator fix ships.

… Name Resolution

spec.md "Name Resolution" says a model-scoped metric names fields as
dataset.field, and that a bare name in a field expression is a column of
the dataset's source. validate_references already holds keys and
relationship columns to declared fields; nothing checked the expressions.

- validate_metric_references warns when a metric column is unqualified,
  names another metric, has more than two parts, or names an unknown
  dataset or an undeclared field, once per expression.
- validate_field_shadowing warns when a field expression's bare name is
  another field of the same dataset, unless that field passes the
  same-named column through or has no SQL definition to compare.

Both read expressions through read_expressions (the top-level `sql` as
OSSIE_SQL_2026, a superseded entry skipped) and the SQL check's parser,
so a dialect it does not parse is skipped, and read columns through
iter_columns: `*` and the unit of a date function are no reference, and
the unit slot the default parser fills with the last DATEDIFF or
DATE_ADD argument is read back as that column. _expressions_in now skips
shapes the schema rejects instead of failing on them, for direct calls.
examples/renamed_fields.yaml is the spec's example as a complete model,
validated in CI.

Generated-by: Claude Code
@kayemkim
kayemkim force-pushed the validation/field-reference-checks branch from ed11583 to 582a991 Compare October 28, 2026 04:27
@kayemkim kayemkim changed the title feat(validation): warn when keys, relationship columns or metric references do not name a declared field feat(validation): check metric references and field shadowing against Name Resolution Oct 28, 2026
@kayemkim

Copy link
Copy Markdown
Member Author

Rebuilt on main now that #649 is in. It covers keys and relationship columns as errors, so I dropped that check and kept the other two from #602, model-scoped metric references and field shadowing, in one commit that compares names with casefold like dataset_field_names. The scoping changes are gone as well: #633 rewrites validate_metric_scoping and removes the undeclared-field branch that @ekut's DATEDIFF(MINUTE, ...) case tripped.

Both checks are still warnings. Now that keys are errors, should metric references follow? In this tree they would fail only the three *_bare portability fixtures.

@jbonofre jbonofre left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

I checked out the branch and ran validation/tests and test_validate.py.
renamed_fields.yaml and tpcds_semantic_model.yaml both validate cleanly.

I also tried a few likely false positive cases against the new reference check: DATE_TRUNC(month, ...) (Snowflake and ANSI), TIMESTAMP_TRUNC(..., month) (BigQuery), DATEDIFF(day, ...), and a Databricks lambda. None of them warned. A dataset scoped metric that references a source column (SUM(o_totalprice)) is still rejected by the existing [Scope] check.

@jbonofre
jbonofre merged commit 415ed9e into apache:main Oct 29, 2026
9 checks passed
MrBlack1995 added a commit to MrBlack1995/ossie that referenced this pull request Oct 30, 2026
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>
403pagelabs added a commit to 403pagelabs/ossie that referenced this pull request Oct 30, 2026
Picks up apache#595 and apache#608, which append tests to validation/tests/test_validate.py
beside this branch's dialect tests; both sets kept.
MrBlack1995 added a commit to MrBlack1995/ossie that referenced this pull request Nov 2, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants