sqlmesh 0.196.3.dev4__py3-none-any.whl → 0.196.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. sqlmesh/_version.py +2 -2
  2. sqlmesh/cli/main.py +15 -6
  3. sqlmesh/core/engine_adapter/mssql.py +85 -2
  4. sqlmesh/core/linter/definition.py +5 -2
  5. sqlmesh/core/linter/rule.py +27 -2
  6. sqlmesh/core/linter/rules/builtin.py +25 -2
  7. sqlmesh/core/model/definition.py +1 -0
  8. sqlmesh/core/state_sync/db/environment.py +18 -14
  9. sqlmesh/core/state_sync/db/facade.py +0 -4
  10. sqlmesh/lsp/context.py +158 -0
  11. sqlmesh/lsp/main.py +95 -120
  12. {sqlmesh-0.196.3.dev4.dist-info → sqlmesh-0.196.4.dist-info}/METADATA +2 -2
  13. {sqlmesh-0.196.3.dev4.dist-info → sqlmesh-0.196.4.dist-info}/RECORD +60 -60
  14. web/client/dist/assets/{Audits-DrNQaTQA.js → Audits-DKgXravk.js} +1 -1
  15. web/client/dist/assets/{Banner-DsG8aGIL.js → Banner-BlNaPns_.js} +1 -1
  16. web/client/dist/assets/{ChevronDownIcon-CpQjfO01.js → ChevronDownIcon-MZBJpOO8.js} +1 -1
  17. web/client/dist/assets/{ChevronRightIcon-B-9lnqGr.js → ChevronRightIcon-Bdxx50QS.js} +1 -1
  18. web/client/dist/assets/{Content-CSMzf1cU.js → Content-ClpOw61F.js} +1 -1
  19. web/client/dist/assets/{Content-vfltDlhj.js → Content-DRsp6uQI.js} +1 -1
  20. web/client/dist/assets/{Data-k1Dz6qdS.js → Data-91WCokZa.js} +1 -1
  21. web/client/dist/assets/{DataCatalog-DlbWZyBp.js → DataCatalog-C63qcucX.js} +2 -2
  22. web/client/dist/assets/{Editor--iBTA9xH.js → Editor-DRDwxpI2.js} +1 -1
  23. web/client/dist/assets/{Editor-C2pcRDlN.js → Editor-PtL9tfk8.js} +1 -1
  24. web/client/dist/assets/{Errors-CQW3lhsM.js → Errors-C-Webrjw.js} +1 -1
  25. web/client/dist/assets/{FileExplorer-IAIPoTxM.js → FileExplorer-ylHWQwsm.js} +1 -1
  26. web/client/dist/assets/{Footer-D77vCxKH.js → Footer-CasDkGte.js} +1 -1
  27. web/client/dist/assets/{Header-Ccb81kaP.js → Header-DyL7LEVP.js} +1 -1
  28. web/client/dist/assets/{Input-D_Fp4W_B.js → Input-DZamwNGa.js} +1 -1
  29. web/client/dist/assets/Lineage-BPGCodgf.js +1 -0
  30. web/client/dist/assets/{ListboxShow-BIPQ-cqE.js → ListboxShow-DSFZEE-M.js} +1 -1
  31. web/client/dist/assets/{ModelLineage-BrTZ2lm3.js → ModelLineage-BQmZD--B.js} +1 -1
  32. web/client/dist/assets/{Models-B6cHeIlA.js → Models-C2AYU-74.js} +1 -1
  33. web/client/dist/assets/{Page-S1ykkm5T.js → Page-BlOb3Ql3.js} +1 -1
  34. web/client/dist/assets/{Plan-DCD4m0nD.js → Plan-DWeacCNs.js} +1 -1
  35. web/client/dist/assets/{PlusCircleIcon-C40sAC-m.js → PlusCircleIcon-DD7nOhwQ.js} +1 -1
  36. web/client/dist/assets/{ReportErrors-CxqgDcYY.js → ReportErrors-CH5Vf5lv.js} +1 -1
  37. web/client/dist/assets/{Root-DtUrFfE_.js → Root-DZ2xKltj.js} +1 -1
  38. web/client/dist/assets/{SearchList-DoRflby9.js → SearchList-BpsOOoGh.js} +6 -6
  39. web/client/dist/assets/{SelectEnvironment-equ86mF1.js → SelectEnvironment-DLsh1BMi.js} +1 -1
  40. web/client/dist/assets/{SourceList-DT-sBCAQ.js → SourceList-Dg2mc7aY.js} +1 -1
  41. web/client/dist/assets/{SourceListItem-MFS9vpg9.js → SourceListItem-2mO71O7W.js} +1 -1
  42. web/client/dist/assets/{SplitPane-D2cB3Qkv.js → SplitPane-BbVgMyQB.js} +1 -1
  43. web/client/dist/assets/{Tests-Duzk1K8j.js → Tests-LDkS-T5R.js} +1 -1
  44. web/client/dist/assets/{Welcome-BgAHQe0m.js → Welcome-C0UCzzcU.js} +1 -1
  45. web/client/dist/assets/context-CGAXyfSr.js +71 -0
  46. web/client/dist/assets/{context-g_ECqgnp.js → context-D4y_v88x.js} +1 -1
  47. web/client/dist/assets/{editor-BddPKujl.js → editor-BOTuvmku.js} +1 -1
  48. web/client/dist/assets/{file-DUr_Gx7F.js → file-CXPqyzKt.js} +1 -1
  49. web/client/dist/assets/{floating-ui.react-dom-DTKAUIBI.js → floating-ui.react-dom-X8vTMPq7.js} +1 -1
  50. web/client/dist/assets/{help-LQUMs6nO.js → help-65lcCO6u.js} +1 -1
  51. web/client/dist/assets/{index-BsS6H8XQ.js → index-C7dOv_Xt.js} +8 -8
  52. web/client/dist/assets/{index-DvxjegO0.js → index-Cf0u2ogu.js} +1 -1
  53. web/client/dist/assets/{plan-DS237yuz.js → plan-D5w005YE.js} +1 -1
  54. web/client/dist/assets/{popover-CiiY17g7.js → popover-CQm70UjD.js} +1 -1
  55. web/client/dist/assets/{project-DUHOhQT2.js → project-CUisbOpA.js} +1 -1
  56. web/client/dist/index.html +1 -1
  57. web/client/dist/assets/Lineage-BHLfUAmC.js +0 -1
  58. web/client/dist/assets/context-CXV8eAaM.js +0 -68
  59. {sqlmesh-0.196.3.dev4.dist-info → sqlmesh-0.196.4.dist-info}/WHEEL +0 -0
  60. {sqlmesh-0.196.3.dev4.dist-info → sqlmesh-0.196.4.dist-info}/entry_points.txt +0 -0
  61. {sqlmesh-0.196.3.dev4.dist-info → sqlmesh-0.196.4.dist-info}/licenses/LICENSE +0 -0
  62. {sqlmesh-0.196.3.dev4.dist-info → sqlmesh-0.196.4.dist-info}/top_level.txt +0 -0
