Skip to content

Commit bcbed2f

Browse files
committed
Refine and document project-index linting
Signed-off-by: Andreas Fredhøi <andreas.fredhoi@fresio.no>
1 parent 20035da commit bcbed2f

5 files changed

Lines changed: 120 additions & 38 deletions

File tree

docs/guides/configuration.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,16 @@ By default, the SQLMesh cache is stored in a `.cache` directory within your proj
329329

330330
The cache directory is automatically created if it doesn't exist. You can clear the cache using the `sqlmesh clean` command.
331331

332+
#### Project index
333+
334+
The `--use-project-index` option on supported commands maintains a persistent model dependency index in the cache directory. Each project writes a file named `<project>_<hash>_model_index.json`.
335+
336+
A full project load with the option enabled creates or refreshes the index. SQLMesh invalidates it when relevant configuration, gateway, macro, audit, or signal metadata changes, or when the set of model files changes. If the index is missing, invalid, or stale, SQLMesh safely falls back to a full project load and rebuilds it.
337+
338+
For operations targeting selected models, the index allows SQLMesh to load only those models and their upstream dependencies.
339+
340+
In multi-repository projects, dependencies that cross project boundaries may not be represented by an individual project's index. SQLMesh detects incomplete scoped loads and falls back to loading the full configured project set.
341+
332342
### Table/view storage locations
333343

