Skip to content

Commit 908581b

Browse files
committed
feat: location in nomissingexternal models linting error
1 parent a94c4f0 commit 908581b

File tree

15 files changed

+630
-446
lines changed

15 files changed

+630
-446
lines changed

sqlmesh/core/linter/helpers.py

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -84,19 +84,8 @@ def to_range(self, read_file: t.Optional[t.List[str]]) -> Range:
8484
)
8585

8686

87-
def read_range_from_file(file: Path, text_range: Range) -> str:
88-
"""
89-
Read the file and return the content within the specified range.
90-
91-
Args:
92-
file: Path to the file to read
93-
text_range: The range of text to extract
94-
95-
Returns:
96-
The content within the specified range
97-
"""
98-
with file.open("r", encoding="utf-8") as f:
99-
lines = f.readlines()
87+
def read_range_from_string(content: str, text_range: Range) -> str:
88+
lines = content.splitlines(keepends=False)
10089

10190
# Ensure the range is within bounds
10291
start_line = max(0, text_range.start.line)
@@ -116,6 +105,23 @@ def read_range_from_file(file: Path, text_range: Range) -> str:
116105
return "".join(result)
117106

118107

108+
def read_range_from_file(file: Path, text_range: Range) -> str:
109+
"""
110+
Read the file and return the content within the specified range.
111+
112+
Args:
113+
file: Path to the file to read
114+
text_range: The range of text to extract
115+
116+
Returns:
117+
The content within the specified range
118+
"""
119+
with file.open("r", encoding="utf-8") as f:
120+
lines = f.readlines()
121+
122+
return read_range_from_string("".join(lines), text_range)
123+
124+
119125
def get_range_of_model_block(
120126
sql: str,
121127
dialect: str,

sqlmesh/core/linter/rules/builtin.py

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,16 @@
77
from sqlglot.expressions import Star
88
from sqlglot.helper import subclasses
99

10-
from sqlmesh.core.linter.helpers import TokenPositionDetails, get_range_of_model_block
10+
from sqlmesh.core.dialect import normalize_model_name
11+
from sqlmesh.core.linter.helpers import (
12+
TokenPositionDetails,
13+
get_range_of_model_block,
14+
read_range_from_string,
15+
)
1116
from sqlmesh.core.linter.rule import Rule, RuleViolation, Range, Fix, TextEdit
1217
from sqlmesh.core.linter.definition import RuleSet
1318
from sqlmesh.core.model import Model, SqlModel, ExternalModel
19+
from sqlmesh.utils.lineage import extract_references_from_query, ExternalModelReference
1420

1521

1622
class NoSelectStar(Rule):
@@ -113,7 +119,9 @@ def check_model(self, model: Model) -> t.Optional[RuleViolation]:
113119
class NoMissingExternalModels(Rule):
114120
"""All external models must be registered in the external_models.yaml file"""
115121

116-
def check_model(self, model: Model) -> t.Optional[RuleViolation]:
122+
def check_model(
123+
self, model: Model
124+
) -> t.Optional[t.Union[RuleViolation, t.List[RuleViolation]]]:
117125
# Ignore external models themselves, because either they are registered,
118126
# and if they are not, they will be caught as referenced in another model.
119127
if isinstance(model, ExternalModel):
@@ -129,10 +137,74 @@ def check_model(self, model: Model) -> t.Optional[RuleViolation]:
129137
if not not_registered_external_models:
130138
return None
131139

140+
# If the model is anything other than a sql model that and has a path
141+
# that ends with .sql, we cannot extract the references from the query.
142+
path = model._path
143+
if not isinstance(model, SqlModel) or not path or not str(path).endswith(".sql"):
144+
return self._standard_error_message(
145+
model_name=model.fqn,
146+
external_models=not_registered_external_models,
147+
)
148+
149+
with open(path, "r", encoding="utf-8") as file:
150+
read_file = file.read()
151+
split_read_file = read_file.splitlines()
152+
153+
# If there are any unregistered external models, return a violation find
154+
# the ranges for them.
155+
references = extract_references_from_query(
156+
query=model.query,
157+
context=self.context,
158+
document_path=path,
159+
read_file=split_read_file,
160+
depends_on=model.depends_on,
161+
dialect=model.dialect,
162+
)
163+
external_references = {
164+
normalize_model_name(
165+
table=read_range_from_string(read_file, ref.range),
166+
default_catalog=model.default_catalog,
167+
dialect=model.dialect,
168+
): ref
169+
for ref in references
170+
if isinstance(ref, ExternalModelReference) and ref.path is None
171+
}
172+
173+
# Ensure that depends_on and external references match.
174+
if not_registered_external_models != set(external_references.keys()):
175+
return self._standard_error_message(
176+
model_name=model.fqn,
177+
external_models=not_registered_external_models,
178+
)
179+
180+
# Return a violation for each unregistered external model with its range.
181+
violations = []
182+
for ref_name, ref in external_references.items():
183+
if ref_name in not_registered_external_models:
184+
violations.append(
185+
RuleViolation(
186+
rule=self,
187+
violation_msg=f"Model '{model.fqn}' depends on unregistered external model '{ref_name}'. "
188+
"Please register it in the external models file. This can be done by running 'sqlmesh create_external_models'.",
189+
violation_range=ref.range,
190+
)
191+
)
192+
193+
if len(violations) < len(not_registered_external_models):
194+
return self._standard_error_message(
195+
model_name=model.fqn,
196+
external_models=not_registered_external_models,
197+
)
198+
199+
return violations
200+
201+
def _standard_error_message(
202+
self, model_name: str, external_models: t.Set[str]
203+
) -> RuleViolation:
132204
return RuleViolation(
133205
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)}. "
206+
violation_msg=f"Model '{model_name}' depends on unregistered external models: "
207+
f"{', '.join(m for m in external_models)}. "
136208
"Please register them in the external models file. This can be done by running 'sqlmesh create_external_models'.",
137209
)
138210

