Skip to content

Commit d21e40d

Browse files
committed
feat: linting rule for unregistered external models
[ci skip]
1 parent 812bc27 commit d21e40d

File tree

15 files changed

+580
-420
lines changed

15 files changed

+580
-420
lines changed

sqlmesh/core/linter/definition.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,10 @@ def check_model(self, model: Model, context: GenericContext) -> t.List[RuleViola
108108

109109
for rule in self._underlying.values():
110110
violation = rule(context).check_model(model)
111-
111+
if isinstance(violation, RuleViolation):
112+
violation = [violation]
112113
if violation:
113-
violations.append(violation)
114+
violations.extend(violation)
114115

115116
return violations
116117

sqlmesh/core/linter/rule.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@ def __init__(self, context: GenericContext):
7070
self.context = context
7171

7272
@abc.abstractmethod
73-
def check_model(self, model: Model) -> t.Optional[RuleViolation]:
73+
def check_model(
74+
self, model: Model
75+
) -> t.Optional[t.Union[RuleViolation, t.List[RuleViolation]]]:
7476
"""The evaluation function that'll check for a violation of this rule."""
7577

7678
@property

sqlmesh/core/linter/rules/builtin.py

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
from sqlmesh.core.linter.rule import Rule, RuleViolation, Range, Fix, TextEdit
1212
from sqlmesh.core.linter.definition import RuleSet
1313
from sqlmesh.core.model import Model, SqlModel, ExternalModel
14+
from sqlmesh.core.model import Model, SqlModel, ExternalModel
15+
from sqlmesh.core.linter.rules.helpers.lineage import find_external_model_ranges
1416

1517

1618
class NoSelectStar(Rule):
@@ -110,31 +112,82 @@ def check_model(self, model: Model) -> t.Optional[RuleViolation]:
110112
return self.violation()
111113

112114

113-
class NoMissingExternalModels(Rule):
115+
class NoUnregisteredExternalModels(Rule):
114116
"""All external models must be registered in the external_models.yaml file"""
115117

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.
118+
def check_model(
119+
self, model: Model
120+
) -> t.Optional[t.Union[RuleViolation, t.List[RuleViolation]]]:
121+
depends_on = model.depends_on
122+
123+
# Ignore external models themselves, because either they are registered
124+
# if they are not, they will be caught as referenced in another model.
119125
if isinstance(model, ExternalModel):
120126
return None
121127

122-
# Handle other models that may refer to the external models.
128+
# Handle other models that are referring to them
123129
not_registered_external_models: t.Set[str] = set()
124-
for depends_on_model in model.depends_on:
130+
for depends_on_model in depends_on:
125131
existing_model = self.context.get_model(depends_on_model)
126132
if existing_model is None:
127133
not_registered_external_models.add(depends_on_model)
128134

129135
if not not_registered_external_models:
130136
return None
131137

138+
path = model._path
139+
# For SQL models, try to do better than just raise it
140+
if isinstance(model, SqlModel) and path is not None and str(path).endswith(".sql"):
141+
external_model_ranges = self.find_external_model_ranges(
142+
not_registered_external_models, model
143+
)
144+
if external_model_ranges is None:
145+
return RuleViolation(
146+
rule=self,
147+
violation_msg=f"Model '{model.fqn}' depends on unregistered external models: "
148+
f"{', '.join(m for m in not_registered_external_models)}. "
149+
"Please register them in the external_models.yaml file.",
150+
)
151+
152+
outs: t.List[RuleViolation] = []
153+
for external_model in not_registered_external_models:
154+
external_model_range = external_model_ranges.get(external_model)
155+
if external_model_range:
156+
outs.extend(
157+
RuleViolation(
158+
rule=self,
159+
violation_msg=f"Model '{model.fqn}' depends on unregistered external model: "
160+
f"{external_model}. Please register it in the external_models.yaml file.",
161+
violation_range=target,
162+
)
163+
for target in external_model_range
164+
)
165+
else:
166+
outs.append(
167+
RuleViolation(
168+
rule=self,
169+
violation_msg=f"Model '{model.fqn}' depends on unregistered external model: "
170+
f"{external_model}. Please register it in the external_models.yaml file.",
171+
)
172+
)
173+
174+
return outs
175+
132176
return RuleViolation(
133177
rule=self,
134178
violation_msg=f"Model '{model.name}' depends on unregistered external models: "
135179
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'.",
180+
"Please register them in the external_models.yaml file.",
137181
)
138182

183+
def find_external_model_ranges(
184+
self, external_models_not_registered: t.Set[str], model: SqlModel
185+
) -> t.Optional[t.Dict[str, t.List[Range]]]:
186+
"""Returns a map of external model names to their ranges found in the query.
187+
188+
It returns a dictionary of fqn to a list of ranges where the external model
189+
"""
190+
return find_external_model_ranges(self.context, external_models_not_registered, model)
191+
139192

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

sqlmesh/core/linter/rules/helpers/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)