334344
SQLMesh creates schemas, physical tables, and views in the data warehouse/engine. Learn more about why and how SQLMesh creates schema in the ["Why does SQLMesh create schemas?" FAQ](../faq/faq.md#schema-question).

docs/reference/cli.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -650,9 +650,12 @@ Usage: sqlmesh lint [OPTIONS]
650650
651651
Options:
652652
--model TEXT A model to lint. Multiple models can be linted. If no models are specified, every model will be linted.
653+
--use-project-index Use the persistent project index. With --model, only the selected models and their upstream dependencies
654+
are loaded, resolved, and validated, so errors in unrelated models are not reported. Without --model,
655+
every model is still loaded and linted.
653656
--local Lint using only locally loaded project files without loading state. In multi-repository setups, or when
654657
linting only a subset of projects, this may cause additional linting errors because SQLMesh will not resolve
655658
references or schemas from models that exist only in remote state.
656659
--help Show this message and exit.
657660
658-
```
661+
```

sqlmesh/core/context.py

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3419,32 +3419,30 @@ def lint_models(
34193419
raise_on_error: bool = True,
34203420
use_project_index: bool = False,
34213421
) -> t.List[AnnotatedRuleViolation]:
3422-
input_models = list(models) if models is not None else []
3422+
models = list(models) if models is not None else []
34233423

34243424
if not self._loaded:
3425-
if input_models and use_project_index:
3426-
target_fqns = {
3425+
target_fqns = (
3426+
{
34273427
normalize_model_name(
34283428
model,
34293429
default_catalog=self.default_catalog,
34303430
dialect=self.default_dialect,
34313431
)
34323432
if isinstance(model, str)
34333433
else model.fqn
3434-
for model in input_models
3434+
for model in models
34353435
}
3436-
self.load(
3437-
model_fqns=target_fqns,
3438-
use_project_index=True,
3439-
)
3440-
else:
3441-
self.load(use_project_index=use_project_index)
3436+
if models and use_project_index
3437+
else None
3438+
)
3439+
self.load(model_fqns=target_fqns, use_project_index=use_project_index)
34423440

34433441
found_error = False
34443442

34453443
model_list = (
3446-
list(self.get_model(model, raise_if_missing=True) for model in input_models)
3447-
if input_models
3444+
list(self.get_model(model, raise_if_missing=True) for model in models)
3445+
if models
34483446
else self.models.values()
34493447
)
34503448
all_violations = []

sqlmesh/core/loader.py

Lines changed: 48 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import linecache
88
import os
99
import re
10+
import tempfile
1011
import typing as t
1112
from collections import Counter, defaultdict
1213
from dataclasses import dataclass
@@ -605,9 +606,9 @@ def _model_index_path(self) -> Path:
605606
/ f"{self.config.project or 'default'}_{project_path_hash}_model_index.json"
606607
)
607608

608-
def _model_index_id(self) -> t.List[t.Optional[t.Union[str, float]]]:
609+
def _model_index_id(self) -> t.List[t.Optional[t.Union[str, int, float]]]:
609610
return [
610-
str(self.MODEL_INDEX_VERSION),
611+
self.MODEL_INDEX_VERSION,
611612
self.config.fingerprint,
612613
self.context.default_catalog,
613614
self.context.gateway or self.config.default_gateway_name,
@@ -640,20 +641,35 @@ def _selected_model_paths(
640641
except (OSError, ValueError):
641642
return None
642643

644+
if not isinstance(index, dict):
645+
return None
646+
643647
if index.get("index_id") != self._model_index_id():
644648
return None
645649

646650
current_paths = self._model_paths()
647-
indexed_files = index.get("files", {})
651+
indexed_files = index.get("files")
652+
if not isinstance(indexed_files, dict) or not all(
653+
isinstance(relative_path, str) and isinstance(file_models, dict)
654+
for relative_path, file_models in indexed_files.items()
655+
):
656+
return None
657+
648658
indexed_paths = {self.config_path / relative_path for relative_path in indexed_files}
649659
if current_paths != indexed_paths:
650660
return None
651661

652662
model_to_path: t.Dict[str, Path] = {}
653663
dependencies: t.Dict[str, t.Set[str]] = {}
654-
for relative_path, file_info in indexed_files.items():
664+
for relative_path, file_models in indexed_files.items():
655665
path = self.config_path / relative_path
656-
for fqn, depends_on in file_info.get("models", {}).items():
666+
for fqn, depends_on in file_models.items():
667+
if (
668+
not isinstance(fqn, str)
669+
or not isinstance(depends_on, list)
670+
or not all(isinstance(dependency, str) for dependency in depends_on)
671+
):
672+
return None
657673
model_to_path[fqn] = path
658674
dependencies[fqn] = set(depends_on)
659675

@@ -673,25 +689,36 @@ def _write_model_index(self, models: UniqueKeyDict[str, Model]) -> None:
673689
if not model_paths:
674690
return
675691

676-
files: t.Dict[str, t.Dict[str, t.Any]] = {}
677-
for path in model_paths:
678-
files[str(path.relative_to(self.config_path))] = {
679-
"models": {},
680-
}
681-
692+
# Maps each model file to the models it defines and their dependencies.
693+
files: t.Dict[str, t.Dict[str, t.List[str]]] = {
694+
str(path.relative_to(self.config_path)): {} for path in model_paths
695+
}
682696
for model in models.values():
683-
if model._path not in model_paths:
684-
continue
685-
relative_path = str(t.cast(Path, model._path).relative_to(self.config_path))
686-
files[relative_path]["models"][model.fqn] = sorted(model.depends_on)
697+
if model._path in model_paths:
698+
relative_path = str(t.cast(Path, model._path).relative_to(self.config_path))
699+
files[relative_path][model.fqn] = sorted(model.depends_on)
687700

688701
self._model_index_path.parent.mkdir(parents=True, exist_ok=True)
689-
temporary_path = self._model_index_path.with_suffix(".tmp")
690-
temporary_path.write_text(
691-
json.dumps({"index_id": self._model_index_id(), "files": files}, sort_keys=True),
692-
encoding="utf-8",
693-
)
694-
temporary_path.replace(self._model_index_path)
702+
temporary_path: t.Optional[Path] = None
703+
try:
704+
with tempfile.NamedTemporaryFile(
705+
mode="w",
706+
encoding="utf-8",
707+
dir=self._model_index_path.parent,
708+
prefix=f".{self._model_index_path.name}.",
709+
suffix=".tmp",
710+
delete=False,
711+
) as temporary_file:
712+
json.dump(
713+
{"index_id": self._model_index_id(), "files": files},
714+
temporary_file,
715+
sort_keys=True,
716+
)
717+
temporary_path = Path(temporary_file.name)
718+
temporary_path.replace(self._model_index_path)
719+
finally:
720+
if temporary_path is not None:
721+
temporary_path.unlink(missing_ok=True)
695722

696723
def _load_sql_models(
697724
self,

tests/core/test_context.py

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import logging
23
import pathlib
34
import typing as t
@@ -1403,13 +1404,13 @@ def get_sushi_fingerprints(context: Context):
14031404
def test_physical_schema_mapping(tmp_path: pathlib.Path) -> None:
14041405
create_temp_file(
14051406
tmp_path,
1406-
pathlib.Path("models", "a.sql"),
1407+
pathlib.Path(pathlib.Path("models"), "a.sql"),
14071408
"MODEL(name foo_staging.model_a); SELECT 1;",
14081409
)
14091410

14101411
create_temp_file(
14111412
tmp_path,
1412-
pathlib.Path("models", "b.sql"),
1413+
pathlib.Path(pathlib.Path("models"), "b.sql"),
14131414
"MODEL(name testone.model_b); SELECT 1;",
14141415
)
14151416

@@ -2951,7 +2952,7 @@ def create_context() -> Context:
29512952

29522953
create_temp_file(
29532954
tmp_path,
2954-
pathlib.Path(pathlib.Path("models"), "a.sql"),
2955+
pathlib.Path("models", "a.sql"),
29552956
"MODEL(name a); SELECT 1 AS col FROM raw.unregistered_source;",
29562957
)
29572958
create_temp_file(tmp_path, pathlib.Path("models", "b.sql"), "MODEL(name b); SELECT col FROM a;")
@@ -2990,7 +2991,7 @@ def create_context() -> Context:
29902991
# dependency outside this set, Context.load safely retries with a full load.
29912992
create_temp_file(
29922993
tmp_path,
2993-
pathlib.Path(pathlib.Path("models"), "b.sql"),
2994+
pathlib.Path("models", "b.sql"),
29942995
"MODEL(name b); SELECT col + 1 AS col FROM a;",
29952996
)
29962997
ctx = create_context()
@@ -3052,6 +3053,49 @@ def create_context() -> Context:
30523053
assert set(schemas_mock.call_args.kwargs["models"]) == set(ctx.models)
30533054

30543055

3056+
@pytest.mark.parametrize("invalid_files_shape", ["file_list", "model_list"])
3057+
def test_invalid_model_index_falls_back_to_full_load(
3058+
tmp_path: pathlib.Path, invalid_files_shape: str
3059+
) -> None:
3060+
create_temp_file(
3061+
tmp_path,
3062+
pathlib.Path("models", "a.sql"),
3063+
"MODEL(name a); SELECT 1 AS col;",
3064+
)
3065+
create_temp_file(
3066+
tmp_path,
3067+
pathlib.Path("models", "b.sql"),
3068+
"MODEL(name b); SELECT col FROM a;",
3069+
)
3070+
config = Config(model_defaults=ModelDefaultsConfig(dialect="duckdb"))
3071+
3072+
indexed_context = Context(config=config, paths=tmp_path, load=False)
3073+
indexed_context.load(use_project_index=True)
3074+
indexed_loader = t.cast(SqlMeshLoader, indexed_context._loaders[0])
3075+
index = json.loads(indexed_loader._model_index_path.read_text(encoding="utf-8"))
3076+
if invalid_files_shape == "file_list":
3077+
index["files"] = list(index["files"])
3078+
else:
3079+
relative_path = next(iter(index["files"]))
3080+
index["files"][relative_path] = []
3081+
indexed_loader._model_index_path.write_text(json.dumps(index), encoding="utf-8")
3082+
3083+
context = Context(config=config, paths=tmp_path, load=False)
3084+
loader = t.cast(SqlMeshLoader, context._loaders[0])
3085+
with patch.object(
3086+
loader,
3087+
"_load_sql_models",
3088+
wraps=loader._load_sql_models,
3089+
) as load_sql_models_mock:
3090+
assert context.lint_models(["b"], use_project_index=True) == []
3091+
3092+
assert load_sql_models_mock.call_args.kwargs["selected_paths"] is None
3093+
assert set(context.models) == {
3094+
context.get_model("a", raise_if_missing=True).fqn,
3095+
context.get_model("b", raise_if_missing=True).fqn,
3096+
}
3097+
3098+
30553099
def test_plan_selector_expression_no_match(sushi_context: Context) -> None:
30563100
with pytest.raises(
30573101
PlanError,

0 commit comments

Comments
 (0)