Skip to content

Commit 812bc27

Browse files
authored
feat!: linting rule no unregistered external models (#5009)
1 parent dc3a34c commit 812bc27

File tree

4 files changed

+91
-5
lines changed

4 files changed

+91
-5
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: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
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):
@@ -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 file. This can be done by running 'sqlmesh create_external_models'.",
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+
)

vscode/extension/tests/quickfix.spec.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,28 @@ test('noselectstar quickfix', async ({ page, sharedCodeServer }) => {
1717
const configPath = path.join(tempDir, 'config.py')
1818
const read = await fs.readFile(configPath, 'utf8')
1919
// Replace linter to be on
20+
const target = 'enabled=True'
2021
const replaced = read.replace('enabled=False', 'enabled=True')
22+
// Assert replaced correctly
23+
expect(replaced).toContain(target)
24+
25+
// Replace the rules to only have noselectstar
26+
const targetRules = `rules=[
27+
"noselectstar",
28+
],`
2129
const replacedTheOtherRules = replaced.replace(
2230
`rules=[
2331
"ambiguousorinvalidcolumn",
2432
"invalidselectstarexpansion",
2533
"noselectstar",
2634
"nomissingaudits",
2735
"nomissingowner",
36+
"nomissingexternalmodels",
2837
],`,
29-
`rules=[
30-
"noselectstar",
31-
],
32-
`,
38+
targetRules,
3339
)
40+
expect(replacedTheOtherRules).toContain(targetRules)
41+
3442
await fs.writeFile(configPath, replacedTheOtherRules)
3543
// Replace the file to cause the error
3644
const modelPath = path.join(tempDir, 'models', 'latest_order.sql')

0 commit comments

Comments
 (0)