Skip to content

Commit 82a30cd

Browse files
committed
Perf: add opt-in project-index loading for render
Signed-off-by: Andreas Fredhøi <andreas.fredhoi@fresio.no>
1 parent 44bb070 commit 82a30cd

5 files changed

Lines changed: 130 additions & 7 deletions

File tree

docs/reference/cli.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,9 @@ Options:
447447
only they will be expanded as raw queries.
448448
--dialect TEXT The SQL dialect to render the query as.
449449
--no-format Disable fancy formatting of the query.
450+
--use-project-index Use the persistent project index to load and
451+
render only the target model and its upstream
452+
dependencies.
450453
--max-text-width INTEGER The max number of characters in a segment before
451454
creating new lines in pretty mode.
452455
--leading-comma Determines whether or not the comma is leading

sqlmesh/cli/main.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,8 @@ def cli(
141141
if ctx.invoked_subcommand in SKIP_LOAD_COMMANDS:
142142
load = False
143143

144-
# Unlike the other commands above, lint can scope its own load for multi-project contexts.
145-
if ctx.invoked_subcommand == "lint":
144+
# These commands can scope their own load for multi-project contexts.
145+
if ctx.invoked_subcommand in ("lint", "render"):
146146
load = False
147147

148148
configs = load_configs(config, Context.CONFIG_TYPE, paths, dotenv_path=dotenv)
@@ -284,6 +284,11 @@ def init(
284284
help="The SQL dialect to render the query as.",
285285
)
286286
@click.option("--no-format", is_flag=True, help="Disable fancy formatting of the query.")
287+
@click.option(
288+
"--use-project-index",
289+
is_flag=True,
290+
help="Use the persistent project index to load and render only the target model and its upstream dependencies.",
291+
)
287292
@opt.format_options
288293
@click.pass_context
289294
@error_handler
@@ -297,19 +302,20 @@ def render(
297302
expand: t.Optional[t.Union[bool, t.Iterable[str]]] = None,
298303
dialect: t.Optional[str] = None,
299304
no_format: bool = False,
305+
use_project_index: bool = False,
300306
**format_kwargs: t.Any,
301307
) -> None:
302308
"""Render a model's query, optionally expanding referenced models."""
303-
model = ctx.obj.get_model(model, raise_if_missing=True)
304-
305309
rendered = ctx.obj.render(
306310
model,
307311
start=start,
308312
end=end,
309313
execution_time=execution_time,
310314
expand=expand,
315+
use_project_index=use_project_index,
311316
)
312317

318+
model = ctx.obj.get_model(model, raise_if_missing=True)
313319
format_config = ctx.obj.config_for_node(model).format
314320
format_kwargs = {
315321
**format_config.generator_options,

sqlmesh/core/context.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1186,6 +1186,7 @@ def render(
11861186
end: t.Optional[TimeLike] = None,
11871187
execution_time: t.Optional[TimeLike] = None,
11881188
expand: t.Union[bool, t.Iterable[str]] = False,
1189+
use_project_index: bool = False,
11891190
**kwargs: t.Any,
11901191
) -> exp.Expr:
11911192
"""Renders a model's query, expanding macros with provided kwargs, and optionally expanding referenced models.
@@ -1198,12 +1199,20 @@ def render(
11981199
expand: Whether or not to use expand materialized models, defaults to False.
11991200
If True, all referenced models are expanded as raw queries.
12001201
If a list, only referenced models are expanded as raw queries.
1202+
use_project_index: Whether to use the persistent project index to load and
1203+
render only the target model and its transitive upstream dependencies.
12011204
12021205
Returns:
12031206
The rendered expression.
12041207
"""
12051208
execution_time = execution_time or now()
12061209

1210+
if not self._loaded:
1211+
target_fqns = (
1212+
{self._node_or_snapshot_to_fqn(model_or_snapshot)} if use_project_index else None
1213+
)
1214+
self.load(model_fqns=target_fqns, use_project_index=use_project_index)
1215+
12071216
model = self.get_model(model_or_snapshot, raise_if_missing=True)
12081217

12091218
if expand and not isinstance(expand, bool):
@@ -1232,7 +1241,19 @@ def render(
12321241
)
12331242
return next(pandas_to_sql(t.cast(pd.DataFrame, df), model.columns_to_types))
12341243

1235-
snapshots = self.snapshots
1244+
if use_project_index:
1245+
# Only the target model and its transitive upstream dependencies can be referenced
1246+
# by the rendered query, so there is no need to create snapshots for the rest.
1247+
upstream_fqns = {model.fqn, *self.dag.upstream(model.fqn)}
1248+
upstream_models: UniqueKeyDict[str, Model] = UniqueKeyDict(
1249+
"models", {fqn: m for fqn, m in self._models.items() if fqn in upstream_fqns}
1250+
)
1251+
snapshots = self._snapshots(
1252+
upstream_models,
1253+
include_standalone_audits=False,
1254+
)
1255+
else:
1256+
snapshots = self.snapshots
12361257
deployability_index = DeployabilityIndex.create(snapshots.values(), start=start)
12371258

12381259
return model.render_query_or_raise(
@@ -2986,9 +3007,13 @@ def _get_engine_adapter(self, gateway: t.Optional[str] = None) -> EngineAdapter:
29863007
return self.engine_adapter
29873008

29883009
def _snapshots(
2989-
self, models_override: t.Optional[UniqueKeyDict[str, Model]] = None
3010+
self,
3011+
models_override: t.Optional[UniqueKeyDict[str, Model]] = None,
3012+
include_standalone_audits: bool = True,
29903013
) -> t.Dict[str, Snapshot]:
2991-
nodes = {**(models_override or self._models), **self._standalone_audits}
3014+
nodes: t.Dict[str, Node] = dict(models_override or self._models)
3015+
if include_standalone_audits:
3016+
nodes.update(self._standalone_audits)
29923017
snapshots = self._nodes_to_snapshots(nodes)
29933018
stored_snapshots = self.state_reader.get_snapshots(snapshots.values())
29943019

tests/cli/test_cli.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2073,6 +2073,19 @@ def test_render(runner: CliRunner, tmp_path: Path):
20732073

20742074
assert expected in cleaned_output
20752075

2076+
indexed_result = runner.invoke(
2077+
cli,
2078+
[
2079+
"--paths",
2080+
str(tmp_path),
2081+
"render",
2082+
"sqlmesh_example.full_model",
2083+
"--use-project-index",
2084+
"--no-format",
2085+
],
2086+
)
2087+
assert indexed_result.exit_code == 0
2088+
20762089

20772090
@time_machine.travel(FREEZE_TIME)
20782091
def test_signals(runner: CliRunner, tmp_path: Path):

tests/core/test_context.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,82 @@ def test_render_seed_model(sushi_context, assert_exp_eq):
274274
)
275275

276276

277+
@pytest.mark.slow
278+
def test_render_only_creates_snapshots_for_upstream_models(sushi_context: Context):
279+
model = sushi_context.get_model("sushi.top_waiters", raise_if_missing=True)
280+
upstream_fqns = {model.fqn, *sushi_context.dag.upstream(model.fqn)}
281+
282+
# Sanity check that the project contains models outside of the target model's subgraph.
283+
assert set(sushi_context.models) - upstream_fqns
284+
285+
with patch.object(
286+
sushi_context.state_reader,
287+
"get_snapshots",
288+
wraps=sushi_context.state_reader.get_snapshots,
289+
) as default_get_snapshots_mock:
290+
sushi_context.render("sushi.top_waiters")
291+
292+
default_requested_names = {
293+
snapshot.name
294+
for call_args in default_get_snapshots_mock.call_args_list
295+
for snapshot in call_args.args[0]
296+
}
297+
assert set(sushi_context.models) <= default_requested_names
298+
299+
with patch.object(
300+
sushi_context.state_reader,
301+
"get_snapshots",
302+
wraps=sushi_context.state_reader.get_snapshots,
303+
) as get_snapshots_mock:
304+
sushi_context.render("sushi.top_waiters", use_project_index=True)
305+
306+
requested_names = {
307+
snapshot.name
308+
for call_args in get_snapshots_mock.call_args_list
309+
for snapshot in call_args.args[0]
310+
}
311+
assert model.fqn in requested_names
312+
assert requested_names == upstream_fqns
313+
314+
315+
def test_render_only_loads_upstream_model_files(tmp_path: pathlib.Path) -> None:
316+
create_temp_file(
317+
tmp_path,
318+
pathlib.Path("models", "a.sql"),
319+
"MODEL(name a, kind FULL); SELECT 1 AS col;",
320+
)
321+
create_temp_file(
322+
tmp_path,
323+
pathlib.Path("models", "b.sql"),
324+
"MODEL(name b, kind FULL); SELECT col FROM a;",
325+
)
326+
create_temp_file(
327+
tmp_path,
328+
pathlib.Path("models", "c.sql"),
329+
"MODEL(name c, kind FULL); SELECT col FROM b;",
330+
)
331+
config = Config(model_defaults=ModelDefaultsConfig(dialect="duckdb"))
332+
333+
# Populate the persistent model path/dependency index.
334+
Context(config=config, paths=tmp_path, load=False).load(use_project_index=True)
335+
336+
ctx = Context(config=config, paths=tmp_path, load=False)
337+
loader = t.cast(SqlMeshLoader, ctx._loaders[0])
338+
with patch.object(
339+
loader,
340+
"_load_sql_models",
341+
wraps=loader._load_sql_models,
342+
) as load_sql_models_mock:
343+
ctx.render("b", use_project_index=True)
344+
345+
selected_paths = load_sql_models_mock.call_args.kwargs["selected_paths"]
346+
assert {path.name for path in selected_paths} == {"a.sql", "b.sql"}
347+
assert set(ctx.models) == {
348+
ctx.get_model("a", raise_if_missing=True).fqn,
349+
ctx.get_model("b", raise_if_missing=True).fqn,
350+
}
351+
352+
277353
@pytest.mark.slow
278354
def test_diff(sushi_context: Context, mocker: MockerFixture):
279355
mock_console = mocker.Mock()

0 commit comments

Comments
 (0)