Skip to content

Commit c0eecff

Browse files
mday-ioclaude
andcommitted
fix: preserve cross-dialect resolution in narrowed _resolve_table lookup
The narrowed single-snapshot lookup added in the previous commit did a raw snapshots.get(table_name) dict lookup. table_name is normalized under the referencing renderer's own dialect, while a snapshots dict key is each model's fqn, normalized under that model's own dialect. These can disagree in casing when models use different dialects (e.g. a case-uppercasing dialect like snowflake referenced from a case-insensitive one like duckdb), causing the lookup to silently miss an existing snapshot and leave the table name unmapped, even though the old full-mapping + exp.replace_tables path (which reconciles casing per-dialect during matching) would have resolved it correctly. _resolve_table now falls back to building the full mapping only when the narrowed lookup misses and the name isn't in table_mapping either, so the common same-dialect case stays O(1) while the rare cross-dialect miss still gets exp.replace_tables' dialect-aware reconciliation. Also adds tests for: the cross-dialect regression itself, table_mapping-only resolution with no snapshots, the non-string exp.Expr branch (otherwise unreachable from any real call site), expand-then-find-Table ordering in _resolve_tables, a table reference appearing only inside a string literal, and deployability_index handling through the narrowed path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzEtt434q32KhtoGDtD424 Signed-off-by: Michael Day <mdaytn@gmail.com>
1 parent 3cc4daa commit c0eecff

2 files changed

Lines changed: 261 additions & 4 deletions

File tree

