Skip to content

feat(validation): warn when keys, relationship columns or metric references do not name a declared field - #608

Open
kayemkim wants to merge 2 commits into
apache:mainfrom
kayemkim:validation/field-reference-checks
Open

kayemkim wants to merge 2 commits into
apache:mainfrom
kayemkim:validation/field-reference-checks

Conversation

@kayemkim

Copy link
Copy Markdown
Contributor

Summary

The Name Resolution section that #486 adds to spec.md settles what a name means in each place of a model: a bare name inside a field expression is a column of the dataset's source, while primary_key, unique_keys, relationship from_columns/to_columns and metric expressions name declared fields, the last qualified as dataset.field. validate.py checks none of that today, so a model whose keys name source columns, or whose metric reads SUM(orders.o_totalprice) against a field called total_amount, passes. Issue #602 asks for the three checks this adds.

Three checks, one per rule. validate_key_references reports a key or relationship column that is not a declared field of its dataset. 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 last 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. A qualified orders.total_usd inside a field expression is not covered, since #602 asks about bare names. Metric and field expressions are parsed with sqlglot per dialect, the dialects it cannot parse are skipped, an expression that does not parse is left to the SQL check, and a bare date part such as the day in DATEDIFF(day, a, b), which the default parser reads as a column, is not reported. Names are compared as written, the way validate_references compares dataset names.

Every finding is a warning for now, as #602 suggests for the rollout. A sweep of the 26 semantic-model documents in this tree finds 59 keys, relationship columns and metric references the first two checks report, in 15 converter fixtures and the TPC-DS example, 7 of them metrics that refer to other metrics, and the NVIDIA converter's generated document, which its own tests run through validate.py, still carries a metric over orders.subtotal, which is not a field. The shadowing check reports nothing in the tree. Promoting the first two to errors is a one-line change once the converters have caught up.

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. The test fixtures now declare the fields their keys refer to, which is the validation-side instance of the fixture updates #486 anticipates. Until #486 lands, examples/tpcds_semantic_model.yaml reports its ss_ticket_number key as undeclared, which is the field #486 declares.

Verification

  • validation/tests/: 285 passed, 52 of them new, also in random order, one of them through the CLI. validation/test_validate.py OK. The validation workflow run locally against current main (53418ba) on Python 3.11 to 3.14.
  • examples/renamed_fields.yaml and examples/flights.semantic_model.yaml pass clean. examples/tpcds_semantic_model.yaml passes with the two ss_ticket_number warnings described above.
  • The converter suites that validate their own output with validate.py (cube, thoughtspot, orionbelt, nvidia) stay green, nvidia with the one warning above.

Related Issues

#602. The wording follows the Name Resolution section as it stands in #486 and will be adjusted if that text changes before it lands.

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

…rences do not name a declared field

The spec's Name Resolution section says primary_key, unique_keys,
relationship from_columns/to_columns and metric expressions refer to a
dataset's declared fields, not to columns of its source. validate.py did
not check any of that, so a document whose keys name source columns, or
whose metric reads SUM(orders.o_totalprice) against a field called
total_amount, passed.

Three checks report each such reference, one per rule. Keys and
relationship columns have to be declared fields of their dataset. Metric
expressions are parsed with sqlglot and every column has to be a
declared field written as dataset.field: an unqualified name, a name
that is another metric, a dataset the model does not declare and a field
the dataset does not are each told apart. Dialects sqlglot cannot parse
are skipped, an expression that fails to parse is left to the SQL
check, and a bare date part such as the day in DATEDIFF(day, a, b),
which the default parser reads as a column, is not reported.

The same section makes a bare name in a field expression a source
column. When another field of the same dataset carries that name, the
author may have meant the field, so that is reported too, unless the
other field is the same-named column passed through, read from its
parsed definition, in which case both readings agree.

The findings are warnings for now: at least one converter still emits a
metric over a name that is not a field and runs validate.py in its
tests, so an error would turn that suite red before the converter has
caught up.

examples/renamed_fields.yaml is a small model whose field names differ
from their source columns throughout, and validation-ci validates it.
The test fixtures now declare the fields their keys refer to.

Generated-by: Claude Code
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
Contributor 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
Contributor 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
Contributor 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
Contributor 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.

… part as a unit

The three Name Resolution checks compared names as written, while
expression_language.md makes regular identifiers case-insensitive. So
SUM(ORDERS.TOTAL_USD) over dataset orders warned about an unknown
dataset, and Total_USD * 2 next to a field total_usd escaped the
shadowing check. fold() now upper-cases both sides of every lookup:
the declared fields, the metric names and the pass-through test.

is_date_part read sqlglot's UNABBREVIATED_UNIT_NAME, which has no MONTH
and none of the aliases, so month, dd and days in DATEDIFF, DATEADD and
DATE_PART warned as unqualified columns. It now reads
Dialect.DATE_PART_MAPPING, and the shadowing check applies the same
filter, so a field named day next to DATEDIFF(day, ...) is quiet.

Generated-by: Claude Code
@kayemkim

Copy link
Copy Markdown
Contributor 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
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.

2 participants