Skip to content

Commit 74c65e2

Browse files
authored
Merge branch 'main' into lint-model-paths
2 parents ecf41ac + 5f1911d commit 74c65e2

20 files changed

Lines changed: 1026 additions & 52 deletions

File tree

‎.github/workflows/pr.yaml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -558,7 +558,8 @@ jobs:
558558
run: |
559559
source ../../.venv/bin/activate
560560
sed -i 's/target: in_memory/target: postgres/g' profiles.yml
561-
if [[ $(echo -e "${{ matrix.dbt-version }}\n1.5.0" | sort -V | head -n1) == "${{ matrix.dbt-version }}" ]] && [[ "${{ matrix.dbt-version }}" != "1.5.0" ]]; then
561+
if [[ "${{ matrix.dbt-version }}" == "1.3" ]] || \
562+
[[ "${{ matrix.dbt-version }}" == "1.4" ]]; then
562563
echo "DBT version is ${{ matrix.dbt-version }} (< 1.5.0), removing version parameters..."
563564
sed -i -e 's/, version=1) }}/) }}/g' -e 's/, v=1) }}/) }}/g' models/top_waiters.sql
564565
else

‎docs/concepts/tests.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,20 @@ You can also run tests that match a pattern or substring using a glob pathname e
463463
$ sqlmesh test tests/test_*
464464
```
465465

466+
Passing the path of a model file runs the tests for that model, which is useful for commit hooks and other tools that work with changed files rather than test names:
467+
468+
```
469+
$ sqlmesh test models/full_model.sql
470+
```
471+
472+
Model files and test files can be mixed, and the results are unioned. A test selected by more than one argument still runs only once, so the following runs each of `full_model`'s tests a single time even though both arguments cover them:
473+
474+
```
475+
$ sqlmesh test models/full_model.sql tests/test_full_model.yaml
476+
```
477+
478+
An argument that is neither a known model file nor a known test file is an error, so a mistyped or stale path fails instead of quietly running no tests. A model that simply has no tests is not an error.
479+
466480
You can pass `--local` to run tests without loading state from the configured state connection:
467481

468482
``` bash

‎docs/reference/cli.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,10 @@ Usage: sqlmesh test [OPTIONS] [TESTS]...
623623
624624
Run model unit tests.
625625
626+
TESTS are test files, `file.yaml::test_name` selectors, or model files, in
627+
which case the tests for those models are run. They are unioned, and a test
628+
selected more than once still only runs once.
629+
626630
Options:
627631
-k TEXT Only run tests that match the pattern of substring.
628632
-v, --verbose Verbose output.

‎pnpm-lock.yaml‎

Lines changed: 12 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎sqlmesh/cli/main.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -835,7 +835,12 @@ def test(
835835
select_model: t.List[str],
836836
tests: t.List[str],
837837
) -> None:
838-
"""Run model unit tests."""
838+
"""Run model unit tests.
839+
840+
TESTS are test files, `file.yaml::test_name` selectors, or model files, in which case the
841+
tests for those models are run. They are unioned, and a test selected more than once still
842+
only runs once.
843+
"""
839844
model_names = (
840845
obj._new_selector().expand_model_selections(select_model) if select_model else None
841846
)
@@ -845,6 +850,7 @@ def test(
845850
verbosity=Verbosity(verbose),
846851
preserve_fixtures=preserve_fixtures,
847852
model_names=model_names,
853+
raise_on_unknown_paths=True,
848854
)
849855
if not result.wasSuccessful():
850856
exit(1)

‎sqlmesh/core/context.py‎

