Skip to content

Commit c8acb31

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 c8acb31

File tree

4 files changed

+164
-161
lines changed

4 files changed

+164
-161
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,)))

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)