sqlmesh/_version.py CHANGED
@@ -17,5 +17,5 @@ __version__: str
17
17
  __version_tuple__: VERSION_TUPLE
18
18
  version_tuple: VERSION_TUPLE
19
19
 
20
- __version__ = version = '0.196.3.dev4'
21
- __version_tuple__ = version_tuple = (0, 196, 3, 'dev4')
20
+ __version__ = version = '0.196.4'
21
+ __version_tuple__ = version_tuple = (0, 196, 4)
sqlmesh/cli/main.py CHANGED
@@ -4,25 +4,26 @@ import logging
4
4
  import os
5
5
  import sys
6
6
  import typing as t
7
+ from pathlib import Path
7
8
 
8
9
  import click
10
+
9
11
  from sqlmesh import configure_logging, remove_excess_logs
10
12
  from sqlmesh.cli import error_handler
11
13
  from sqlmesh.cli import options as opt
12
14
  from sqlmesh.cli.project_init import (
15
+ InitCliMode,
13
16
  ProjectTemplate,
14
17
  init_example_project,
15
- InitCliMode,
16
18
  interactive_init,
17
19
  )
18
20
  from sqlmesh.core.analytics import cli_analytics
19
- from sqlmesh.core.console import configure_console, get_console
20
- from sqlmesh.utils import Verbosity
21
21
  from sqlmesh.core.config import load_configs
