Skip to content

Commit dc994c6

Browse files
committed
perf: resolve promotion properties against a pre-normalized view mapping
Promotion passed snapshots keyed by SnapshotId into render_virtual_properties, which expects them keyed by model name. Snapshot lookups, @model_kind_name and embedded-model expansion therefore never worked in virtual properties during promotion. _promote_snapshot now re-keys them by name once and uses that dict for both virtual properties and on_virtual_update. to_view_mapping now returns a TableMapping, which normalizes its keys once per dialect. _resolve_table looks the table up in that index and passes only the matching entry to exp.replace_tables, instead of re-normalizing every model in the environment for each rendered property. The explicit mapping still takes precedence over snapshots, and among equivalent keys the last one still wins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rwe8v9iP3iGKVygBfPEZaU
1 parent a4af80b commit dc994c6

5 files changed

Lines changed: 292 additions & 38 deletions

File tree

‎sqlmesh/core/renderer.py‎

Lines changed: 81 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,65 @@
4545
logger = logging.getLogger(__name__)
4646

4747

48+
class TableMapping(t.Dict[str, str]):
49+
"""A table name mapping that caches the dialect-normalized form of its keys.
50+
51+
`exp.replace_tables` normalizes every key of the mapping it's given, so resolving a single
52+
table against a mapping of every model in an environment costs O(N). Resolving it against
53+
this mapping costs a dictionary lookup, since each key is normalized once per dialect.
54+
"""
55+
56+
def __init__(self, *args: t.Any, **kwargs: t.Any):
57+
super().__init__(*args, **kwargs)
58+
self._normalized_keys: t.Dict[DialectType, t.Dict[str, str]] = {}
59+
60+
def normalized_keys(self, dialect: DialectType) -> t.Dict[str, str]:
61+
"""Returns a mapping from each normalized key to the last key that normalizes to it."""
62+
normalized_keys = self._normalized_keys.get(dialect)
63+
if normalized_keys is None:
64+
normalized_keys = {exp.normalize_table_name(key, dialect=dialect): key for key in self}
65+
self._normalized_keys[dialect] = normalized_keys
66+
return normalized_keys
67+
68+
def __setitem__(self, key: str, value: str) -> None:
69+
self._normalized_keys.clear()
70+
super().__setitem__(key, value)
71+
72+
def __delitem__(self, key: str) -> None:
73+
self._normalized_keys.clear()
74+
super().__delitem__(key)
75+
76+
def __ior__(self, other: t.Any) -> TableMapping: # type: ignore[override,misc]
77+
self._normalized_keys.clear()
78+
return super().__ior__(other)
79+
80+
def update(self, *args: t.Any, **kwargs: t.Any) -> None:
81+
self._normalized_keys.clear()
82+
super().update(*args, **kwargs)
83+
84+
def setdefault(self, key: str, default: str) -> str: # type: ignore[override]
85+
self._normalized_keys.clear()
86+
return super().setdefault(key, default)
87+
88+
def pop(self, key: str, *args: t.Any) -> t.Any:
89+
self._normalized_keys.clear()
90+
return super().pop(key, *args)
91+
92+
def popitem(self) -> t.Tuple[str, str]:
93+
self._normalized_keys.clear()
94+
return super().popitem()
95+
96+
def clear(self) -> None:
97+
self._normalized_keys.clear()
98+
super().clear()
99+
100+
101+
def _normalize_keys(mapping: t.Dict[str, str], dialect: DialectType) -> t.Dict[str, str]:
102+
if isinstance(mapping, TableMapping):
103+
return mapping.normalized_keys(dialect)
104+
return {exp.normalize_table_name(key, dialect=dialect): key for key in mapping}
105+
106+
48107
class BaseExpressionRenderer:
49108
def __init__(
50109
self,
@@ -330,35 +389,30 @@ def _resolve_table(
330389
table_mapping: t.Optional[t.Dict[str, str]] = None,
331390
deployability_index: t.Optional[DeployabilityIndex] = None,
332391
) -> exp.Table:
333-
table_mapping = table_mapping or {}
334-
if isinstance(table_name, str):
392+
table = t.cast(
393+
exp.Table, exp.maybe_parse(table_name, into=exp.Table, dialect=self._dialect)
394+
)
395+
396+
mapping: t.Dict[str, str] = {}
397+
if table_mapping:
398+
# An explicit mapping takes precedence over snapshots, so when one of its keys matches
399+
# the table, that key alone decides the result. Among equivalent keys, the last wins.
400+
key = _normalize_keys(table_mapping, self._dialect).get(
401+
exp.normalize_table_name(table, dialect=self._dialect)
402+
)
403+
if key is not None:
404+
mapping = {key: table_mapping[key]}
405+
406+
if not mapping and snapshots:
335407
# An exact FQN match avoids scanning unrelated snapshots.
336-
snapshot = snapshots.get(table_name) if snapshots else None
337-
if snapshot is None and table_name not in table_mapping:
338-
# Keys normalized under different dialects may differ in casing or quoting.
339-
# Fall back to the full mapping so exp.replace_tables can reconcile them.
340-
mapping = {
341-
**self._to_table_mapping((snapshots or {}).values(), deployability_index),
342-
**table_mapping,
343-
}
344-
else:
345-
mapping = {
346-
**self._to_table_mapping([snapshot] if snapshot else [], deployability_index),
347-
# Keep all explicit overrides to preserve precedence for equivalent keys.
348-
**table_mapping,
349-
}
350-
else:
351-
mapping = {
352-
**self._to_table_mapping((snapshots or {}).values(), deployability_index),
353-
**table_mapping,
354-
}
408+
snapshot = snapshots.get(table_name) if isinstance(table_name, str) else None
409+
# Keys normalized under different dialects may differ in casing or quoting.
410+
# Fall back to the full mapping so exp.replace_tables can reconcile them.
411+
mapping = self._to_table_mapping(
412+
[snapshot] if snapshot else snapshots.values(), deployability_index
413+
)
355414

356-
table = exp.replace_tables(
357-
t.cast(exp.Table, exp.maybe_parse(table_name, into=exp.Table, dialect=self._dialect)),
358-
mapping,
359-
dialect=self._dialect,
360-
copy=False,
361-
)
415+
table = exp.replace_tables(table, mapping, dialect=self._dialect, copy=False)
362416
# We quote the table here to mimic the behavior of _resolve_tables, otherwise we may end
363417
# up normalizing twice, because _to_table_mapping returns the mapped names unquoted.
364418
return (

‎sqlmesh/core/snapshot/definition.py‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from sqlmesh.core.model import Model, ModelKindMixin, ModelKindName, ViewKind, CustomKind
2525
from sqlmesh.core.model.definition import _Model
2626
from sqlmesh.core.node import IntervalUnit, NodeType
27+
from sqlmesh.core.renderer import TableMapping
2728
from sqlmesh.utils import sanitize_name, unique
2829
from sqlmesh.utils.dag import DAG
2930
from sqlmesh.utils.date import (
@@ -2007,14 +2008,17 @@ def to_view_mapping(
20072008
environment_naming_info: EnvironmentNamingInfo,
20082009
default_catalog: t.Optional[str] = None,
20092010
dialect: t.Optional[str] = None,
2010-
) -> t.Dict[str, str]:
2011-
return {
2012-
snapshot.name: snapshot.display_name(
2013-
environment_naming_info, default_catalog=default_catalog, dialect=dialect
2011+
) -> TableMapping:
2012+
return TableMapping(
2013+
(
2014+
snapshot.name,
2015+
snapshot.display_name(
2016+
environment_naming_info, default_catalog=default_catalog, dialect=dialect
2017+
),
20142018
)
20152019
for snapshot in snapshots
20162020
if snapshot.is_model
2017-
}
2021+
)
20182022

20192023

20202024
def has_paused_forward_only(

‎sqlmesh/core/snapshot/evaluator.py‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1284,6 +1284,8 @@ def _promote_snapshot(
12841284
table_mapping=table_mapping,
12851285
runtime_stage=RuntimeStage.PROMOTING,
12861286
)
1287+
# Renderers look snapshots up by model name, not by SnapshotId.
1288+
snapshots_by_name = {s.name: s for s in (snapshots or {}).values()}
12871289

12881290
with (
12891291
adapter.transaction(),
@@ -1294,14 +1296,16 @@ def _promote_snapshot(
12941296
view_name=view_name,
12951297
model=snapshot.model,
12961298
environment=environment_naming_info.name,
1297-
snapshots=snapshots,
1299+
snapshots=snapshots_by_name,
12981300
snapshot=snapshot,
12991301
**render_kwargs,
13001302
)
13011303

1302-
snapshot_by_name = {s.name: s for s in (snapshots or {}).values()}
1303-
render_kwargs["snapshots"] = snapshot_by_name
1304-
adapter.execute(snapshot.model.render_on_virtual_update(**render_kwargs))
1304+
adapter.execute(
1305+
snapshot.model.render_on_virtual_update(
1306+
snapshots=snapshots_by_name, **render_kwargs
1307+
)
1308+
)
13051309

13061310
if on_complete is not None:
13071311
on_complete(snapshot)

‎tests/core/test_model.py‎

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from sqlglot.schema import MappingSchema
1616
from sqlmesh.cli.project_init import init_example_project, ProjectTemplate
1717
from sqlmesh.core.environment import EnvironmentNamingInfo
18+
from sqlmesh.core.renderer import TableMapping
1819
from sqlmesh.core.model.kind import TimeColumn, ModelKindName, SeedKind
1920

2021
from sqlmesh import CustomMaterialization, CustomKind
@@ -9818,9 +9819,10 @@ def resolve_named(evaluator, name):
98189819
assert unmapped_result[0].sql() == '"does_not_exist"'
98199820

98209821

9822+
@pytest.mark.parametrize("mapping_type", [dict, TableMapping])
98219823
@pytest.mark.parametrize("include_exact_mapping", [False, True])
98229824
def test_resolve_table_preserves_dialect_equivalent_table_mapping_override(
9823-
make_snapshot: t.Callable, include_exact_mapping: bool
9825+
make_snapshot: t.Callable, include_exact_mapping: bool, mapping_type: t.Callable
98249826
):
98259827
"""An explicit mapping should override a snapshot mapping when its key is dialect-equivalent
98269828
to the resolved table name, even when the snapshot lookup is an exact match."""
@@ -9848,7 +9850,7 @@ def resolve_named(evaluator, name):
98489850
table_mapping = {parent.fqn: "earlier_table", **table_mapping}
98499851

98509852
post_statements = child.render_post_statements(
9851-
snapshots={parent.fqn: parent_snapshot}, table_mapping=table_mapping
9853+
snapshots={parent.fqn: parent_snapshot}, table_mapping=mapping_type(table_mapping)
98529854
)
98539855

98549856
assert post_statements[0].sql() == '"override_table"'
@@ -10221,6 +10223,87 @@ def items(self):
1022110223
assert ItemsCountingDict.items_call_count == 0
1022210224

1022310225

10226+
def test_resolve_table_with_view_mapping_uses_single_entry(
10227+
make_snapshot: t.Callable, mocker: MockerFixture
10228+
):
10229+
"""During promotion `table_mapping` maps every model in the environment to its view. Resolving
10230+
one table against it must not normalize every key in that mapping on every call
10231+
(https://github.com/SQLMesh/sqlmesh/issues/6017)."""
10232+
from sqlmesh.core.snapshot.definition import to_view_mapping
10233+
10234+
@macro()
10235+
def resolve_named(evaluator, name):
10236+
return evaluator.resolve_table(name.name)
10237+
10238+
snapshots = {}
10239+
for i in range(50):
10240+
other = load_sql_based_model(d.parse(f"MODEL (name db.other_{i}); SELECT 1 AS c"))
10241+
other_snapshot = make_snapshot(other)
10242+
other_snapshot.categorize_as(SnapshotChangeCategory.BREAKING)
10243+
snapshots[other.fqn] = other_snapshot
10244+
10245+
children = [
10246+
load_sql_based_model(
10247+
d.parse(
10248+
f"""
10249+
MODEL (name db.child_{i});
10250+
SELECT 1 AS c;
10251+
@resolve_named('db.other_{i}')
10252+
"""
10253+
)
10254+
)
10255+
for i in range(3)
10256+
]
10257+
for child in children:
10258+
child_snapshot = make_snapshot(child)
10259+
child_snapshot.categorize_as(SnapshotChangeCategory.BREAKING)
10260+
snapshots[child.fqn] = child_snapshot
10261+
10262+
table_mapping = to_view_mapping(snapshots.values(), EnvironmentNamingInfo(name="dev"))
10263+
spy = mocker.spy(exp, "replace_tables")
10264+
10265+
for i, child in enumerate(children):
10266+
rendered = child.render_post_statements(snapshots=snapshots, table_mapping=table_mapping)
10267+
assert rendered[0].sql() == f'"db__dev"."other_{i}"'
10268+
10269+
# One call for `this_model` and one for the resolved table, per child.
10270+
assert spy.call_count == 6
10271+
for call in spy.call_args_list:
10272+
assert len(call.args[1]) == 1
10273+
10274+
10275+
@pytest.mark.parametrize("dialect", ["duckdb", "snowflake"])
10276+
def test_table_mapping_normalized_keys(dialect: str):
10277+
table_mapping = TableMapping({'"db"."a"': "view_a", "db.A": "view_a_upper"})
10278+
10279+
normalized = table_mapping.normalized_keys(dialect)
10280+
# Keys that normalize to the same name resolve to the last one, like exp.replace_tables.
10281+
if dialect == "snowflake":
10282+
assert normalized == {"db.a": '"db"."a"', "DB.A": "db.A"}
10283+
else:
10284+
assert normalized == {"db.a": "db.A"}
10285+
# Normalization happens once per dialect.
10286+
assert table_mapping.normalized_keys(dialect) is normalized
10287+
10288+
# Every mutation invalidates the cache.
10289+
table_mapping["db.b"] = "view_b"
10290+
assert "db.b" in table_mapping.normalized_keys("duckdb")
10291+
table_mapping.update({"db.c": "view_c"})
10292+
assert "db.c" in table_mapping.normalized_keys("duckdb")
10293+
table_mapping.setdefault("db.d", "view_d")
10294+
assert "db.d" in table_mapping.normalized_keys("duckdb")
10295+
table_mapping |= {"db.e": "view_e"}
10296+
assert "db.e" in table_mapping.normalized_keys("duckdb")
10297+
del table_mapping["db.b"]
10298+
assert "db.b" not in table_mapping.normalized_keys("duckdb")
10299+
table_mapping.pop("db.c")
10300+
assert "db.c" not in table_mapping.normalized_keys("duckdb")
10301+
table_mapping.popitem()
10302+
assert "db.e" not in table_mapping.normalized_keys("duckdb")
10303+
table_mapping.clear()
10304+
assert table_mapping.normalized_keys("duckdb") == {}
10305+
10306+
1022410307
def test_cluster_with_complex_expression():
1022510308
expressions = d.parse(
1022610309
"""

0 commit comments

Comments
 (0)