Skip to content

Commit 275da98

Browse files
authored
Merge branch 'main' into embedded-model-no-audit
2 parents ed28cab + f9fb3cd commit 275da98

File tree

17 files changed

+534
-24
lines changed

17 files changed

+534
-24
lines changed

.github/workflows/pr.yaml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
runs-on: ubuntu-latest
1818
steps:
1919
- uses: actions/checkout@v5
20-
- uses: actions/setup-node@v4
20+
- uses: actions/setup-node@v6
2121
with:
2222
node-version: '22'
2323
- uses: pnpm/action-setup@v4
@@ -32,7 +32,7 @@ jobs:
3232
labels: [ubuntu-2204-8]
3333
steps:
3434
- uses: actions/checkout@v5
35-
- uses: actions/setup-node@v4
35+
- uses: actions/setup-node@v6
3636
with:
3737
node-version: '22'
3838
- uses: pnpm/action-setup@v4
@@ -41,11 +41,11 @@ jobs:
4141
- name: Install dependencies
4242
run: pnpm install
4343
- name: Set up Python
44-
uses: actions/setup-python@v5
44+
uses: actions/setup-python@v6
4545
with:
4646
python-version: '3.12'
4747
- name: Install uv
48-
uses: astral-sh/setup-uv@v6
48+
uses: astral-sh/setup-uv@v7
4949
- name: Install python dependencies
5050
run: |
5151
python -m venv .venv
@@ -77,11 +77,11 @@ jobs:
7777
steps:
7878
- uses: actions/checkout@v5
7979
- name: Set up Python
80-
uses: actions/setup-python@v5
80+
uses: actions/setup-python@v6
8181
with:
8282
python-version: '3.10'
8383
- name: Install uv
84-
uses: astral-sh/setup-uv@v6
84+
uses: astral-sh/setup-uv@v7
8585
- name: Install SQLMesh dev dependencies
8686
run: |
8787
uv venv .venv

.github/workflows/private-repo-test.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,13 @@ jobs:
2222
fetch-depth: 0
2323
ref: ${{ github.event.pull_request.head.sha || github.ref }}
2424
- name: Set up Python
25-
uses: actions/setup-python@v5
25+
uses: actions/setup-python@v6
2626
with:
2727
python-version: '3.12'
2828
- name: Install uv
29-
uses: astral-sh/setup-uv@v6
29+
uses: astral-sh/setup-uv@v7
3030
- name: Set up Node.js for UI build
31-
uses: actions/setup-node@v4
31+
uses: actions/setup-node@v6
3232
with:
3333
node-version: '20'
3434
- name: Install pnpm

.github/workflows/release_extension.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ jobs:
2828
fi
2929
echo "Version format is valid: $version"
3030
- name: Setup Node.js
31-
uses: actions/setup-node@v4
31+
uses: actions/setup-node@v6
3232
with:
3333
node-version: '20'
3434
- name: Install pnpm

.github/workflows/release_shared_js.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ jobs:
3131
fi
3232
echo "Version format is valid: $version"
3333
- name: Setup Node.js
34-
uses: actions/setup-node@v4
34+
uses: actions/setup-node@v6
3535
with:
3636
node-version: '20'
3737
registry-url: 'https://registry.npmjs.org'