22
+ from sqlmesh.core.console import configure_console, get_console
22
23
  from sqlmesh.core.context import Context
24
+ from sqlmesh.utils import Verbosity
23
25
  from sqlmesh.utils.date import TimeLike
24
26
  from sqlmesh.utils.errors import MissingDependencyError, SQLMeshError
25
- from pathlib import Path
26
27
 
27
28
  logger = logging.getLogger(__name__)
28
29
 
@@ -355,6 +356,7 @@ def evaluate(
355
356
  "--append-newline",
356
357
  is_flag=True,
357
358
  help="Include a newline at the end of each file.",
359
+ default=None,
358
360
  )
359
361
  @opt.format_options
360
362
  @click.pass_context
@@ -805,7 +807,11 @@ def check_intervals(
805
807
  context = ctx.obj
806
808
  context.console.show_intervals(
807
809
  context.check_intervals(
808
- environment, no_signals=no_signals, select_models=select_model, start=start, end=end
810
+ environment,
811
+ no_signals=no_signals,
812
+ select_models=select_model,
813
+ start=start,
814
+ end=end,
809
815
  )
810
816
  )
811
817
 
@@ -1107,7 +1113,10 @@ def clean(obj: Context) -> None:
1107
1113
  @error_handler
1108
1114
  @cli_analytics
1109
1115
  def table_name(
1110
- obj: Context, model_name: str, environment: t.Optional[str] = None, prod: bool = False
1116
+ obj: Context,
1117
+ model_name: str,
1118
+ environment: t.Optional[str] = None,
1119
+ prod: bool = False,
1111
1120
  ) -> None:
1112
1121
  """Prints the name of the physical table for the given model."""
1113
1122
  print(obj.table_name(model_name, environment, prod))
@@ -6,11 +6,13 @@ import typing as t
6
6
 
7
7
  from sqlglot import exp
8
8
 
9
- from sqlmesh.core.dialect import to_schema
9
+ from sqlmesh.core.dialect import to_schema, add_table
10
10
  from sqlmesh.core.engine_adapter.base import (
11
11
  EngineAdapterWithIndexSupport,
12
12
  EngineAdapter,
13
13
  InsertOverwriteStrategy,
14
+ MERGE_SOURCE_ALIAS,
15
+ MERGE_TARGET_ALIAS,
14
16
  )
15
17
  from sqlmesh.core.engine_adapter.mixins import (
16
18
  GetCurrentCatalogFromFunctionMixin,
@@ -32,7 +34,7 @@ from sqlmesh.core.schema_diff import SchemaDiffer
32
34
 
33
35
  if t.TYPE_CHECKING:
34
36
  from sqlmesh.core._typing import SchemaName, TableName
35
- from sqlmesh.core.engine_adapter._typing import DF, Query
37
+ from sqlmesh.core.engine_adapter._typing import DF, Query, QueryOrDF
36
38
 
37
39
 
38
40
  @set_catalog()
@@ -188,6 +190,87 @@ class MSSQLEngineAdapter(
188
190
  )
189
191
  super().drop_schema(schema_name, ignore_if_not_exists=ignore_if_not_exists, cascade=False)
190
192
 
193
+ def merge(
194
+ self,
195
+ target_table: TableName,
196
+ source_table: QueryOrDF,
197
+ columns_to_types: t.Optional[t.Dict[str, exp.DataType]],
198
+ unique_key: t.Sequence[exp.Expression],
199
+ when_matched: t.Optional[exp.Whens] = None,
200
+ merge_filter: t.Optional[exp.Expression] = None,
201
+ ) -> None:
202
+ source_queries, columns_to_types = self._get_source_queries_and_columns_to_types(
203
+ source_table, columns_to_types, target_table=target_table
204
+ )
205
+ columns_to_types = columns_to_types or self.columns(target_table)
206
+ on = exp.and_(
207
+ *(
208
+ add_table(part, MERGE_TARGET_ALIAS).eq(add_table(part, MERGE_SOURCE_ALIAS))
209
+ for part in unique_key
210
+ )
211
+ )
212
+ if merge_filter:
213
+ on = exp.and_(merge_filter, on)
214
+
215
+ if not when_matched:
216
+ match_condition = None
217
+ unique_key_names = [y.name for y in unique_key]
218
+ columns_to_types_no_keys = [c for c in columns_to_types if c not in unique_key_names]
219
+
220
+ target_columns_no_keys = [
221
+ exp.column(c, MERGE_TARGET_ALIAS) for c in columns_to_types_no_keys
222
+ ]
223
+ source_columns_no_keys = [
224
+ exp.column(c, MERGE_SOURCE_ALIAS) for c in columns_to_types_no_keys
225
+ ]
226
+
227
+ match_condition = exp.Exists(
228
+ this=exp.select(*target_columns_no_keys).except_(
229
+ exp.select(*source_columns_no_keys)
230
+ )
231
+ )
232
+
233
+ match_expressions = [
234
+ exp.When(
235
+ matched=True,
236
+ source=False,
237
+ condition=match_condition,
238
+ then=exp.Update(
239
+ expressions=[
240
+ exp.column(col, MERGE_TARGET_ALIAS).eq(
241
+ exp.column(col, MERGE_SOURCE_ALIAS)
242
+ )
243
+ for col in columns_to_types_no_keys
244
+ ],
245
+ ),
246
+ )
247
+ ]
248
+ else:
249
+ match_expressions = when_matched.copy().expressions
250
+
251
+ match_expressions.append(
252
+ exp.When(
253
+ matched=False,
254
+ source=False,
255
+ then=exp.Insert(
256
+ this=exp.Tuple(expressions=[exp.column(col) for col in columns_to_types]),
257
+ expression=exp.Tuple(
258
+ expressions=[
259
+ exp.column(col, MERGE_SOURCE_ALIAS) for col in columns_to_types
260
+ ]
261
+ ),
262
+ ),
263
+ )
264
+ )
265
+ for source_query in source_queries:
266
+ with source_query as query:
267
+ self._merge(
268
+ target_table=target_table,
269
+ query=query,
270
+ on=on,
271
+ whens=exp.Whens(expressions=match_expressions),
272
+ )
273
+
191
274
  def _convert_df_datetime(self, df: DF, columns_to_types: t.Dict[str, exp.DataType]) -> None:
192
275
  import pandas as pd
193
276
  from pandas.api.types import is_datetime64_any_dtype # type: ignore
@@ -8,7 +8,7 @@ import operator as op
8
8
  from collections.abc import Iterator, Iterable, Set, Mapping, Callable
9
9
  from functools import reduce
10
10
  from sqlmesh.core.model import Model
11
- from sqlmesh.core.linter.rule import Rule, RuleViolation, Range
11
+ from sqlmesh.core.linter.rule import Rule, RuleViolation, Range, Fix
12
12
  from sqlmesh.core.console import LinterConsole, get_console
13
13
 
14
14
  if t.TYPE_CHECKING:
@@ -75,6 +75,7 @@ class Linter:
75
75
  model=model,
76
76
  violation_type="error",
77
77
  violation_range=violation.violation_range,
78
+ fixes=violation.fixes,
78
79
  )
79
80
  for violation in error_violations
80
81
  ] + [
@@ -84,6 +85,7 @@ class Linter:
84
85
  model=model,
85
86
  violation_type="warning",
86
87
  violation_range=violation.violation_range,
88
+ fixes=violation.fixes,
87
89
  )
