@@ -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+
986710107def test_cluster_with_complex_expression ():
986810108 expressions = d .parse (
986910109 """
0 commit comments