docs/reference/model_configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ Configuration options for [`SCD_TYPE_2` models](../concepts/models/model_kinds.m
282282
| `unique_key` | The model column(s) containing each row's unique key | array[str] | Y |
283283
| `valid_from_name` | The model column containing each row's valid from date. (Default: `valid_from`) | str | N |
284284
| `valid_to_name` | The model column containing each row's valid to date. (Default: `valid_to`) | str | N |
285-
| `invalidate_hard_deletes` | If set to true, when a record is missing from the source table it will be marked as invalid - see [here](../concepts/models/model_kinds.md#deletes) for more information. (Default: `True`) | bool | N |
285+
| `invalidate_hard_deletes` | If set to true, when a record is missing from the source table it will be marked as invalid - see [here](../concepts/models/model_kinds.md#deletes) for more information. (Default: `False`) | bool | N |
286286

287287
##### SCD Type 2 By Time
288288

sqlmesh/core/context.py

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@
115115
ModelTestMetadata,
116116
generate_test,
117117
run_tests,
118+
filter_tests_by_patterns,
118119
)
119120
from sqlmesh.core.user import User
120121
from sqlmesh.utils import UniqueKeyDict, Verbosity
@@ -146,8 +147,8 @@
146147
from typing_extensions import Literal
147148

148149
from sqlmesh.core.engine_adapter._typing import (
149-
BigframeSession,
150150
DF,
151+
BigframeSession,
151152
PySparkDataFrame,
152153
PySparkSession,
153154
SnowparkSession,
@@ -398,6 +399,10 @@ def __init__(
398399
self._standalone_audits: UniqueKeyDict[str, StandaloneAudit] = UniqueKeyDict(
399400
"standaloneaudits"
400401
)
402+
self._model_test_metadata: t.List[ModelTestMetadata] = []
403+
self._model_test_metadata_path_index: t.Dict[Path, t.List[ModelTestMetadata]] = {}
404+
self._model_test_metadata_fully_qualified_name_index: t.Dict[str, ModelTestMetadata] = {}
405+
self._models_with_tests: t.Set[str] = set()
401406
self._macros: UniqueKeyDict[str, ExecutableOrMacro] = UniqueKeyDict("macros")
402407
self._metrics: UniqueKeyDict[str, Metric] = UniqueKeyDict("metrics")
403408
self._jinja_macros = JinjaMacroRegistry()
@@ -636,6 +641,10 @@ def load(self, update_schemas: bool = True) -> GenericContext[C]:
636641
self._excluded_requirements.clear()
637642
self._linters.clear()
638643
self._environment_statements = []
644+
self._model_test_metadata.clear()
645+
self._model_test_metadata_path_index.clear()
646+
self._model_test_metadata_fully_qualified_name_index.clear()
647+
self._models_with_tests.clear()
639648

640649
for loader, project in zip(self._loaders, loaded_projects):
641650
self._jinja_macros = self._jinja_macros.merge(project.jinja_macros)
@@ -647,6 +656,15 @@ def load(self, update_schemas: bool = True) -> GenericContext[C]:
647656
self._requirements.update(project.requirements)
648657
self._excluded_requirements.update(project.excluded_requirements)
649658
self._environment_statements.extend(project.environment_statements)
659+
self._model_test_metadata.extend(project.model_test_metadata)
660+
for metadata in project.model_test_metadata:
661+
if metadata.path not in self._model_test_metadata_path_index:
662+
self._model_test_metadata_path_index[metadata.path] = []
663+
self._model_test_metadata_path_index[metadata.path].append(metadata)
664+
self._model_test_metadata_fully_qualified_name_index[
665+
metadata.fully_qualified_test_name
666+
] = metadata
667+
self._models_with_tests.add(metadata.model_name)
650668

651669
config = loader.config
652670
self._linters[config.project] = Linter.from_rules(
@@ -1049,6 +1067,11 @@ def standalone_audits(self) -> MappingProxyType[str, StandaloneAudit]:
10491067
"""Returns all registered standalone audits in this context."""
10501068
return MappingProxyType(self._standalone_audits)
10511069

1070+
@property
1071+
def models_with_tests(self) -> t.Set[str]:
1072+
"""Returns all models with tests in this context."""
1073+
return self._models_with_tests
1074+
10521075
@property
10531076
def snapshots(self) -> t.Dict[str, Snapshot]:
10541077
"""Generates and returns snapshots based on models registered in this context.
@@ -2220,7 +2243,9 @@ def test(
22202243

22212244
pd.set_option("display.max_columns", None)
22222245

2223-
test_meta = self.load_model_tests(tests=tests, patterns=match_patterns)
2246+
test_meta = self._select_tests(
2247+
test_meta=self._model_test_metadata, tests=tests, patterns=match_patterns
2248+
)
22242249

22252250
result = run_tests(
22262251
model_test_metadata=test_meta,
@@ -2782,6 +2807,33 @@ def _get_engine_adapter(self, gateway: t.Optional[str] = None) -> EngineAdapter:
27822807
raise SQLMeshError(f"Gateway '{gateway}' not found in the available engine adapters.")
27832808
return self.engine_adapter
27842809

2810+
def _select_tests(
2811+
self,
2812+
test_meta: t.List[ModelTestMetadata],
2813+
tests: t.Optional[t.List[str]] = None,
2814+
patterns: t.Optional[t.List[str]] = None,
2815+
) -> t.List[ModelTestMetadata]:
2816+
"""Filter pre-loaded test metadata based on tests and patterns."""
2817+
2818+
if tests:
2819+
filtered_tests = []
2820+
for test in tests:
2821+
if "::" in test:
2822+
if test in self._model_test_metadata_fully_qualified_name_index:
2823+
filtered_tests.append(
2824+
self._model_test_metadata_fully_qualified_name_index[test]
2825+
)
2826+
else:
2827+
test_path = Path(test)
2828+
if test_path in self._model_test_metadata_path_index:
2829+
filtered_tests.extend(self._model_test_metadata_path_index[test_path])
2830+
test_meta = filtered_tests
2831+
2832+
if patterns:
2833+
test_meta = filter_tests_by_patterns(test_meta, patterns)
2834+
2835+
return test_meta
2836+
27852837
def _snapshots(
27862838
self, models_override: t.Optional[UniqueKeyDict[str, Model]] = None
27872839
) -> t.Dict[str, Snapshot]:

sqlmesh/core/engine_adapter/databricks.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,3 +394,17 @@ def _build_table_properties_exp(
394394
expressions.append(clustered_by_exp)
395395
properties = exp.Properties(expressions=expressions)
396396
return properties
397+
398+
def _build_column_defs(
399+
self,
400+
target_columns_to_types: t.Dict[str, exp.DataType],
401+
column_descriptions: t.Optional[t.Dict[str, str]] = None,
402+
is_view: bool = False,
403+
) -> t.List[exp.ColumnDef]:
404+
# Databricks requires column types to be specified when adding column comments
405+
# in CREATE MATERIALIZED VIEW statements. Override is_view to False to force
406+
# column types to be included when comments are present.
407+
if is_view and column_descriptions:
408+
is_view = False
409+
410+
return super()._build_column_defs(target_columns_to_types, column_descriptions, is_view)

sqlmesh/core/engine_adapter/fabric.py

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,20 @@
77
from functools import cached_property
88
from sqlglot import exp
99
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_result
10-
from sqlmesh.core.engine_adapter.mixins import LogicalMergeMixin
1110
from sqlmesh.core.engine_adapter.mssql import MSSQLEngineAdapter
1211
from sqlmesh.core.engine_adapter.shared import (
1312
InsertOverwriteStrategy,
1413
)
1514
from sqlmesh.utils.errors import SQLMeshError
1615
from sqlmesh.utils.connection_pool import ConnectionPool
16+
from sqlmesh.core.schema_diff import TableAlterOperation
17+
from sqlmesh.utils import random_id
1718

1819

1920
logger = logging.getLogger(__name__)
2021

2122

22-
class FabricEngineAdapter(LogicalMergeMixin, MSSQLEngineAdapter):
23+
class FabricEngineAdapter(MSSQLEngineAdapter):
2324
"""
2425
Adapter for Microsoft Fabric.
2526
"""
@@ -154,6 +155,113 @@ def set_current_catalog(self, catalog_name: str) -> None:
154155
f"Unable to switch catalog to {catalog_name}, catalog ended up as {catalog_after_switch}"
155156
)
156157

158+
def alter_table(
159+
self, alter_expressions: t.Union[t.List[exp.Alter], t.List[TableAlterOperation]]
160+
) -> None:
161+
"""
162+
Applies alter expressions to a table. Fabric has limited support for ALTER TABLE,
163+
so this method implements a workaround for column type changes.
164+
This method is self-contained and sets its own catalog context.
165+
"""
166+
if not alter_expressions:
167+
return
168+
169+
# Get the target table from the first expression to determine the correct catalog.
170+
first_op = alter_expressions[0]
171+
expression = first_op.expression if isinstance(first_op, TableAlterOperation) else first_op
172+
if not isinstance(expression, exp.Alter) or not expression.this.catalog:
173+
# Fallback for unexpected scenarios
174+
logger.warning(
175+
"Could not determine catalog from alter expression, executing with current context."
176+
)
177+
super().alter_table(alter_expressions)
178+
return
179+
180+
target_catalog = expression.this.catalog
181+
self.set_current_catalog(target_catalog)
182+
183+
with self.transaction():
184+
for op in alter_expressions:
185+
expression = op.expression if isinstance(op, TableAlterOperation) else op
186+
187+
if not isinstance(expression, exp.Alter):
188+
self.execute(expression)
189+
continue
190+
191+
for action in expression.actions:
192+
table_name = expression.this
193+
194+
table_name_without_catalog = table_name.copy()
195+
table_name_without_catalog.set("catalog", None)
196+
197+
is_type_change = isinstance(action, exp.AlterColumn) and action.args.get(
198+
"dtype"
199+
)
200+
201+
if is_type_change:
202+
column_to_alter = action.this
203+
new_type = action.args["dtype"]
204+
temp_column_name_str = f"{column_to_alter.name}__{random_id(short=True)}"
205+
temp_column_name = exp.to_identifier(temp_column_name_str)
206+
207+
logger.info(
208+
"Applying workaround for column '%s' on table '%s' to change type to '%s'.",
209+
column_to_alter.sql(),
210+
table_name.sql(),
211+
new_type.sql(),
212+
)
213+
214+
# Step 1: Add a temporary column.
215+
add_column_expr = exp.Alter(
216+
this=table_name_without_catalog.copy(),
217+
kind="TABLE",
218+
actions=[
219+
exp.ColumnDef(this=temp_column_name.copy(), kind=new_type.copy())
220+
],
221+
)
222+
add_sql = self._to_sql(add_column_expr)
223+
self.execute(add_sql)
224+
225+
# Step 2: Copy and cast data.
226+
update_sql = self._to_sql(
227+
exp.Update(
228+
this=table_name_without_catalog.copy(),
229+
expressions=[
230+
exp.EQ(
231+
this=temp_column_name.copy(),
232+
expression=exp.Cast(
233+
this=column_to_alter.copy(), to=new_type.copy()
234+
),
235+
)
236+
],
237+
)
238+
)
239+
self.execute(update_sql)
240+
241+
# Step 3: Drop the original column.
242+
drop_sql = self._to_sql(
243+
exp.Alter(
244+
this=table_name_without_catalog.copy(),
245+
kind="TABLE",
246+
actions=[exp.Drop(this=column_to_alter.copy(), kind="COLUMN")],
247+
)
248+
)
249+
self.execute(drop_sql)
250+
251+
# Step 4: Rename the temporary column.
252+
old_name_qualified = f"{table_name_without_catalog.sql(dialect=self.dialect)}.{temp_column_name.sql(dialect=self.dialect)}"
253+
new_name_unquoted = column_to_alter.sql(
254+
dialect=self.dialect, identify=False
255+
)
256+
rename_sql = f"EXEC sp_rename '{old_name_qualified}', '{new_name_unquoted}', 'COLUMN'"
257+
self.execute(rename_sql)
258+
else:
259+
# For other alterations, execute directly.
260+
direct_alter_expr = exp.Alter(
261+
this=table_name_without_catalog.copy(), kind="TABLE", actions=[action]
262+
)
263+
self.execute(direct_alter_expr)
264+
157265

158266
class FabricHttpClient:
159267
def __init__(self, tenant_id: str, workspace_id: str, client_id: str, client_secret: str):

sqlmesh/core/linter/rules/builtin.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,21 @@ def check_model(self, model: Model) -> t.Optional[RuleViolation]:
129129
return self.violation()
130130

131131

132+
class NoMissingUnitTest(Rule):
133+
"""All models must have a unit test found in the test/ directory yaml files"""
134+
135+
def check_model(self, model: Model) -> t.Optional[RuleViolation]:
136+
# External models cannot have unit tests
137+
if isinstance(model, ExternalModel):
138+
return None
139+
140+
if model.name not in self.context.models_with_tests:
141+
return self.violation(
142+
violation_msg=f"Model {model.name} is missing unit test(s). Please add in the tests/ directory."
143+
)
144+
return None
145+
146+
132147
class NoMissingExternalModels(Rule):
133148
"""All external models must be registered in the external_models.yaml file"""
134149

0 commit comments

Comments
 (0)