Skip to content

fix(validation): read the unit of a date function as a unit in validate_metric_scoping - #696

Open
ekut wants to merge 2 commits into
apache:mainfrom
ekut:scoping-date-part
Open

ekut wants to merge 2 commits into
apache:mainfrom
ekut:scoping-date-part

Conversation

@ekut

@ekut ekut commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

validate_metric_scoping rejects a dataset-scoped metric that uses DATEDIFF with a unit. The validator parses OSSIE_SQL_2026 and ANSI_SQL with sqlglot's default dialect, which reads the unit as a column. On main (0a908aa, sqlglot 30.29.0) the form from core-spec/expression_language.md, DATEDIFF(day, start_date, end_date), fails with both fields declared:

[Scope] Dataset-scoped metric 'orders.days_open' in model 'm' (OSSIE_SQL_2026) references undeclared field(s) 'day'. A dataset-scoped metric may reference only the dataset's declared fields, and its source columns as source.column.

DATEDIFF(day, source.opened_at, source.closed_at), the form #633 added, fails the same way.

The check now reads columns through iter_columns, as validate_metric_references and validate_field_shadowing do since #608. iter_columns skips every *, so the check reads a qualified star such as COUNT(customers.*) on its own.

iter_columns now takes the expression's dialect. It reads a column back from the unit slot only under the default parser, which does not know the unit of DATEDIFF, DATE_ADD and TIMESTAMPDIFF. A parser that knows the function puts the unit in unit, so every column it finds is a column. The three checks share the iterator, so all of them read the unit slot by dialect.

With the change:

  • DATEDIFF(day, opened_at, closed_at) with both fields declared passes under OSSIE_SQL_2026 and ANSI_SQL, and so does DATEDIFF(day, source.opened_at, source.closed_at).
  • DATEDIFF(MINUTE, MAX(opened_at), CURRENT_TIMESTAMP) passes.
  • With closed_at undeclared, DATEDIFF(day, opened_at, closed_at) reports 'CLOSED_AT' instead of 'day', in the spelling the parser keeps.
  • A date literal in the last argument, DATEDIFF(day, opened_at, '2024-01-01'), passes. Main reported 'day' for it unless a field day was declared.
  • SUM(minute) with no field minute and COUNT(customers.*) are still reported, and COUNT(source.*) still passes.
  • Under SNOWFLAKE and BIGQUERY the check reads as on main. DATEDIFF(nanoseconds, opened_at, d) and DATE_DIFF(day, opened_at, ISOWEEK) with their fields declared pass. DATE_DIFF(day, opened_at, WEEK(MONDAY)) with day undeclared reports 'day'.
  • Under SNOWFLAKE, validate_metric_references warns on d in SUM(DATEDIFF(nanoseconds, orders.total_usd, d)) instead of 'NANOSECONDS'.

Known limits:

  • A declared field named like a date part in the last argument, DATEDIFF(day, opened_at, quarter), is read as the unit, as is_date_part documents.
  • Under the default parser a quoted name in the last argument, DATEDIFF(day, opened_at, "closed at"), is not checked. It reaches the tree as the same Var as a string literal. Main reported 'day' for it unless a field day was declared, and the reference checks have skipped it since feat(validation): check metric references and field shadowing against Name Resolution #608.
  • Some dialect parsers read a function the way the default parser does (ClickHouse timestampDiff) or do not know it (DATEDIFF under BIGQUERY, TRINO and POSTGRES, DATE_ADD under SNOWFLAKE). There the written unit is read as a column. The scoping check reports 'day', as main does, and the two reference checks now warn on day.

One existing test changes. The SNOWFLAKE case of test_date_add_reads_its_unit_as_a_unit_and_its_date_as_a_column used DATE_ADD, which Snowflake does not have. sqlglot's Snowflake parser reads it the way the default parser does, so the case now uses DATEADD.

test_dataset_scoped_metric_reads_a_date_part_as_a_unit_not_a_field, test_dataset_scoped_metric_reads_a_column_in_the_unit_slot_as_a_field and the nanoseconds case of test_a_parser_that_knows_the_unit_leaves_the_date_arguments_as_columns fail on main. test_dataset_scoped_metric_still_rejects_a_qualified_star and the SNOWFLAKE and BIGQUERY scoping tests pass on main and guard the switch to iter_columns. validation/tests: 536 passed, 1 skipped (530 passed, 1 skipped on main). The examples CI validates pass as before.

Follow-up to #633, as asked in #633 (comment). The same false positive was first reported on #608 (#608 (comment)).

…te_metric_scoping

validate_metric_scoping walked tree.find_all(exp.Column), so under
sqlglot's default dialect the unit of DATEDIFF was reported as an
undeclared field and the spec's own DATEDIFF(day, start_date, end_date)
form was rejected. The check now walks iter_columns, the iterator
validate_metric_references and validate_field_shadowing already use: the
unit is skipped and an identifier the parser left in the unit slot is
read as a column. iter_columns skips every `*`, so a qualified star is
read on its own and is still a qualified reference.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@jbonofre
jbonofre self-requested a review November 4, 2026 15:41
Comment thread validation/validate.py Outdated
col for col in tree.find_all(exp.Column)
if isinstance(col.this, exp.Star) and col.table
)
for col in (*iter_columns(tree), *qualified_stars):

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.

