Skip to content

Commit bc1653a

Browse files
committed
feat: nomissingexternalmodels returns fix
- nomissingexternalmodels lint rule now returns a fix that can be used by the IDE [ci skip]
1 parent 3c311f6 commit bc1653a

File tree

6 files changed

+187
-183
lines changed

6 files changed

+187
-183
lines changed

examples/sushi/config.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,9 @@
4141
default_gateway="duckdb",
4242
model_defaults=model_defaults,
4343
linter=LinterConfig(
44-
enabled=False,
44+
enabled=True,
4545
rules=[
46-
"ambiguousorinvalidcolumn",
47-
"invalidselectstarexpansion",
48-
"noselectstar",
49-
"nomissingaudits",
50-
"nomissingowner",
51-
"nomissingexternalmodels",
46+
"nomissingexternalmodels",
5247
],
5348
),
5449
)
Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,2 @@
1-
- name: raw.demographics
2-
description: Table containing demographics information
3-
dialect: duckdb
4-
start: 1 week ago
5-
audits:
6-
- name: not_null
7-
columns: "[customer_id]"
8-
- name: accepted_range
9-
column: zip
10-
min_v: "'00000'"
11-
max_v: "'99999'"
12-
- name: assert_raw_demographics
13-
columns:
14-
customer_id: int
15-
zip: text
1+
- name: raw.test
2+
- name: '"memory"."raw"."demographics"'

sqlmesh/core/linter/rules/builtin.py

Lines changed: 52 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,53 @@ 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 Fix(
231+
title="Add external model",
232+
edits=[
233+
TextEdit(
234+
path=external_models_path,
235+
range=Range(start=Position(0, 0), end=Position(0, 0)),
236+
new_text=f"- name: {model_name}\n",
237+
)
238+
],
239+
)
240+
241+
# Figure out the position to insert the new external model at the end of the file, whether
242+
# needs new line or not.
243+
with open(external_models_path, "r", encoding="utf-8") as file:
244+
lines = file.read()
245+
246+
# If file ends in newline, we can add the new model directly.
247+
if not lines.endswith("\n"):
248+
new_text = f"\n- name: {model_name}\n"
249+
else:
250+
new_text = f"- name: {model_name}\n"
251+
252+
split_lines = lines.splitlines()
253+
position = Position(line=len(split_lines) - 1, character=len(split_lines[-1]))
254+
255+
return Fix(
256+
title="Add external model",
257+
edits=[
258+
TextEdit(
259+
path=external_models_path,
260+
range=Range(start=position, end=position),
261+
new_text=new_text,
262+
)
263+
],
264+
)
265+
215266

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

sqlmesh/lsp/context.py

Lines changed: 2 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,16 @@
11
from dataclasses import dataclass
22
from pathlib import Path
3-
from pygls.server import LanguageServer
43
from sqlmesh.core.context import Context
54
import typing as t
5+
66
from sqlmesh.core.linter.rule import Range
7-
from sqlmesh.core.model.definition import SqlModel, ExternalModel
7+
from sqlmesh.core.model.definition import SqlModel
88
from sqlmesh.core.linter.definition import AnnotatedRuleViolation
9-
from sqlmesh.core.schema_loader import get_columns
10-
from sqlmesh.lsp.commands import EXTERNAL_MODEL_UPDATE_COLUMNS
119
from sqlmesh.lsp.custom import ModelForRendering, TestEntry, RunTestResponse
1210
from sqlmesh.lsp.custom import AllModelsResponse, RenderModelEntry
1311
from sqlmesh.lsp.tests_ranges import get_test_ranges
14-
from sqlmesh.lsp.helpers import to_lsp_range
1512
from sqlmesh.lsp.uri import URI
1613
from lsprotocol import types
17-
from sqlmesh.utils import yaml
18-
from sqlmesh.utils.lineage import get_yaml_model_name_ranges
1914

2015