88
90
  for violation in warn_violations
89
91
  ]
@@ -152,7 +154,8 @@ class AnnotatedRuleViolation(RuleViolation):
152
154
  model: Model,
153
155
  violation_type: t.Literal["error", "warning"],
154
156
  violation_range: t.Optional[Range] = None,
157
+ fixes: t.Optional[t.List[Fix]] = None,
155
158
  ) -> None:
156
- super().__init__(rule, violation_msg, violation_range)
159
+ super().__init__(rule, violation_msg, violation_range, fixes)
157
160
  self.model = model
158
161
  self.violation_type = violation_type
@@ -39,6 +39,22 @@ class Range:
39
39
  end: Position
40
40
 
41
41
 
42
+ @dataclass(frozen=True)
43
+ class TextEdit:
44
+ """A text edit to apply to a file."""
45
+
46
+ range: Range
47
+ new_text: str
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Fix:
52
+ """A fix that can be applied to resolve a rule violation."""
53
+
54
+ title: str
55
+ edits: t.List[TextEdit]
56
+
57
+
42
58
  class _Rule(abc.ABCMeta):
43
59
  def __new__(cls: Type[_Rule], clsname: str, bases: t.Tuple, attrs: t.Dict) -> _Rule:
44
60
  attrs["name"] = clsname.lower()