Switching to iter_columns brings its unit-vs-column guessing into a check that fails validation. Until now it only fed validate_metric_references, which warns. For dialects whose parser knows the date function, it gets cases wrong in both directions:

  • Valid metrics are now rejected. is_date_part_name only knows sqlglot DATE_PART_MAPPING, so a real unit missing from it makes the field in this look like the unit. BIGQUERY MAX(DATE_DIFF(day, opened_at, ISOWEEK)) with fields day and opened_at drops day and reports ISOWEEK as an undeclared field. SNOWFLAKE MAX(DATEDIFF(nanoseconds, opened_at, d)) with fields d and opened_at reports NANOSECONDS. Both pass on main. It only triggers for a field named like a date part, but d is what the spec own examples use.
  • Undeclared fields are now accepted. is_date_part drops the column in this whenever unit is not a date part Var. BIGQUERY MAX(DATE_DIFF(day, opened_at, WEEK(MONDAY))) with only opened_at declared: the unit is a WeekStart, so day is skipped and nothing is recovered. MAX(DATEDIFF(day, opened_at, "closed at")): the recovered name fails isidentifier(), so neither argument is checked. Main rejects both, this PR accepts them.

Only sqlglot default parser (OSSIE_SQL_2026, ANSI_SQL) moves DATEDIFF last argument into unit. Dialects that know the function already put the unit there, so the column in this is never a unit.

Could iter_columns take the sqlglot dialect and only do the swap when it's None? That removes the guessing instead of adding more special cases.

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 bbbe42d: iter_columns takes the dialect and reads the unit slot back only when SQLGLOT_DIALECTS maps it to None. Your BIGQUERY and SNOWFLAKE metrics with declared fields pass, and WEEK(MONDAY) with day undeclared reports 'day'. The reference checks share the iterator and change too where a dialect's parser reads a function like the default parser; the description lists those cases.

The quoted name is the one case I couldn't fix. Under the default parser "closed at" reaches the tree as the same Var as the literal '2024-01-01', so it passes. Main rejected it by reporting 'day', the unit, and accepts it once a field day is declared. Comparing that Var with the fields would reject it, but also a literal date there, which main accepts when day is a field. I left that out and can add it if you prefer the strict reading.

{"name": "days_open", "expression": _ds_expr("MAX(DATEDIFF(day, opened_at, closed_at))")},
{"name": "source_days_open", "expression": _ds_expr("MAX(DATEDIFF(day, source.opened_at, source.closed_at))")},
{"name": "minutes_since_open", "expression": _ds_expr("DATEDIFF(MINUTE, MAX(opened_at), CURRENT_TIMESTAMP)")},
{"name": "ansi_days_open", "expression": _ds_expr("MAX(DATEDIFF(day, opened_at, closed_at))", "ANSI_SQL")},

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.

These tests only cover OSSIE_SQL_2026 and ANSI_SQL, the two dialects that go through sqlglot default parser. The regressions above are in SNOWFLAKE and BIGQUERY, which aren't covered.

Could we add scoping cases for those two dialects? One case should declare a field named like a date part next to a unit sqlglot doesn't alias (ISOWEEK, NANOSECONDS). Another should use a non-Var unit like WEEK(MONDAY) with an undeclared field.

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.

Added in bbbe42d: a test where ISOWEEK next to a field day (BIGQUERY) and nanoseconds next to a field d (SNOWFLAKE) pass, and one where WEEK(MONDAY) with day undeclared (BIGQUERY) reports 'day'. Both pass on main and fail on 8655cbe.

assert errors[0].startswith("[Scope]") and "'minute'" in errors[0]


def test_dataset_scoped_metric_still_rejects_a_qualified_star() -> 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.

The description lists this test as failing on main, but it passes there: main find_all(exp.Column) walk already reports customers.*. It's still a good guard for the switch to iter_columns.

Could we update the description so it doesn't read as reproducing a bug?

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.

Right. The description now lists it with the tests that pass on main.

iter_columns guessed whether a column in the unit slot of DATEDIFF,
DATE_ADD or TIMESTAMPDIFF was the unit or a column by whether the Var in
unit names a date part. Under a parser that knows the function that
guess fails both ways: a field named like a date part next to a unit the
alias table lacks (ISOWEEK, nanoseconds) was dropped and the unit
reported, and a non-Var unit hid an undeclared field. The iterator now
takes the expression's dialect and reads the unit slot back only under
sqlglot's default parser, where the shift happens; is_date_part treats a
column in this as the unit only there too.

The SNOWFLAKE case of the DATE_ADD reference test now uses DATEADD:
Snowflake has no DATE_ADD, and sqlglot reads it there like the default
parser does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@kayemkim kayemkim 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.

On the tree merged with current main the validation workflow passes on the four Python versions, validate.py agrees with main on the 90 model and ontology documents I could find, converter fixtures included, and the branch merges cleanly with #697, #698, #701, #647 and #491. Across the nine mapped dialects the scoping check moves only where the description says, plus one case of the same kind: where a parser leaves DATEADD anonymous (the default parser, BIGQUERY, TRINO, POSTGRES), MAX(DATEADD(day, 1, opened_at)) now passes in a dataset-scoped metric and DATEADD(day, 1, nope) reports 'nope' alone instead of 'day', 'nope', which is the reading validate_metric_references already had on main. The quoted-name reading in the thread is the only thing I see left open.

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.

3 participants