Skip to content

Commit 0300d41

Browse files
committed
feat: linting rule for unregistered external models
[ci skip]
1 parent 5c9b360 commit 0300d41

File tree

16 files changed

+642
-415
lines changed

16 files changed

+642
-415
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+
"nounregisteredexternalmodels",
5152
],
5253
),
5354
)

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: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@
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
14+
from sqlmesh.core.linter.rules.helpers.lineage import find_external_model_ranges
1415

1516

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

1920
def check_model(self, model: Model) -> t.Optional[RuleViolation]:
2021
# Only applies to SQL models, as other model types do not have a query.
@@ -110,4 +111,82 @@ def check_model(self, model: Model) -> t.Optional[RuleViolation]:
110111
return self.violation()
111112

112113

114+
class NoUnregisteredExternalModels(Rule):
115+
"""All external models must be registered in the external_models.yaml file"""
116+
117+
def check_model(
118+
self, model: Model
119+
) -> t.Optional[t.Union[RuleViolation, t.List[RuleViolation]]]:
120+
depends_on = model.depends_on
121+
122+
# Ignore external models themselves, because either they are registered
123+
# if they are not, they will be caught as referenced in another model.
124+
if isinstance(model, ExternalModel):
125+
return None
126+
127+
# Handle other models that are referring to them
128+
not_registered_external_models: t.Set[str] = set()
129+
for depends_on_model in depends_on:
130+
existing_model = self.context.get_model(depends_on_model)
131+
if existing_model is None:
132+
not_registered_external_models.add(depends_on_model)
133+
134+
if not not_registered_external_models:
135+
return None
136+
137+
path = model._path
138+
# For SQL models, try to do better than just raise it
139+
if isinstance(model, SqlModel) and path is not None and str(path).endswith(".sql"):
140+
external_model_ranges = self.find_external_model_ranges(
141+
not_registered_external_models, model
142+
)
143+
if external_model_ranges is None:
144+
return RuleViolation(
145+
rule=self,
146+
violation_msg=f"Model '{model.fqn}' depends on unregistered external models: "
147+
f"{', '.join(m for m in not_registered_external_models)}. "
148+
"Please register them in the external_models.yaml file.",
149+
)
150+
151+
outs: t.List[RuleViolation] = []
152+
for external_model in not_registered_external_models:
153+
external_model_range = external_model_ranges.get(external_model)
154+
if external_model_range:
155+
outs.extend(
156+
RuleViolation(
157+
rule=self,
158+
violation_msg=f"Model '{model.fqn}' depends on unregistered external model: "
159+
f"{external_model}. Please register it in the external_models.yaml file.",
160+
violation_range=target,
161+
)
162+
for target in external_model_range
163+
)
164+
else:
165+
outs.append(
166+
RuleViolation(
167+
rule=self,
168+
violation_msg=f"Model '{model.fqn}' depends on unregistered external model: "
169+
f"{external_model}. Please register it in the external_models.yaml file.",
170+
)
171+
)
172+
173+
return outs
174+
175+
return RuleViolation(
176+
rule=self,
177+
violation_msg=f"Model '{model.name}' depends on unregistered external models: "
178+
f"{', '.join(m for m in not_registered_external_models)}. "
179+
"Please register them in the external_models.yaml file.",
180+
)
181+
182+
def find_external_model_ranges(
183+
self, external_models_not_registered: t.Set[str], model: SqlModel
184+
) -> t.Optional[t.Dict[str, t.List[Range]]]:
185+
"""Returns a map of external model names to their ranges found in the query.
186+
187+
It returns a dictionary of fqn to a list of ranges where the external model
188+
"""
189+
return find_external_model_ranges(self.context, external_models_not_registered, model)
190+
191+
113192
BUILTIN_RULES = RuleSet(subclasses(__name__, Rule, (Rule,)))

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

Whitespace-only changes.

0 commit comments

Comments
 (0)