@@ -66,10 +82,14 @@ class Rule(abc.ABC, metaclass=_Rule):
66
82
  self,
67
83
  violation_msg: t.Optional[str] = None,
68
84
  violation_range: t.Optional[Range] = None,
85
+ fixes: t.Optional[t.List[Fix]] = None,
69
86
  ) -> RuleViolation:
70
87
  """Create a RuleViolation instance for this rule"""
71
88
  return RuleViolation(
72
- rule=self, violation_msg=violation_msg or self.summary, violation_range=violation_range
89
+ rule=self,
90
+ violation_msg=violation_msg or self.summary,
91
+ violation_range=violation_range,
92
+ fixes=fixes,
73
93
  )
74
94
 
75
95
  def get_definition_location(self) -> RuleLocation:
@@ -103,11 +123,16 @@ class Rule(abc.ABC, metaclass=_Rule):
103
123
 
104
124
  class RuleViolation:
105
125
  def __init__(
106
- self, rule: Rule, violation_msg: str, violation_range: t.Optional[Range] = None
126
+ self,
127
+ rule: Rule,
128
+ violation_msg: str,
129
+ violation_range: t.Optional[Range] = None,
130
+ fixes: t.Optional[t.List[Fix]] = None,
107
131
  ) -> None:
108
132
  self.rule = rule
109
133
  self.violation_msg = violation_msg
110
134
  self.violation_range = violation_range
135
+ self.fixes = fixes or []
111
136
 
112
137
  def __repr__(self) -> str:
113
138
  return f"{self.rule.name}: {self.violation_msg}"
@@ -8,7 +8,7 @@ from sqlglot.expressions import Star
8
8
  from sqlglot.helper import subclasses
9
9
 
10
10
  from sqlmesh.core.linter.helpers import TokenPositionDetails
11
- from sqlmesh.core.linter.rule import Rule, RuleViolation, Range
11
+ from sqlmesh.core.linter.rule import Rule, RuleViolation, Range, Fix, TextEdit
12
12
  from sqlmesh.core.linter.definition import RuleSet
13
13
  from sqlmesh.core.model import Model, SqlModel
14
14
 
@@ -22,7 +22,8 @@ class NoSelectStar(Rule):
22
22
  return None
23
23
  if model.query.is_star:
24
24
  violation_range = self._get_range(model)
25
- return self.violation(violation_range=violation_range)
25
+ fixes = self._create_fixes(model, violation_range)
26
+ return self.violation(violation_range=violation_range, fixes=fixes)
26
27
  return None
27
28
 
28
29
  def _get_range(self, model: SqlModel) -> t.Optional[Range]:
@@ -37,6 +38,28 @@ class NoSelectStar(Rule):
37
38
 
38
39
  return None
39
40
 
41
+ def _create_fixes(
42
+ self, model: SqlModel, violation_range: t.Optional[Range]
43
+ ) -> t.Optional[t.List[Fix]]:
44
+ """Create fixes for the SELECT * violation."""
45
+ if not violation_range:
46
+ return None
47
+ columns = model.columns_to_types
48
+ if not columns:
49
+ return None
50
+ new_text = ", ".join(columns.keys())
51
+ return [
52
+ Fix(
53
+ title="Replace SELECT * with explicit column list",
54
+ edits=[
55
+ TextEdit(
56
+ range=violation_range,
57
+ new_text=new_text,
58
+ )
59
+ ],
60
+ )
61
+ ]
62
+
40
63
 
41
64
  class InvalidSelectStarExpansion(Rule):
42
65
  def check_model(self, model: Model) -> t.Optional[RuleViolation]:
@@ -2886,6 +2886,7 @@ META_FIELD_CONVERTER: t.Dict[str, t.Callable] = {
2886
2886
  for name, args in values
2887
2887
  )
2888
2888
  ),
2889
+ "formatting": str,
2889
2890
  }
2890
2891
 
2891
2892
 
@@ -172,13 +172,9 @@ class EnvironmentState:
172
172
  Returns:
173
173
  The list of environment summaries to remove.
174
174
  """
175
-
176
- environment_summaries = self.get_environments_summary()
177
- return [
178
- env_summary
179
- for env_summary in environment_summaries
180
- if env_summary.expiration_ts is not None and env_summary.expiration_ts <= current_ts
181
- ]
175
+ return self._fetch_environment_summaries(
176
+ where=self._create_expiration_filter_expr(current_ts)
177
+ )
182
178
 
183
179
  def delete_expired_environments(
184
180
  self, current_ts: t.Optional[int] = None
@@ -225,13 +221,7 @@ class EnvironmentState:
225
221
  Returns:
226
222
  A list of all environment summaries.
227
223
  """
228
- return [
229
- self._environment_summmary_from_row(row)
230
- for row in fetchall(
231
- self.engine_adapter,
232
- self._environments_query(required_fields=list(EnvironmentSummary.all_fields())),
233
- )
234
- ]
224
+ return self._fetch_environment_summaries()
235
225
 
236
226
  def get_environment(
237
227
  self, environment: str, lock_for_update: bool = False
@@ -327,6 +317,20 @@ class EnvironmentState:
327
317
  expression=exp.Literal.number(current_ts),
328
318
  )
329
319
 
320
+ def _fetch_environment_summaries(
321
+ self, where: t.Optional[str | exp.Expression] = None
322
+ ) -> t.List[EnvironmentSummary]:
323
+ return [
324
+ self._environment_summmary_from_row(row)
325
+ for row in fetchall(
326
+ self.engine_adapter,
327
+ self._environments_query(
328
+ where=where,
329
+ required_fields=list(EnvironmentSummary.all_fields()),
330
+ ),
331
+ )
332
+ ]
333
+
330
334
 
331
335
  def _environment_to_df(environment: Environment) -> pd.DataFrame:
332
336
  import pandas as pd
@@ -69,10 +69,6 @@ logger = logging.getLogger(__name__)
69
69
  T = t.TypeVar("T")
70
70
 
71
71
 
72
- if t.TYPE_CHECKING:
73
- pass
74
-
75
-
76
72
  class EngineAdapterStateSync(StateSync):
