Skip to content

Commit 6603eaa

Browse files
committed
feat: linting rule no unregistered external models
1 parent afb7907 commit 6603eaa

File tree

3 files changed

+80
-2
lines changed

3 files changed

+80
-2
lines changed

examples/sushi/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"noselectstar",
4949
"nomissingaudits",
5050
"nomissingowner",
51+
"nomissingexternalmodels",
5152
],
5253
),
5354
)

sqlmesh/core/linter/rules/builtin.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@
1010
from sqlmesh.core.linter.helpers import TokenPositionDetails, get_range_of_model_block
1111
from sqlmesh.core.linter.rule import Rule, RuleViolation, Range, Fix, TextEdit
1212
from sqlmesh.core.linter.definition import RuleSet
13-
from sqlmesh.core.model import Model, SqlModel
13+
from sqlmesh.core.model import Model, SqlModel, ExternalModel
1414

1515

1616
class NoSelectStar(Rule):
17-
"""Query should not contain SELECT * on its outer most projections, even if it can be expanded."""
17+
"""Query should not contain SELECT * on its outermost projections, even if it can be expanded."""
1818

1919
def check_model(self, model: Model) -> t.Optional[RuleViolation]:
2020
# Only applies to SQL models, as other model types do not have a query.
@@ -110,4 +110,31 @@ def check_model(self, model: Model) -> t.Optional[RuleViolation]:
110110
return self.violation()
111111

112112

113+
class NoMissingExternalModels(Rule):
114+
"""All external models must be registered in the external_models.yaml file"""
115+
116+
def check_model(self, model: Model) -> t.Optional[RuleViolation]:
117+
# Ignore external models themselves, because either they are registered,
118+
# and if they are not, they will be caught as referenced in another model.
119+
if isinstance(model, ExternalModel):
120+
return None
121+
122+
# Handle other models that may refer to the external models.
123+
not_registered_external_models: t.Set[str] = set()
124+
for depends_on_model in model.depends_on:
125+
existing_model = self.context.get_model(depends_on_model)
126+
if existing_model is None:
127+
not_registered_external_models.add(depends_on_model)
128+
129+
if not not_registered_external_models:
130+
return None
131+
132+
return RuleViolation(
133+
rule=self,
134+
violation_msg=f"Model '{model.name}' depends on unregistered external models: "
135+
f"{', '.join(m for m in not_registered_external_models)}. "
136+
"Please register them in the external_models.yaml file.",
137+
)
138+
139+
113140
BUILTIN_RULES = RuleSet(subclasses(__name__, Rule, (Rule,)))

tests/core/linter/test_builtin.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import os
2+
3+
from sqlmesh import Context
4+
5+
6+
def test_no_missing_external_models(tmp_path, copy_to_temp_path) -> None:
7+
"""
8+
Tests that the linter correctly identifies unregistered external model dependencies.
9+
10+
This test removes the `external_models.yaml` file from the sushi example project,
11+
enables the linter, and verifies that the linter raises a violation for a model
12+
that depends on unregistered external models.
13+
"""
14+
sushi_paths = copy_to_temp_path("examples/sushi")
15+
sushi_path = sushi_paths[0]
16+
17+
# Remove the external_models.yaml file
18+
os.remove(sushi_path / "external_models.yaml")
19+
20+
# Override the config.py to turn on lint
21+
with open(sushi_path / "config.py", "r") as f:
22+
read_file = f.read()
23+
24+
before = """ linter=LinterConfig(
25+
enabled=False,
26+
rules=[
27+
"ambiguousorinvalidcolumn",
28+
"invalidselectstarexpansion",
29+
"noselectstar",
30+
"nomissingaudits",
31+
"nomissingowner",
32+
"nomissingexternalmodels",
33+
],
34+
),"""
35+
after = """linter=LinterConfig(enabled=True, rules=["nomissingexternalmodels"]),"""
36+
read_file = read_file.replace(before, after)
37+
assert after in read_file
38+
with open(sushi_path / "config.py", "w") as f:
39+
f.writelines(read_file)
40+
41+
# Load the context with the temporary sushi path
42+
context = Context(paths=[sushi_path])
43+
44+
# Lint the models
45+
lints = context.lint_models(raise_on_error=False)
46+
assert len(lints) == 1
47+
assert (
48+
"Model 'sushi.customers' depends on unregistered external models: "
49+
in lints[0].violation_msg
50+
)

0 commit comments

Comments
 (0)