Lines changed: 141 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import abc
3737
import collections
3838
import logging
39+
import os.path
3940
import sys
4041
import time
4142
import traceback
@@ -107,6 +108,7 @@
107108
CachingStateSync,
108109
StateReader,
109110
StateSync,
111+
Versions,
110112
)
111113
from sqlmesh.core.janitor import cleanup_expired_views, delete_expired_snapshots
112114
from sqlmesh.core.table_diff import TableDiff
@@ -119,7 +121,7 @@
119121
filter_tests_by_patterns,
120122
)
121123
from sqlmesh.core.user import User
122-
from sqlmesh.utils import CorrelationId, UniqueKeyDict, Verbosity
124+
from sqlmesh.utils import CorrelationId, UniqueKeyDict, Verbosity, unique
123125
from sqlmesh.utils.concurrency import concurrent_apply_to_values
124126
from sqlmesh.utils.dag import DAG
125127
from sqlmesh.utils.date import (
@@ -2435,17 +2437,26 @@ def test(
24352437
preserve_fixtures: bool = False,
24362438
stream: t.Optional[t.TextIO] = None,
24372439
model_names: t.Optional[t.Collection[str]] = None,
2440+
raise_on_unknown_paths: bool = False,
24382441
) -> ModelTextTestResult:
24392442
"""Discover and run model tests"""
24402443
if verbosity >= Verbosity.VERBOSE:
24412444
import pandas as pd
24422445

24432446
pd.set_option("display.max_columns", None)
24442447

2445-
baseline_meta = self.select_tests(tests=tests, patterns=match_patterns, model_names=None)
2448+
baseline_meta = self.select_tests(
2449+
tests=tests,
2450+
patterns=match_patterns,
2451+
model_names=None,
2452+
raise_on_unknown_paths=raise_on_unknown_paths,
2453+
)
24462454
if model_names is not None:
24472455
test_meta = self.select_tests(
2448-
tests=tests, patterns=match_patterns, model_names=model_names
2456+
tests=tests,
2457+
patterns=match_patterns,
2458+
model_names=model_names,
2459+
raise_on_unknown_paths=raise_on_unknown_paths,
24492460
)
24502461
tests_skipped = len(baseline_meta) - len(test_meta)
24512462
else:
@@ -2628,15 +2639,18 @@ def migrate(self) -> None:
26282639
"""
26292640
self.notification_target_manager.notify(NotificationEvent.MIGRATION_START)
26302641
self._load_materializations()
2642+
state_sync = self._new_state_sync()
2643+
previous_versions = self._state_versions(state_sync)
26312644
try:
2632-
self._new_state_sync().migrate(
2645+
state_sync.migrate(
26332646
promoted_snapshots_only=self.config.migration.promoted_snapshots_only,
26342647
)
26352648
except Exception as e:
26362649
self.notification_target_manager.notify(
26372650
NotificationEvent.MIGRATION_FAILURE, traceback.format_exc()
26382651
)
26392652
raise e
2653+
self._print_state_versions(self._state_versions(state_sync), previous_versions)
26402654
self.notification_target_manager.notify(NotificationEvent.MIGRATION_END)
26412655

26422656
@python_api_analytics
@@ -2645,7 +2659,10 @@ def rollback(self) -> None:
26452659
26462660
Please contact your SQLMesh administrator before doing this. This action cannot be undone.
26472661
"""
2648-
self._new_state_sync().rollback()
2662+
state_sync = self._new_state_sync()
2663+
previous_versions = self._state_versions(state_sync)
2664+
state_sync.rollback()
2665+
self._print_state_versions(self._state_versions(state_sync), previous_versions)
26492666

26502667
@python_api_analytics
26512668
def create_external_models(self, strict: bool = False) -> None:
@@ -2719,6 +2736,12 @@ def print_info(
27192736
if state_connection:
27202737
self._try_connection("state backend", state_connection.connection_validator())
27212738

2739+
if verbosity >= Verbosity.VERBOSE:
2740+
try:
2741+
self._print_state_versions(self._state_versions())
2742+
except Exception as ex:
2743+
self.console.log_error(f"Failed to fetch the state backend versions. {ex}")
2744+
27222745
@python_api_analytics
27232746
def print_environment_names(self) -> None:
27242747
"""Prints all environment names along with expiry datetime."""
@@ -3308,6 +3331,25 @@ def _try_connection(self, connection_name: str, validator: t.Callable[[], None])
33083331
except Exception as ex:
33093332
self.console.log_error(f"{connection_name} connection failed. {ex}")
33103333

3334+
def _state_versions(self, state_sync: t.Optional[StateSync] = None) -> Versions:
3335+
"""Returns the versions recorded in the state backend without validating them."""
3336+
return (state_sync or self._new_state_sync()).get_versions(validate=False)
3337+
3338+
def _print_state_versions(
3339+
self, versions: Versions, previous_versions: t.Optional[Versions] = None
3340+
) -> None:
3341+
"""Prints the state backend versions, optionally alongside the ones they replaced."""
3342+
self.console.log_status_update("\nState backend versions:")
3343+
for label, attribute in (
3344+
("Schema version", "schema_version"),
3345+
("SQLGlot version", "sqlglot_version"),
3346+
("SQLMesh version", "sqlmesh_version"),
3347+
):
3348+
version = getattr(versions, attribute)
3349+
if previous_versions is not None:
3350+
version = f"{getattr(previous_versions, attribute)} -> {version}"
3351+
self.console.log_status_update(f"{label}: {version}")
3352+
33113353
def _new_state_sync(self) -> StateSync:
33123354
return self._provided_state_sync or self._scheduler.create_state_sync(self)
33133355

@@ -3704,30 +3746,112 @@ def lint_models(
37043746

37053747
return all_violations
37063748

3749+
def _tests_by_absolute_model_path(self) -> t.Dict[str, t.List[ModelTestMetadata]]:
3750+
"""Map each model file to the tests that target the model(s) defined in it."""
3751+
tests_by_model_name: t.Dict[str, t.List[ModelTestMetadata]] = collections.defaultdict(list)
3752+
for metadata in self._model_test_metadata:
3753+
if metadata.model_name:
3754+
tests_by_model_name[
3755+
normalize_model_name(
3756+
metadata.model_name,
3757+
default_catalog=self.default_catalog,
3758+
dialect=self.default_dialect,
3759+
)
3760+
].append(metadata)
3761+
3762+
# A path is made absolute rather than resolved, so this costs no syscalls per model.
3763+
tests_by_path: t.Dict[str, t.List[ModelTestMetadata]] = {}
3764+
for fqn, model in self._models.items():
3765+
if model._path is not None:
3766+
tests_by_path.setdefault(os.path.abspath(model._path), []).extend(
3767+
tests_by_model_name.get(fqn, [])
3768+
)
3769+
3770+
return tests_by_path
3771+
3772+
def _select_tests_by_test_path(self, selector: str) -> t.Optional[t.List[ModelTestMetadata]]:
3773+
"""Resolve a selector against the test files, or return None if it matches none of them.
3774+
3775+
The selector is a test file path or a `path::test_name`. Paths are matched as given
3776+
first, so an unchanged selector never pays for normalization.
3777+
"""
3778+
if "::" in selector:
3779+
metadata = self._model_test_metadata_fully_qualified_name_index.get(selector)
3780+
if metadata is None:
3781+
path, _, test_name = selector.rpartition("::")
3782+
metadata = self._model_test_metadata_fully_qualified_name_index.get(
3783+
f"{os.path.abspath(path)}::{test_name}"
3784+
)
3785+
return [metadata] if metadata is not None else None
3786+
3787+
for candidate in (Path(selector), Path(os.path.abspath(selector))):
3788+
matched = self._model_test_metadata_path_index.get(candidate)
3789+
if matched is not None:
3790+
return list(matched)
3791+
3792+
return None
3793+
3794+
def _unknown_test_selector_error(self, selector: str) -> str:
3795+
"""Explains why a selector matched nothing.
3796+
3797+
A `path::test_name` whose file is a known test file failed on the test name, not the
3798+
path, so the message says so rather than claiming the file is unknown.
3799+
"""
3800+
if "::" in selector:
3801+
path, _, _ = selector.rpartition("::")
3802+
if any(
3803+
candidate in self._model_test_metadata_path_index
3804+
for candidate in (Path(path), Path(os.path.abspath(path)))
3805+
):
3806+
return f"'{selector}' is not a known test in '{path}'."
3807+
3808+
return f"'{selector}' is not a known model or test file."
3809+
37073810
def select_tests(
37083811
self,
37093812
tests: t.Optional[t.List[str]] = None,
37103813
patterns: t.Optional[t.List[str]] = None,
37113814
model_names: t.Optional[t.Collection[str]] = None,
3815+
raise_on_unknown_paths: bool = False,
37123816
) -> t.List[ModelTestMetadata]:
3713-
"""Filter pre-loaded test metadata based on tests and patterns."""
3817+
"""Filter pre-loaded test metadata based on tests and patterns.
3818+
3819+
Args:
3820+
tests: Test selectors. Each one is a test file path, a `path::test_name`, or the path
3821+
of a model file, in which case that model's tests are selected. Selectors are
3822+
unioned and the result is deduplicated, so a model file and a test file that
3823+
resolve to the same test run it once rather than twice.
3824+
patterns: Patterns matched against fully qualified test names.
3825+
model_names: If given, narrows the selection to tests targeting these models.
3826+
raise_on_unknown_paths: Whether to raise when a selector matches neither a known test
3827+
nor a known model file. Off by default so that callers which probe arbitrary
3828+
documents, such as the LSP, keep getting an empty result instead of an error.
3829+
"""
37143830

37153831
test_meta = self._model_test_metadata
37163832

37173833
if tests:
3718-
filtered_tests = []
3834+
filtered_tests: t.List[ModelTestMetadata] = []
3835+
# Built at most once, and only if a selector turns out not to be a test file.
3836+
tests_by_model_path: t.Optional[t.Dict[str, t.List[ModelTestMetadata]]] = None
3837+
37193838
for test in tests:
3720-
if "::" in test:
3721-
if test in self._model_test_metadata_fully_qualified_name_index:
3722-
filtered_tests.append(
3723-
self._model_test_metadata_fully_qualified_name_index[test]
3724-
)
3725-
else:
3726-
test_path = Path(test)
3727-
if test_path in self._model_test_metadata_path_index:
3728-
filtered_tests.extend(self._model_test_metadata_path_index[test_path])
3839+
matched = self._select_tests_by_test_path(test)
3840+
if matched is None and "::" not in test:
3841+
if tests_by_model_path is None:
3842+
tests_by_model_path = self._tests_by_absolute_model_path()
3843+
# A known model with no tests matches an empty list, which is not the same
3844+
# as a selector that resolves to nothing at all.
3845+
matched = tests_by_model_path.get(os.path.abspath(test))
3846+
if matched is None:
3847+
if raise_on_unknown_paths:
3848+
raise SQLMeshError(self._unknown_test_selector_error(test))
3849+
continue
3850+
filtered_tests.extend(matched)
37293851

3730-
test_meta = filtered_tests
3852+
# Selectors can overlap, e.g. a model file and the test file holding its tests, so
3853+
# the union is deduplicated to avoid running the same test more than once.
3854+
test_meta = unique(filtered_tests)
37313855

37323856
if patterns:
37333857
test_meta = filter_tests_by_patterns(test_meta, patterns)

‎sqlmesh/core/engine_adapter/mssql.py‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,10 @@ def drop_schema(
180180
**drop_args: t.Dict[str, exp.Expr],
181181
) -> None:
182182
"""
183-
MsSql doesn't support CASCADE clause and drops schemas unconditionally.
183+
MsSql doesn't support CASCADE clause so objects are dropped individually.
184+
185+
SQL Server also forbids dropping the built-in ``dbo`` schema (error 15150).
186+
Objects inside it are still dropped when cascade=True, but the schema itself is left in place.
184187
"""
185188
if cascade:
186189
objects = self._get_data_objects(schema_name)
@@ -199,6 +202,15 @@ def drop_schema(
199202
object_table,
200203
exists=ignore_if_not_exists,
201204
)
205+
206+
schema = (
207+
(schema_name.db or schema_name.name)
208+
if isinstance(schema_name, exp.Table)
209+
else schema_name
210+
)
211+
if schema.lower() == "dbo":
212+
return
213+
202214
super().drop_schema(schema_name, ignore_if_not_exists=ignore_if_not_exists, cascade=False)
203215

204216
def merge(

‎sqlmesh/core/state_sync/db/migrator.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,15 @@ def migrate(
9696
migrate_rows = self._apply_migrations(schema, skip_backup)
9797

9898
if not migrate_rows and major_minor(SQLMESH_VERSION) == versions.minor_sqlmesh_version:
99+
# Nothing to migrate, but a patch-level bump still leaves the recorded versions
100+
# behind what is actually running, so they are brought up to date here. The
101+
# schema version is carried over rather than defaulted, since no migration ran
102+
# and moving it could hide one that is genuinely needed later.
103+
if (
104+
versions.sqlmesh_version != SQLMESH_VERSION
105+
or versions.sqlglot_version != SQLGLOT_VERSION
106+
):
107+
self.version_state.update_versions(schema_version=versions.schema_version)
99108
return
100109

101110
if migrate_rows:

0 commit comments

Comments
 (0)