77
73
  """Manages state of nodes and snapshot with an existing engine adapter.
78
74
 
sqlmesh/lsp/context.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from dataclasses import dataclass
2
2
  from pathlib import Path
3
+ import uuid
3
4
  from sqlmesh.core.context import Context
4
5
  import typing as t
5
6
 
@@ -8,6 +9,7 @@ from sqlmesh.core.linter.definition import AnnotatedRuleViolation
8
9
  from sqlmesh.lsp.custom import ModelForRendering
9
10
  from sqlmesh.lsp.custom import AllModelsResponse, RenderModelEntry
10
11
  from sqlmesh.lsp.uri import URI
12
+ from lsprotocol import types
11
13
 
12
14
 
13
15
  @dataclass
@@ -33,8 +35,14 @@ class LSPContext:
33
35
  map: t.Dict[Path, t.Union[ModelTarget, AuditTarget]]
34
36
  _render_cache: t.Dict[Path, t.List[RenderModelEntry]]
35
37
  _lint_cache: t.Dict[Path, t.List[AnnotatedRuleViolation]]
38
+ _version_id: str
39
+ """
40
+ This is a version ID for the context. It is used to track changes to the context. It can be used to
41
+ return a version number to the LSP client.
42
+ """
36
43
 
37
44
  def __init__(self, context: Context) -> None:
45
+ self._version_id = str(uuid.uuid4())
38
46
  self.context = context
39
47
  self._render_cache = {}
40
48
  self._lint_cache = {}
@@ -62,6 +70,11 @@ class LSPContext:
62
70
  **audit_map,
63
71
  }
64
72
 
73
+ @property
74
+ def version_id(self) -> str:
75
+ """Get the version ID for the context."""
76
+ return self._version_id
77
+
65
78
  def render_model(self, uri: URI) -> t.List[RenderModelEntry]:
66
79
  """Get rendered models for a file, using cache when available.
67
80
 
@@ -150,6 +163,86 @@ class LSPContext:
150
163
  self._lint_cache[path] = diagnostics
151
164
  return diagnostics
152
165
 
166
+ def get_code_actions(
167
+ self, uri: URI, params: types.CodeActionParams
168
+ ) -> t.Optional[t.List[t.Union[types.Command, types.CodeAction]]]:
169
+ """Get code actions for a file."""
170
+
171
+ # Get the violations (which contain the fixes)
172
+ violations = self.lint_model(uri)
173
+
174
+ # Convert violations to a map for quick lookup
175
+ # Use a hashable representation of Range as the key
176
+ violation_map: t.Dict[
177
+ t.Tuple[str, t.Tuple[int, int, int, int]], AnnotatedRuleViolation
178
+ ] = {}
179
+ for violation in violations:
180
+ if violation.violation_range:
181
+ lsp_diagnostic = self.diagnostic_to_lsp_diagnostic(violation)
182
+ if lsp_diagnostic:
183
+ # Create a hashable key from the diagnostic message and range
184
+ key = (
185
+ lsp_diagnostic.message,
186
+ (
187
+ lsp_diagnostic.range.start.line,
188
+ lsp_diagnostic.range.start.character,
189
+ lsp_diagnostic.range.end.line,
190
+ lsp_diagnostic.range.end.character,
191
+ ),
192
+ )
193
+ violation_map[key] = violation
194
+
195
+ # Get diagnostics in the requested range
196
+ diagnostics = params.context.diagnostics if params.context else []
197
+
198
+ code_actions: t.List[t.Union[types.Command, types.CodeAction]] = []
199
+
200
+ for diagnostic in diagnostics:
201
+ # Find the corresponding violation
202
+ key = (
203
+ diagnostic.message,
204
+ (
205
+ diagnostic.range.start.line,
206
+ diagnostic.range.start.character,
207
+ diagnostic.range.end.line,
208
+ diagnostic.range.end.character,
209
+ ),
210
+ )
211
+ found_violation = violation_map.get(key)
212
+
213
+ if found_violation is not None and found_violation.fixes:
214
+ # Create code actions for each fix
215
+ for fix in found_violation.fixes:
216
+ # Convert our Fix to LSP TextEdits
217
+ text_edits = []
218
+ for edit in fix.edits:
219
+ text_edits.append(
220
+ types.TextEdit(
221
+ range=types.Range(
222
+ start=types.Position(
223
+ line=edit.range.start.line,
224
+ character=edit.range.start.character,
225
+ ),
226
+ end=types.Position(
227
+ line=edit.range.end.line,
228
+ character=edit.range.end.character,
229
+ ),
230
+ ),
231
+ new_text=edit.new_text,
232
+ )
233
+ )
234
+
235
+ # Create the code action
236
+ code_action = types.CodeAction(
237
+ title=fix.title,
238
+ kind=types.CodeActionKind.QuickFix,
239
+ diagnostics=[diagnostic],
240
+ edit=types.WorkspaceEdit(changes={params.text_document.uri: text_edits}),
241
+ )
242
+ code_actions.append(code_action)
243
+
244
+ return code_actions if code_actions else None
245
+
153
246
  def list_of_models_for_rendering(self) -> t.List[ModelForRendering]:
154
247
  """Get a list of models for rendering.
155
248
 
@@ -186,3 +279,68 @@ class LSPContext:
186
279
  from sqlmesh.lsp.completions import get_sql_completions
187
280
 
188
281
  return get_sql_completions(self, uri, file_content)
282
+
283
+ @staticmethod
284
+ def diagnostics_to_lsp_diagnostics(
285
+ diagnostics: t.List[AnnotatedRuleViolation],
286
+ ) -> t.List[types.Diagnostic]:
287
+ """
288
+ Converts a list of AnnotatedRuleViolations to a list of LSP diagnostics. It will remove duplicates based on the message and range.
289
+ """
290
+ lsp_diagnostics = {}
291
+ for diagnostic in diagnostics:
292
+ lsp_diagnostic = LSPContext.diagnostic_to_lsp_diagnostic(diagnostic)
293
+ if lsp_diagnostic is not None:
294
+ # Create a unique key combining message and range
295
+ diagnostic_key = (
296
+ lsp_diagnostic.message,
297
+ lsp_diagnostic.range.start.line,
298
+ lsp_diagnostic.range.start.character,
299
+ lsp_diagnostic.range.end.line,
300
+ lsp_diagnostic.range.end.character,
301
+ )
302
+ if diagnostic_key not in lsp_diagnostics:
303
+ lsp_diagnostics[diagnostic_key] = lsp_diagnostic
304
+ return list(lsp_diagnostics.values())
305
+
306
+ @staticmethod
307
+ def diagnostic_to_lsp_diagnostic(
308
+ diagnostic: AnnotatedRuleViolation,
309
+ ) -> t.Optional[types.Diagnostic]:
310
+ if diagnostic.model._path is None:
311
+ return None
312
+ if not diagnostic.violation_range:
313
+ with open(diagnostic.model._path, "r", encoding="utf-8") as file:
314
+ lines = file.readlines()
315
+ diagnostic_range = types.Range(
316
+ start=types.Position(line=0, character=0),
317
+ end=types.Position(line=len(lines) - 1, character=len(lines[-1])),
318
+ )
319
+ else:
320
+ diagnostic_range = types.Range(
321
+ start=types.Position(
322
+ line=diagnostic.violation_range.start.line,
323
+ character=diagnostic.violation_range.start.character,
324
+ ),
325
+ end=types.Position(
326
+ line=diagnostic.violation_range.end.line,
327
+ character=diagnostic.violation_range.end.character,
328
+ ),
329
+ )
330
+
331
+ # Get rule definition location for diagnostics link
332
+ rule_location = diagnostic.rule.get_definition_location()
333
+ rule_uri_wihout_extension = URI.from_path(rule_location.file_path)
334
+ rule_uri = f"{rule_uri_wihout_extension.value}#L{rule_location.start_line}"
335
+
336
+ # Use URI format to create a link for "related information"
337
+ return types.Diagnostic(
338
+ range=diagnostic_range,
339
+ message=diagnostic.violation_msg,
340
+ severity=types.DiagnosticSeverity.Error
341
+ if diagnostic.violation_type == "error"
342
+ else types.DiagnosticSeverity.Warning,
343
+ source="sqlmesh",
344
+ code=diagnostic.rule.name,
345
+ code_description=types.CodeDescription(href=rule_uri),
346
+ )