sqlmesh/core/renderer.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -338,10 +338,27 @@ def _resolve_table(
338338
# environment - building the full mapping made this call O(N) in the number of
339339
# snapshots in the environment for every table resolved.
340340
snapshot = snapshots.get(table_name) if snapshots else None
341-
mapping = {
342-
**self._to_table_mapping([snapshot] if snapshot else [], deployability_index),
343-
**({table_name: table_mapping[table_name]} if table_name in table_mapping else {}),
344-
}
341+
if snapshot is None and snapshots and table_name not in table_mapping:
342+
# table_name is normalized under this renderer's own dialect, but a snapshot's
343+
# fqn (the snapshots dict key) is normalized under that model's own dialect -
344+
# these can disagree in casing when models use different dialects (e.g. a
345+
# case-uppercasing dialect referenced from a case-insensitive one). A direct
346+
# dict lookup can miss in that case even though the table is present, so fall
347+
# back to the full, dialect-reconciling mapping that exp.replace_tables itself
348+
# performs. This only pays the O(N) cost on a miss, not on every resolution.
349+
mapping = {
350+
**self._to_table_mapping(snapshots.values(), deployability_index),
351+
**table_mapping,
352+
}
353+
else:
354+
mapping = {
355+
**self._to_table_mapping([snapshot] if snapshot else [], deployability_index),
356+
**(
357+
{table_name: table_mapping[table_name]}
358+
if table_name in table_mapping
359+
else {}
360+
),
361+
}
345362
else:
346363
mapping = {
347364
**self._to_table_mapping((snapshots or {}).values(), deployability_index),

tests/core/test_model.py

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9864,6 +9864,246 @@ def test_render_virtual_properties_skips_mapping_without_table_refs(
98649864
assert len(call.args[0]) <= 1
98659865

98669866

9867+
def test_resolve_table_cross_dialect_fqn_mismatch(make_snapshot: t.Callable):
9868+
"""`_resolve_table`'s narrowed lookup keys `snapshots` by the caller's already-normalized
9869+
`table_name` string. That string is built with the *referencing* model's own dialect
9870+
(`self._dialect` in the `resolve_table` macro closure), while the entry in `snapshots` is
9871+
keyed by the *referenced* model's fqn, which is normalized using that model's own dialect.
9872+
9873+
When the two models use dialects with different identifier-casing rules (e.g. a
9874+
case-insensitive dialect like duckdb referencing a model whose fqn was computed under a
9875+
case-uppercasing dialect like snowflake), the raw string lookup can miss even though
9876+
`exp.replace_tables`'s own (dialect-aware) matching -- which is what ran before this
9877+
optimization, and which the narrowed lookup's own final `exp.replace_tables` call still
9878+
performs when the key IS found -- would have matched them.
9879+
"""
9880+
9881+
@macro()
9882+
def resolve_named(evaluator, name):
9883+
return evaluator.resolve_table(name.name)
9884+
9885+
# parent is declared/rendered under snowflake, which uppercases unquoted identifiers, so its
9886+
# fqn (the key that will appear in `snapshots`) is uppercase-quoted.
9887+
parent = load_sql_based_model(
9888+
d.parse("MODEL (name parent); SELECT 1 AS c"), dialect="snowflake"
9889+
)
9890+
parent_snapshot = make_snapshot(parent)
9891+
parent_snapshot.categorize_as(SnapshotChangeCategory.BREAKING)
9892+
assert parent.fqn == '"PARENT"'
9893+
9894+
# child is declared/rendered under duckdb (case-insensitive), referencing `parent` in lowercase
9895+
child = load_sql_based_model(
9896+
d.parse(
9897+
"""
9898+
MODEL (name child);
9899+
SELECT c FROM parent;
9900+
@resolve_named('parent')
9901+
"""
9902+
),
9903+
dialect="duckdb",
9904+
)
9905+
9906+
snapshots = {parent.fqn: parent_snapshot}
9907+
post_statements = child.render_post_statements(snapshots=snapshots)
9908+
9909+
assert len(post_statements) == 1
9910+
resolved_sql = post_statements[0].sql()
9911+
# BUG: if this assertion fails with the resolved name still literally "parent" (unmapped)
9912+
# instead of the physical table name, the narrowed single-snapshot lookup in `_resolve_table`
9913+
# failed to find `parent` in `snapshots` due to the cross-dialect casing mismatch between the
9914+
# lookup key and the dict key, even though the table legitimately exists in `snapshots`.
9915+
assert resolved_sql == f'"sqlmesh__default"."parent__{parent_snapshot.version}"', (
9916+
f"expected parent to resolve to its physical table name, but got {resolved_sql!r} -- "
9917+
"this indicates the narrowed snapshots.get(table_name) lookup in _resolve_table missed "
9918+
"a snapshot that the old full-mapping + exp.replace_tables path would have matched"
9919+
)
9920+
9921+
9922+
def test_resolve_table_table_mapping_only_no_snapshots(make_snapshot: t.Callable):
9923+
"""A `table_mapping` entry with no corresponding `snapshots` entry should still be honored
9924+
by the narrowed lookup in `_resolve_table` (mirrors the override case in
9925+
`test_resolve_table_large_environment`, but with `snapshots=None`/empty entirely, to make
9926+
sure the narrowed code path doesn't assume `snapshots` is non-empty before consulting
9927+
`table_mapping`)."""
9928+
9929+
@macro()
9930+
def resolve_named(evaluator, name):
9931+
return evaluator.resolve_table(name.name)
9932+
9933+
child = load_sql_based_model(
9934+
d.parse(
9935+
"""
9936+
MODEL (name child);
9937+
SELECT 1 AS c;
9938+
@resolve_named('parent')
9939+
"""
9940+
)
9941+
)
9942+
9943+
post_statements = child.render_post_statements(
9944+
snapshots=None, table_mapping={'"parent"': "explicit_physical_table"}
9945+
)
9946+
assert post_statements[0].sql() == '"explicit_physical_table"'
9947+
9948+
9949+
def test_resolve_table_non_string_expr_path(make_snapshot: t.Callable):
9950+
"""When `table_name` is an `exp.Expr` (not a `str`), `_resolve_table` falls back to building
9951+
the full snapshot mapping (the `else` branch of the new code). This exercises that branch --
9952+
which the `this_model`/`resolve_table` macro call sites never hit, since they always pass a
9953+
pre-normalized string -- directly at the renderer level, to make sure it's still reachable
9954+
and correct, and not dead code that silently bit-rots."""
9955+
9956+
from sqlmesh.core.renderer import ExpressionRenderer
9957+
9958+
parent = load_sql_based_model(d.parse("MODEL (name parent); SELECT 1 AS c"))
9959+
parent_snapshot = make_snapshot(parent)
9960+
parent_snapshot.categorize_as(SnapshotChangeCategory.BREAKING)
9961+
9962+
other = load_sql_based_model(d.parse("MODEL (name other); SELECT 1 AS c"))
9963+
other_snapshot = make_snapshot(other)
9964+
other_snapshot.categorize_as(SnapshotChangeCategory.BREAKING)
9965+
9966+
expr_renderer = ExpressionRenderer(
9967+
exp.select("*"),
9968+
dialect="",
9969+
macro_definitions=[],
9970+
path=Path("."),
9971+
)
9972+
9973+
table_expr = exp.to_table('"parent"')
9974+
resolved = expr_renderer._resolve_table(
9975+
table_expr,
9976+
snapshots={'"parent"': parent_snapshot, '"other"': other_snapshot},
9977+
)
9978+
assert (
9979+
resolved.sql(comments=False)
9980+
== f'"sqlmesh__default"."parent__{parent_snapshot.version}"'
9981+
)
9982+
9983+
9984+
def test_resolve_tables_table_ref_only_in_string_literal_not_expanded(make_snapshot: t.Callable):
9985+
"""Adversarial case for the `expression.find(exp.Table)` short-circuit in `_resolve_tables`:
9986+
an expression that references a table only inside a string literal (not a parsed `exp.Table`
9987+
node) has no `exp.Table` node for `find()` to see, so the mapping build is correctly skipped.
9988+
This documents/locks in that the short-circuit is safe because `exp.replace_tables` itself
9989+
only ever rewrites `exp.Table` nodes -- it would never have touched a string literal either,
9990+
mapping built or not -- so skipping the mapping cannot change behavior here."""
9991+
9992+
parent = load_sql_based_model(d.parse("MODEL (name parent); SELECT 1 AS c"))
9993+
parent_snapshot = make_snapshot(parent)
9994+
parent_snapshot.categorize_as(SnapshotChangeCategory.BREAKING)
9995+
9996+
model = load_sql_based_model(
9997+
d.parse(
9998+
"""
9999+
MODEL (
10000+
name test_schema.string_ref_model,
10001+
virtual_properties (
10002+
description = 'references parent as a plain string, not a table node'
10003+
),
10004+
);
10005+
SELECT a FROM tbl;
10006+
"""
10007+
)
10008+
)
10009+
10010+
snapshots = {'"parent"': parent_snapshot}
10011+
props = model.render_virtual_properties(snapshots=snapshots)
10012+
assert (
10013+
props["description"].this
10014+
== "references parent as a plain string, not a table node"
10015+
)
10016+
10017+
10018+
def test_resolve_tables_expand_reveals_table_after_find_check(make_snapshot: t.Callable):
10019+
"""Embedded-model expansion (`expand=`) runs as an `expression.transform` *before* the new
10020+
`expression.find(exp.Table)` short-circuit in `_resolve_tables`, so a table reference that
10021+
only exists *after* inlining an embedded model's query must still be seen by `find()` and
10022+
mapped. This locks in that ordering: `grandparent` is not a literal `exp.Table` node in
10023+
`child`'s original query -- it only appears once the embedded `mid` model is expanded -- and
10024+
must still resolve to its physical table name, not be silently skipped because it wasn't
10025+
present at the time `_resolve_tables` was first called."""
10026+
10027+
grandparent = load_sql_based_model(d.parse("MODEL (name grandparent); SELECT 1 AS c"))
10028+
grandparent_snapshot = make_snapshot(grandparent)
10029+
grandparent_snapshot.categorize_as(SnapshotChangeCategory.BREAKING)
10030+
10031+
mid = load_sql_based_model(
10032+
d.parse("MODEL (name mid, kind EMBEDDED); SELECT c FROM grandparent;")
10033+
)
10034+
mid_snapshot = make_snapshot(mid)
10035+
mid_snapshot.categorize_as(SnapshotChangeCategory.BREAKING)
10036+
10037+
child = load_sql_based_model(d.parse("MODEL (name child); SELECT c FROM mid;"))
10038+
10039+
snapshots = {'"grandparent"': grandparent_snapshot, '"mid"': mid_snapshot}
10040+
query = child.render_query(snapshots=snapshots)
10041+
assert query is not None
10042+
rendered_sql = query.sql()
10043+
10044+
# the physical table name for `grandparent` must appear -- if the find(exp.Table) check had
10045+
# run before expansion (or expansion didn't feed into it), `grandparent` would remain
10046+
# unmapped in the rendered output.
10047+
assert f"grandparent__{grandparent_snapshot.version}" in rendered_sql
10048+
assert "FROM grandparent" not in rendered_sql
10049+
10050+
10051+
def test_resolve_table_deployability_index_consistency(make_snapshot: t.Callable):
10052+
"""The narrowed `_resolve_table` single-snapshot mapping must respect `deployability_index`
10053+
identically to the full-mapping path: a non-deployable (dev-preview) snapshot should map to
10054+
its dev table, not its deployable/prod table.
10055+
10056+
A snapshot's dev table only differs from its prod table when `dev_version_` differs from
10057+
`version` (see `Snapshot._table_name`); that normally arises from a forward-only change
10058+
against a previous version. `SnapshotChangeCategory.FORWARD_ONLY` is deprecated/blocked by
10059+
`categorize_as`, so this sets `dev_version_` directly to force that condition deterministically
10060+
without relying on a deprecated code path.
10061+
"""
10062+
from sqlmesh.core.snapshot import DeployabilityIndex
10063+
10064+
parent = load_sql_based_model(
10065+
d.parse("MODEL (name parent); SELECT 1 AS c"),
10066+
dialect="duckdb",
10067+
)
10068+
parent_snapshot = make_snapshot(parent)
10069+
parent_snapshot.categorize_as(SnapshotChangeCategory.BREAKING)
10070+
parent_snapshot.dev_version_ = "customdevversion123"
10071+
assert parent_snapshot.table_name(is_deployable=True) != parent_snapshot.table_name(
10072+
is_deployable=False
10073+
)
10074+
10075+
@macro()
10076+
def resolve_named(evaluator, name):
10077+
return evaluator.resolve_table(name.name)
10078+
10079+
child_sql = """
10080+
MODEL (name child);
10081+
SELECT 1 AS c;
10082+
@resolve_named('parent')
10083+
"""
10084+
10085+
snapshots = {parent.fqn: parent_snapshot}
10086+
10087+
# separate model instances per render call so the statement-render cache (keyed independent
10088+
# of `deployability_index`) doesn't just return the first call's cached result.
10089+
deployable_result = load_sql_based_model(d.parse(child_sql)).render_post_statements(
10090+
snapshots=snapshots, deployability_index=DeployabilityIndex.all_deployable()
10091+
)[0].sql()
10092+
non_deployable_result = load_sql_based_model(d.parse(child_sql)).render_post_statements(
10093+
snapshots=snapshots,
10094+
deployability_index=DeployabilityIndex.all_deployable().with_non_deployable(
10095+
parent_snapshot
10096+
),
10097+
)[0].sql()
10098+
10099+
# the narrowed single-snapshot mapping must still pick the right table for each index.
10100+
assert deployable_result != non_deployable_result
10101+
assert parent_snapshot.table_name(is_deployable=True) in deployable_result.replace('"', "")
10102+
assert parent_snapshot.table_name(is_deployable=False) in non_deployable_result.replace(
10103+
'"', ""
10104+
)
10105+
10106+
986710107
def test_cluster_with_complex_expression():
986810108
expressions = d.parse(
986910109
"""

0 commit comments

Comments
 (0)