2116
@dataclass
@@ -307,36 +302,6 @@ def get_code_actions(
307302

308303
return code_actions if code_actions else None
309304

310-
def get_code_lenses(self, uri: URI) -> t.Optional[t.List[types.CodeLens]]:
311-
models_in_file = self.map.get(uri.to_path())
312-
if isinstance(models_in_file, ModelTarget):
313-
models = [self.context.get_model(model) for model in models_in_file.names]
314-
if any(isinstance(model, ExternalModel) for model in models):
315-
code_lenses = self._get_external_model_code_lenses(uri)
316-
if code_lenses:
317-
return code_lenses
318-
319-
return None
320-
321-
def _get_external_model_code_lenses(self, uri: URI) -> t.List[types.CodeLens]:
322-
"""Get code lenses for external models YAML files."""
323-
ranges = get_yaml_model_name_ranges(uri.to_path())
324-
if ranges is None:
325-
return []
326-
return [
327-
types.CodeLens(
328-
range=to_lsp_range(range),
329-
command=types.Command(
330-
title="Update Columns",
331-
command=EXTERNAL_MODEL_UPDATE_COLUMNS,
332-
arguments=[
333-
name,
334-
],
335-
),
336-
)
337-
for name, range in ranges.items()
338-
]
339-
340305
def list_of_models_for_rendering(self) -> t.List[ModelForRendering]:
341306
"""Get a list of models for rendering.
342307
@@ -438,74 +403,3 @@ def diagnostic_to_lsp_diagnostic(
438403
code=diagnostic.rule.name,
439404
code_description=types.CodeDescription(href=rule_uri),
440405
)
441-
442-
def update_external_model_columns(self, ls: LanguageServer, uri: URI, model_name: str) -> bool:
443-
"""
444-
Update the columns for an external model in the YAML file. Returns True if changed, False if didn't because
445-
of the columns already being up to date.
446-
447-
In this case, the model name is the name of the external model as is defined in the YAML file, not any other version of it.
448-
449-
Errors still throw exceptions to be handled by the caller.
450-
"""
451-
models = yaml.load(uri.to_path())
452-
if not isinstance(models, list):
453-
raise ValueError(
454-
f"Expected a list of models in {uri.to_path()}, but got {type(models).__name__}"
455-
)
456-
457-
existing_model = next((model for model in models if model.get("name") == model_name), None)
458-
if existing_model is None:
459-
raise ValueError(f"Could not find model {model_name} in {uri.to_path()}")
460-
461-
existing_model_columns = existing_model.get("columns")
462-
463-
# Get the adapter and fetch columns
464-
adapter = self.context.engine_adapter
465-
# Get columns for the model
466-
new_columns = get_columns(
467-
adapter=adapter,
468-
dialect=self.context.config.model_defaults.dialect,
469-
table=model_name,
470-
strict=True,
471-
)
472-
# Compare existing columns and matching types and if they are the same, do not update
473-
if existing_model_columns is not None:
474-
if existing_model_columns == new_columns:
475-
return False
476-
477-
# Model index to update
478-
model_index = next(
479-
(i for i, model in enumerate(models) if model.get("name") == model_name), None
480-
)
481-
if model_index is None:
482-
raise ValueError(f"Could not find model {model_name} in {uri.to_path()}")
483-
484-
# Get end of the file to set the edit range
485-
with open(uri.to_path(), "r", encoding="utf-8") as file:
486-
read_file = file.read()
487-
488-
end_line = read_file.count("\n")
489-
end_character = len(read_file.splitlines()[-1]) if end_line > 0 else 0
490-
491-
models[model_index]["columns"] = new_columns
492-
edit = types.TextDocumentEdit(
493-
text_document=types.OptionalVersionedTextDocumentIdentifier(
494-
uri=uri.value,
495-
version=None,
496-
),
497-
edits=[
498-
types.TextEdit(
499-
range=types.Range(
500-
start=types.Position(line=0, character=0),
501-
end=types.Position(
502-
line=end_line,
503-
character=end_character,
504-
),
505-
),
506-
new_text=yaml.dump(models),
507-
)
508-
],
509-
)
510-
ls.apply_edit(types.WorkspaceEdit(document_changes=[edit]))
511-
return True

sqlmesh/lsp/main.py

Lines changed: 0 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
ApiResponseGetModels,
2525
)
2626

27-
from sqlmesh.lsp.commands import EXTERNAL_MODEL_UPDATE_COLUMNS
2827
from sqlmesh.lsp.completions import get_sql_completions
2928
from sqlmesh.lsp.context import (
3029
LSPContext,
@@ -369,44 +368,6 @@ def function_call(ls: LanguageServer, params: t.Any) -> t.Dict[str, t.Any]:
369368

370369
self.server.feature(name)(create_function_call(method))
371370

372-
@self.server.command(EXTERNAL_MODEL_UPDATE_COLUMNS)
373-
def command_external_models_update_columns(ls: LanguageServer, raw: t.Any) -> None:
374-
try:
375-
if not isinstance(raw, list):
376-
raise ValueError("Invalid command parameters")
377-
if len(raw) != 1:
378-
raise ValueError("Command expects exactly one parameter")
379-
model_name = raw[0]
380-
if not isinstance(model_name, str):
381-
raise ValueError("Command parameter must be a string")
382-
383-
context = self._context_get_or_load()
384-
if not isinstance(context, LSPContext):
385-
raise ValueError("Context is not loaded or invalid")
386-
model = context.context.get_model(model_name)
387-
if model is None:
388-
raise ValueError(f"External model '{model_name}' not found")
389-
if model._path is None:
390-
raise ValueError(f"External model '{model_name}' does not have a file path")
391-
uri = URI.from_path(model._path)
392-
updated = context.update_external_model_columns(
393-
ls=ls,
394-
uri=uri,
395-
model_name=model_name,
396-
)
397-
if updated:
398-
ls.show_message(
399-
f"Updated columns for '{model_name}'",
400-
types.MessageType.Info,
401-
)
402-
else:
403-
ls.show_message(
404-
f"Columns for '{model_name}' are already up to date",
405-
)
406-
except Exception as e:
407-
ls.show_message(f"Error executing command: {e}", types.MessageType.Error)
408-
return None
409-
410371
@self.server.feature(types.INITIALIZE)
411372
def initialize(ls: LanguageServer, params: types.InitializeParams) -> None:
412373
"""Initialize the server when the client connects."""
@@ -789,17 +750,6 @@ def code_action(
789750
ls.log_trace(f"Error getting code actions: {e}")
790751
return None
791752

792-
@self.server.feature(types.TEXT_DOCUMENT_CODE_LENS)
793-
def code_lens(ls: LanguageServer, params: types.CodeLensParams) -> t.List[types.CodeLens]:
794-
try:
795-
uri = URI(params.text_document.uri)
796-
context = self._context_get_or_load(uri)
797-
code_lenses = context.get_code_lenses(uri)
798-
return code_lenses if code_lenses else []
799-
except Exception as e:
800-
ls.log_trace(f"Error getting code lenses: {e}")
801-
return []
802-
803753
@self.server.feature(
804754
types.TEXT_DOCUMENT_COMPLETION,
805755
types.CompletionOptions(trigger_characters=["@"]), # advertise "@" for macros

0 commit comments

Comments
 (0)