Skip to content

Commit 275a770

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 275a770

File tree

2 files changed

+162
-3
lines changed

2 files changed

+162
-3
lines changed

sqlmesh/core/linter/rules/builtin.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@
77
from sqlglot.expressions import Star
88
from sqlglot.helper import subclasses
99

10+
from sqlmesh.core.constants import EXTERNAL_MODELS_YAML
1011
from sqlmesh.core.dialect import normalize_model_name
1112
from sqlmesh.core.linter.helpers import (
1213
TokenPositionDetails,
1314
get_range_of_model_block,
1415
read_range_from_string,
1516
)
16-
from sqlmesh.core.linter.rule import Rule, RuleViolation, Range, Fix, TextEdit
17+
from sqlmesh.core.linter.rule import Rule, RuleViolation, Range, Fix, TextEdit, Position
1718
from sqlmesh.core.linter.definition import RuleSet
1819
from sqlmesh.core.model import Model, SqlModel, ExternalModel
1920
from sqlmesh.utils.lineage import extract_references_from_query, ExternalModelReference
@@ -185,12 +186,14 @@ def check_model(
185186
violations = []
186187
for ref_name, ref in external_references.items():
187188
if ref_name in not_registered_external_models:
189+
fix = self.create_fix(ref_name)
188190
violations.append(
189191
RuleViolation(
190192
rule=self,
191193
violation_msg=f"Model '{model.fqn}' depends on unregistered external model '{ref_name}'. "
192194
"Please register it in the external models file. This can be done by running 'sqlmesh create_external_models'.",
193195
violation_range=ref.range,
196+
fixes=[fix] if fix else [],
194197
)
195198
)
196199

@@ -212,5 +215,44 @@ def _standard_error_message(
212215
"Please register them in the external models file. This can be done by running 'sqlmesh create_external_models'.",
213216
)
214217

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

216258
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=0, character=23),
108+
end=Position(line=0, character=23),
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)