Skip to content

Commit a7604f9

Browse files
committed
feat: nomissingexternalmodels returns fix
- nomissingexternalmodels lint rule now returns a fix that can be used by the IDE
1 parent 5ccd57e commit a7604f9

File tree

2 files changed

+165
-3
lines changed

2 files changed

+165
-3
lines changed

sqlmesh/core/linter/rules/builtin.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,20 @@
22

33
from __future__ import annotations
44

5+
import os
56
import typing as t
67

78
from sqlglot.expressions import Star
89
from sqlglot.helper import subclasses
910

11+
from sqlmesh.core.constants import EXTERNAL_MODELS_YAML
1012
from sqlmesh.core.dialect import normalize_model_name
1113
from sqlmesh.core.linter.helpers import (
1214
TokenPositionDetails,
1315
get_range_of_model_block,
1416
read_range_from_string,
1517
)
16-
from sqlmesh.core.linter.rule import Rule, RuleViolation, Range, Fix, TextEdit
18+
from sqlmesh.core.linter.rule import Rule, RuleViolation, Range, Fix, TextEdit, Position
1719
from sqlmesh.core.linter.definition import RuleSet
1820
from sqlmesh.core.model import Model, SqlModel, ExternalModel
1921
from sqlmesh.utils.lineage import extract_references_from_query, ExternalModelReference
@@ -185,12 +187,14 @@ def check_model(
185187
violations = []
186188
for ref_name, ref in external_references.items():
187189
if ref_name in not_registered_external_models:
190+
fix = self.create_fix(ref_name)
188191
violations.append(
189192
RuleViolation(
190193
rule=self,
191194
violation_msg=f"Model '{model.fqn}' depends on unregistered external model '{ref_name}'. "
192195
"Please register it in the external models file. This can be done by running 'sqlmesh create_external_models'.",
193196
violation_range=ref.range,
197+
fixes=[fix] if fix else [],
194198
)
195199
)
196200

@@ -212,5 +216,46 @@ def _standard_error_message(
212216
"Please register them in the external models file. This can be done by running 'sqlmesh create_external_models'.",
213217
)
214218

219+
def create_fix(self, model_name: str) -> t.Optional[Fix]:
220+
"""
221+
Add an external model to the external models file.
222+
- If no external models file exists, it will create one with the model.
223+
- If the model already exists, it will not add it again.
224+
"""
225+
root = self.context.path
226+
if not root:
227+
return None
228+
229+
external_models_path = root / EXTERNAL_MODELS_YAML
230+
if not external_models_path.exists():
231+
return None
232+
233+
# Figure out the position to insert the new external model at the end of the file, whether
234+
# needs new line or not.
235+
with open(external_models_path, "r", encoding="utf-8") as file:
236+
lines = file.read()
237+
238+
# If a file ends in newline, we can add the new model directly.
239+
split_lines = lines.splitlines()
240+
if lines.endswith(os.linesep):
241+
new_text = f"- name: '{model_name}'\n"
242+
position = Position(line=len(split_lines), character=0)
243+
else:
244+
new_text = f"\n- name: '{model_name}'\n"
245+
position = Position(
246+
line=len(split_lines) - 1, character=len(split_lines[-1]) if split_lines else 0
247+
)
248+
249+
return Fix(
250+
title="Add external model",
251+
edits=[
252+
TextEdit(
253+
path=external_models_path,
254+
range=Range(start=position, end=position),
255+
new_text=new_text,
256+
)
257+
],
258+
)
259+
215260

216261
BUILTIN_RULES = RuleSet(subclasses(__name__, Rule, (Rule,)))

tests/core/linter/test_builtin.py

Lines changed: 119 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os
22

33
from sqlmesh import Context
4+
from sqlmesh.core.linter.rule import Position, Range
45

56

67
def test_no_missing_external_models(tmp_path, copy_to_temp_path) -> None:
@@ -44,8 +45,124 @@ def test_no_missing_external_models(tmp_path, copy_to_temp_path) -> None:
4445
# Lint the models
4546
lints = context.lint_models(raise_on_error=False)
4647
assert len(lints) == 1
47-
assert lints[0].violation_range is not None
48+
lint = lints[0]
49+
assert lint.violation_range is not None
4850
assert (
49-
lints[0].violation_msg
51+
lint.violation_msg
5052
== """Model '"memory"."sushi"."customers"' depends on unregistered external model '"memory"."raw"."demographics"'. Please register it in the external models file. This can be done by running 'sqlmesh create_external_models'."""
5153
)
54+
assert len(lint.fixes) == 0
55+
56+
57+
def test_no_missing_external_models_with_existing_file_ending_in_newline(
58+
tmp_path, copy_to_temp_path
59+
) -> None:
60+
sushi_paths = copy_to_temp_path("examples/sushi")
61+
sushi_path = sushi_paths[0]
62+
63+
# Overwrite the external_models.yaml file to end with a random file and a newline
64+
os.remove(sushi_path / "external_models.yaml")
65+
with open(sushi_path / "external_models.yaml", "w") as f:
66+
f.write("- name: memory.raw.test\n")
67+
68+
# Override the config.py to turn on lint
69+
with open(sushi_path / "config.py", "r") as f:
70+
read_file = f.read()
71+
72+
before = """ linter=LinterConfig(
73+
enabled=False,
74+
rules=[
75+
"ambiguousorinvalidcolumn",
76+
"invalidselectstarexpansion",
77+
"noselectstar",
78+
"nomissingaudits",
79+
"nomissingowner",
80+
"nomissingexternalmodels",
81+
],
82+
),"""
83+
after = """linter=LinterConfig(enabled=True, rules=["nomissingexternalmodels"]),"""
84+
read_file = read_file.replace(before, after)
85+
assert after in read_file
86+
with open(sushi_path / "config.py", "w") as f:
87+
f.writelines(read_file)
88+
89+
# Load the context with the temporary sushi path
90+
context = Context(paths=[sushi_path])
91+
92+
# Lint the models
93+
lints = context.lint_models(raise_on_error=False)
94+
assert len(lints) == 1
95+
lint = lints[0]
96+
assert lint.violation_range is not None
97+
assert (
98+
lint.violation_msg
99+
== """Model '"memory"."sushi"."customers"' depends on unregistered external model '"memory"."raw"."demographics"'. Please register it in the external models file. This can be done by running 'sqlmesh create_external_models'."""
100+
)
101+
assert len(lint.fixes) == 1
102+
fix = lint.fixes[0]
103+
assert len(fix.edits) == 1
104+
edit = fix.edits[0]
105+
assert edit.new_text == """- name: '"memory"."raw"."demographics"'\n"""
106+
assert edit.range == Range(
107+
start=Position(line=1, character=0),
108+
end=Position(line=1, character=0),
109+
)
110+
fix_path = sushi_path / "external_models.yaml"
111+
assert edit.path == fix_path
112+
113+
114+
def test_no_missing_external_models_with_existing_file_not_ending_in_newline(
115+
tmp_path, copy_to_temp_path
116+
) -> None:
117+
sushi_paths = copy_to_temp_path("examples/sushi")
118+
sushi_path = sushi_paths[0]
119+
120+
# Overwrite the external_models.yaml file to end with a random file and a newline
121+
os.remove(sushi_path / "external_models.yaml")
122+
with open(sushi_path / "external_models.yaml", "w") as f:
123+
f.write("- name: memory.raw.test")
124+
125+
# Override the config.py to turn on lint
126+
with open(sushi_path / "config.py", "r") as f:
127+
read_file = f.read()
128+
129+
before = """ linter=LinterConfig(
130+
enabled=False,
131+
rules=[
132+
"ambiguousorinvalidcolumn",
133+
"invalidselectstarexpansion",
134+
"noselectstar",
135+
"nomissingaudits",
136+
"nomissingowner",
137+
"nomissingexternalmodels",
138+
],
139+
),"""
140+
after = """linter=LinterConfig(enabled=True, rules=["nomissingexternalmodels"]),"""
141+
read_file = read_file.replace(before, after)
142+
assert after in read_file
143+
with open(sushi_path / "config.py", "w") as f:
144+
f.writelines(read_file)
145+
146+
# Load the context with the temporary sushi path
147+
context = Context(paths=[sushi_path])
148+
149+
# Lint the models
150+
lints = context.lint_models(raise_on_error=False)
151+
assert len(lints) == 1
152+
lint = lints[0]
153+
assert lint.violation_range is not None
154+
assert (
155+
lint.violation_msg
156+
== """Model '"memory"."sushi"."customers"' depends on unregistered external model '"memory"."raw"."demographics"'. Please register it in the external models file. This can be done by running 'sqlmesh create_external_models'."""
157+
)
158+
assert len(lint.fixes) == 1
159+
fix = lint.fixes[0]
160+
assert len(fix.edits) == 1
161+
edit = fix.edits[0]
162+
assert edit.new_text == """\n- name: '"memory"."raw"."demographics"'\n"""
163+
assert edit.range == Range(
164+
start=Position(line=0, character=23),
165+
end=Position(line=0, character=23),
166+
)
167+
fix_path = sushi_path / "external_models.yaml"
168+
assert edit.path == fix_path

0 commit comments

Comments
 (0)