sqlmesh/lsp/completions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
from sqlmesh import macro
99
import typing as t
1010
from sqlmesh.lsp.context import AuditTarget, LSPContext, ModelTarget
11-
from sqlmesh.lsp.description import generate_markdown_description
1211
from sqlmesh.lsp.uri import URI
12+
from sqlmesh.utils.lineage import generate_markdown_description
1313

1414

1515
def get_sql_completions(

sqlmesh/lsp/description.py

Lines changed: 0 additions & 29 deletions
This file was deleted.

sqlmesh/lsp/main.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@
2323
ApiResponseGetLineage,
2424
ApiResponseGetModels,
2525
)
26+
27+
# Define the command constant
28+
EXTERNAL_MODEL_UPDATE_COLUMNS = "sqlmesh.external_model_update_columns"
29+
2630
from sqlmesh.lsp.completions import get_sql_completions
2731
from sqlmesh.lsp.context import (
2832
LSPContext,
@@ -60,15 +64,15 @@
6064
from sqlmesh.lsp.helpers import to_lsp_range, to_sqlmesh_position
6165
from sqlmesh.lsp.hints import get_hints
6266
from sqlmesh.lsp.reference import (
63-
LSPCteReference,
64-
LSPModelReference,
65-
LSPExternalModelReference,
67+
CTEReference,
68+
ModelReference,
6669
get_references,
6770
get_all_references,
6871
)
6972
from sqlmesh.lsp.rename import prepare_rename, rename_symbol, get_document_highlights
7073
from sqlmesh.lsp.uri import URI
7174
from sqlmesh.utils.errors import ConfigError
75+
from sqlmesh.utils.lineage import ExternalModelReference
7276
from web.server.api.endpoints.lineage import column_lineage, model_lineage
7377
from web.server.api.endpoints.models import get_models
7478
from typing import Union
@@ -479,7 +483,7 @@ def hover(ls: LanguageServer, params: types.HoverParams) -> t.Optional[types.Hov
479483
if not references:
480484
return None
481485
reference = references[0]
482-
if isinstance(reference, LSPCteReference) or not reference.markdown_description:
486+
if isinstance(reference, CTEReference) or not reference.markdown_description:
483487
return None
484488
return types.Hover(
485489
contents=types.MarkupContent(
@@ -525,7 +529,7 @@ def goto_definition(
525529
location_links = []
526530
for reference in references:
527531
# Use target_range if available (CTEs, Macros, and external models in YAML)
528-
if isinstance(reference, LSPModelReference):
532+
if isinstance(reference, ModelReference):
529533
# Regular SQL models - default to start of file
530534
target_range = types.Range(
531535
start=types.Position(line=0, character=0),
@@ -535,7 +539,7 @@ def goto_definition(
535539
start=types.Position(line=0, character=0),
536540
end=types.Position(line=0, character=0),
537541
)
538-
elif isinstance(reference, LSPExternalModelReference):
542+
elif isinstance(reference, ExternalModelReference):
539543
# External models may have target_range set for YAML files
540544
target_range = types.Range(
541545
start=types.Position(line=0, character=0),

0 commit comments

Comments
 (0)