From 596b911f2e517823cd0f1442b5f0912e794bc7e7 Mon Sep 17 00:00:00 2001 From: Erin Drummond Date: Mon, 8 Dec 2025 13:22:31 +1300 Subject: [PATCH 01/39] Chore: Disable snowflake tests temporarily (#5620) --- .circleci/continue_config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/continue_config.yml b/.circleci/continue_config.yml index 5a240b85e4..d5ad6d5ee1 100644 --- a/.circleci/continue_config.yml +++ b/.circleci/continue_config.yml @@ -309,7 +309,7 @@ workflows: matrix: parameters: engine: - - snowflake + #- snowflake - databricks - redshift - bigquery From 563df69d2d72edc3aaf3d435bfc40b8a19b86cb2 Mon Sep 17 00:00:00 2001 From: Themis Valtinos <73662635+themisvaltinos@users.noreply.github.com> Date: Wed, 10 Dec 2025 00:14:13 +0200 Subject: [PATCH 02/39] Fix: Use categorization config in cicd bot environment summary (#5622) --- sqlmesh/integrations/github/cicd/controller.py | 3 +-- tests/integrations/github/cicd/test_github_controller.py | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/sqlmesh/integrations/github/cicd/controller.py b/sqlmesh/integrations/github/cicd/controller.py index d7a9ef8eb8..b27be4070b 100644 --- a/sqlmesh/integrations/github/cicd/controller.py +++ b/sqlmesh/integrations/github/cicd/controller.py @@ -448,10 +448,9 @@ def prod_plan_with_gaps(self) -> Plan: c.PROD, # this is required to highlight any data gaps between this PR environment and prod (since PR environments may only contain a subset of data) no_gaps=False, - # this works because the snapshots were already categorized when applying self.pr_plan so there are no uncategorized local snapshots to trigger a plan error - no_auto_categorization=True, skip_tests=True, skip_linter=True, + categorizer_config=self.bot_config.auto_categorize_changes, run=self.bot_config.run_on_deploy_to_prod, forward_only=self.forward_only_plan, ) diff --git a/tests/integrations/github/cicd/test_github_controller.py b/tests/integrations/github/cicd/test_github_controller.py index 1e114171a3..baa0fb9ad2 100644 --- a/tests/integrations/github/cicd/test_github_controller.py +++ b/tests/integrations/github/cicd/test_github_controller.py @@ -339,7 +339,8 @@ def test_prod_plan_with_gaps(github_client, make_controller): assert controller.prod_plan_with_gaps.environment.name == c.PROD assert not controller.prod_plan_with_gaps.skip_backfill - assert not controller._prod_plan_with_gaps_builder._auto_categorization_enabled + # auto_categorization should now be enabled to prevent uncategorized snapshot errors + assert controller._prod_plan_with_gaps_builder._auto_categorization_enabled assert not controller.prod_plan_with_gaps.no_gaps assert not controller._context.apply.called assert controller._context._run_plan_tests.call_args == call(skip_tests=True) From ac4aa32c403a86881724b1614027ad9d9c2af1db Mon Sep 17 00:00:00 2001 From: Themis Valtinos <73662635+themisvaltinos@users.noreply.github.com> Date: Thu, 11 Dec 2025 00:38:03 +0200 Subject: [PATCH 03/39] Fix: Include staged changes in the git selector (#5624) --- docs/guides/model_selection.md | 3 +- sqlmesh/utils/git.py | 4 +- tests/core/test_selector_native.py | 88 +++++++++++++++ tests/utils/test_git_client.py | 173 +++++++++++++++++++++++++++++ 4 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 tests/utils/test_git_client.py diff --git a/docs/guides/model_selection.md b/docs/guides/model_selection.md index 9cc0a4358a..e6178246d6 100644 --- a/docs/guides/model_selection.md +++ b/docs/guides/model_selection.md @@ -242,8 +242,9 @@ Models: #### Select with git changes The git-based selector allows you to select models whose files have changed compared to a target branch (default: main). This includes: + - Untracked files (new files not in git) -- Uncommitted changes in working directory +- Uncommitted changes in working directory (both staged and unstaged) - Committed changes different from the target branch For example: diff --git a/sqlmesh/utils/git.py b/sqlmesh/utils/git.py index 00410e776c..cdb9d4e2d5 100644 --- a/sqlmesh/utils/git.py +++ b/sqlmesh/utils/git.py @@ -16,7 +16,9 @@ def list_untracked_files(self) -> t.List[Path]: ) def list_uncommitted_changed_files(self) -> t.List[Path]: - return self._execute_list_output(["diff", "--name-only", "--diff-filter=d"], self._git_root) + return self._execute_list_output( + ["diff", "--name-only", "--diff-filter=d", "HEAD"], self._git_root + ) def list_committed_changed_files(self, target_branch: str = "main") -> t.List[Path]: return self._execute_list_output( diff --git a/tests/core/test_selector_native.py b/tests/core/test_selector_native.py index 46d666db64..5889efadda 100644 --- a/tests/core/test_selector_native.py +++ b/tests/core/test_selector_native.py @@ -6,6 +6,7 @@ import pytest from pytest_mock.plugin import MockerFixture +import subprocess from sqlmesh.core import dialect as d from sqlmesh.core.audit import StandaloneAudit @@ -16,6 +17,7 @@ from sqlmesh.core.snapshot import SnapshotChangeCategory from sqlmesh.utils import UniqueKeyDict from sqlmesh.utils.date import now_timestamp +from sqlmesh.utils.git import GitClient @pytest.mark.parametrize( @@ -634,6 +636,92 @@ def test_expand_git_selection( git_client_mock.list_untracked_files.assert_called_once() +def test_expand_git_selection_integration(tmp_path: Path, mocker: MockerFixture): + repo_path = tmp_path / "test_repo" + repo_path.mkdir() + subprocess.run(["git", "init", "-b", "main"], cwd=repo_path, check=True, capture_output=True) + + models: UniqueKeyDict[str, Model] = UniqueKeyDict("models") + model_a_path = repo_path / "model_a.sql" + model_a_path.write_text("SELECT 1 AS a") + model_a = SqlModel(name="test_model_a", query=d.parse_one("SELECT 1 AS a")) + model_a._path = model_a_path + models[model_a.fqn] = model_a + + model_b_path = repo_path / "model_b.sql" + model_b_path.write_text("SELECT 2 AS b") + model_b = SqlModel(name="test_model_b", query=d.parse_one("SELECT 2 AS b")) + model_b._path = model_b_path + models[model_b.fqn] = model_b + + subprocess.run(["git", "add", "."], cwd=repo_path, check=True, capture_output=True) + subprocess.run( + [ + "git", + "-c", + "user.name=Max", + "-c", + "user.email=max@rb.com", + "commit", + "-m", + "Initial commit", + ], + cwd=repo_path, + check=True, + capture_output=True, + ) + + # no changes should select nothing + git_client = GitClient(repo_path) + selector = NativeSelector(mocker.Mock(), models) + selector._git_client = git_client + assert selector.expand_model_selections([f"git:main"]) == set() + + # modify A but dont stage it, should be only selected + model_a_path.write_text("SELECT 10 AS a") + assert selector.expand_model_selections([f"git:main"]) == {'"test_model_a"'} + + # stage model A, should still select it + subprocess.run(["git", "add", "model_a.sql"], cwd=repo_path, check=True, capture_output=True) + assert selector.expand_model_selections([f"git:main"]) == {'"test_model_a"'} + + # now add unstaged change to B and both should be selected + model_b_path.write_text("SELECT 20 AS b") + assert selector.expand_model_selections([f"git:main"]) == { + '"test_model_a"', + '"test_model_b"', + } + + subprocess.run( + ["git", "checkout", "-b", "dev"], + cwd=repo_path, + check=True, + capture_output=True, + ) + + subprocess.run( + [ + "git", + "-c", + "user.name=Max", + "-c", + "user.email=max@rb.com", + "commit", + "-m", + "Update model_a", + ], + cwd=repo_path, + check=True, + capture_output=True, + ) + + # now A is committed in the dev branch and B unstaged but should both be selected + assert selector.expand_model_selections([f"git:main"]) == { + '"test_model_a"', + '"test_model_b"', + } + + def test_select_models_with_external_parent(mocker: MockerFixture): default_catalog = "test_catalog" added_model = SqlModel( diff --git a/tests/utils/test_git_client.py b/tests/utils/test_git_client.py new file mode 100644 index 0000000000..13eecf294b --- /dev/null +++ b/tests/utils/test_git_client.py @@ -0,0 +1,173 @@ +import subprocess +from pathlib import Path +import pytest +from sqlmesh.utils.git import GitClient + + +@pytest.fixture +def git_repo(tmp_path: Path) -> Path: + repo_path = tmp_path / "test_repo" + repo_path.mkdir() + subprocess.run(["git", "init", "-b", "main"], cwd=repo_path, check=True, capture_output=True) + return repo_path + + +def test_git_uncommitted_changes(git_repo: Path): + git_client = GitClient(git_repo) + + test_file = git_repo / "model.sql" + test_file.write_text("SELECT 1 AS a") + subprocess.run(["git", "add", "model.sql"], cwd=git_repo, check=True, capture_output=True) + subprocess.run( + [ + "git", + "-c", + "user.name=Max", + "-c", + "user.email=max@rb.com", + "commit", + "-m", + "Initial commit", + ], + cwd=git_repo, + check=True, + capture_output=True, + ) + assert git_client.list_uncommitted_changed_files() == [] + + # make an unstaged change and see that it is listed + test_file.write_text("SELECT 2 AS a") + uncommitted = git_client.list_uncommitted_changed_files() + assert len(uncommitted) == 1 + assert uncommitted[0].name == "model.sql" + + # stage the change and test that it is still detected + subprocess.run(["git", "add", "model.sql"], cwd=git_repo, check=True, capture_output=True) + uncommitted = git_client.list_uncommitted_changed_files() + assert len(uncommitted) == 1 + assert uncommitted[0].name == "model.sql" + + +def test_git_both_staged_and_unstaged_changes(git_repo: Path): + git_client = GitClient(git_repo) + + file1 = git_repo / "model1.sql" + file2 = git_repo / "model2.sql" + file1.write_text("SELECT 1") + file2.write_text("SELECT 2") + subprocess.run(["git", "add", "."], cwd=git_repo, check=True, capture_output=True) + subprocess.run( + [ + "git", + "-c", + "user.name=Max", + "-c", + "user.email=max@rb.com", + "commit", + "-m", + "Initial commit", + ], + cwd=git_repo, + check=True, + capture_output=True, + ) + + # stage file1 + file1.write_text("SELECT 10") + subprocess.run(["git", "add", "model1.sql"], cwd=git_repo, check=True, capture_output=True) + + # modify file2 but don't stage it! + file2.write_text("SELECT 20") + + # both should be detected + uncommitted = git_client.list_uncommitted_changed_files() + assert len(uncommitted) == 2 + file_names = {f.name for f in uncommitted} + assert file_names == {"model1.sql", "model2.sql"} + + +def test_git_untracked_files(git_repo: Path): + git_client = GitClient(git_repo) + initial_file = git_repo / "initial.sql" + initial_file.write_text("SELECT 0") + subprocess.run(["git", "add", "initial.sql"], cwd=git_repo, check=True, capture_output=True) + subprocess.run( + [ + "git", + "-c", + "user.name=Max", + "-c", + "user.email=max@rb.com", + "commit", + "-m", + "Initial commit", + ], + cwd=git_repo, + check=True, + capture_output=True, + ) + + new_file = git_repo / "new_model.sql" + new_file.write_text("SELECT 1") + + # untracked file should not appear in uncommitted changes + assert git_client.list_uncommitted_changed_files() == [] + + # but in untracked + untracked = git_client.list_untracked_files() + assert len(untracked) == 1 + assert untracked[0].name == "new_model.sql" + + +def test_git_committed_changes(git_repo: Path): + git_client = GitClient(git_repo) + + test_file = git_repo / "model.sql" + test_file.write_text("SELECT 1") + subprocess.run(["git", "add", "model.sql"], cwd=git_repo, check=True, capture_output=True) + subprocess.run( + [ + "git", + "-c", + "user.name=Max", + "-c", + "user.email=max@rb.com", + "commit", + "-m", + "Initial commit", + ], + cwd=git_repo, + check=True, + capture_output=True, + ) + + subprocess.run( + ["git", "checkout", "-b", "feature"], + cwd=git_repo, + check=True, + capture_output=True, + ) + + test_file.write_text("SELECT 2") + subprocess.run(["git", "add", "model.sql"], cwd=git_repo, check=True, capture_output=True) + subprocess.run( + [ + "git", + "-c", + "user.name=Max", + "-c", + "user.email=max@rb.com", + "commit", + "-m", + "Update on feature branch", + ], + cwd=git_repo, + check=True, + capture_output=True, + ) + + committed = git_client.list_committed_changed_files(target_branch="main") + assert len(committed) == 1 + assert committed[0].name == "model.sql" + + assert git_client.list_uncommitted_changed_files() == [] From 8efc5533bfc492b2ec789858c862d43c2d4b2c6c Mon Sep 17 00:00:00 2001 From: Ryan Eakman <6326532+eakmanrq@users.noreply.github.com> Date: Thu, 11 Dec 2025 09:35:48 -0800 Subject: [PATCH 04/39] chore: remove dbt from sqlmesh tests package (#5626) --- tests/setup.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/setup.py b/tests/setup.py index d072cb555b..ab48a3128f 100644 --- a/tests/setup.py +++ b/tests/setup.py @@ -7,6 +7,8 @@ sqlmesh_pyproject = Path(__file__).parent / "sqlmesh_pyproject.toml" parsed = toml.load(sqlmesh_pyproject)["project"] install_requires = parsed["dependencies"] + parsed["optional-dependencies"]["dev"] +# remove dbt dependencies +install_requires = [req for req in install_requires if not req.startswith("dbt")] # this is just so we can have a dynamic install_requires, everything else is defined in pyproject.toml setuptools.setup(install_requires=install_requires) From d719391ab18fbc1aa2a2b5b5c2cd57c3e7887c6d Mon Sep 17 00:00:00 2001 From: etonlels Date: Thu, 18 Dec 2025 08:55:20 -0700 Subject: [PATCH 05/39] fix: set `ExternalModel`'s default `kind` to `ExternalKind()` (#5634) --- sqlmesh/core/model/definition.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sqlmesh/core/model/definition.py b/sqlmesh/core/model/definition.py index b6ea6d23e1..9154b4ec2f 100644 --- a/sqlmesh/core/model/definition.py +++ b/sqlmesh/core/model/definition.py @@ -34,6 +34,7 @@ ) from sqlmesh.core.model.meta import ModelMeta from sqlmesh.core.model.kind import ( + ExternalKind, ModelKindName, SeedKind, ModelKind, @@ -1969,6 +1970,7 @@ def _data_hash_values_no_sql(self) -> t.List[str]: class ExternalModel(_Model): """The model definition which represents an external source/table.""" + kind: ModelKind = ExternalKind() source_type: t.Literal["external"] = "external" def is_breaking_change(self, previous: Model) -> t.Optional[bool]: From 850c41bc624dfdcb9a813f33cde8030e762aa748 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Fri, 19 Dec 2025 10:11:43 -0800 Subject: [PATCH 06/39] Feat(trino): Introduce custom timestamp type mapping (#5635) --- sqlmesh/core/config/connection.py | 35 ++++- sqlmesh/core/engine_adapter/trino.py | 49 ++++++- tests/core/engine_adapter/test_trino.py | 187 ++++++++++++++++++++++++ tests/core/test_connection_config.py | 58 ++++++++ 4 files changed, 322 insertions(+), 7 deletions(-) diff --git a/sqlmesh/core/config/connection.py b/sqlmesh/core/config/connection.py index d89d896897..638f0c28c8 100644 --- a/sqlmesh/core/config/connection.py +++ b/sqlmesh/core/config/connection.py @@ -17,6 +17,7 @@ from packaging import version from sqlglot import exp from sqlglot.helper import subclasses +from sqlglot.errors import ParseError from sqlmesh.core import engine_adapter from sqlmesh.core.config.base import BaseConfig @@ -1890,6 +1891,7 @@ class TrinoConnectionConfig(ConnectionConfig): # SQLMesh options schema_location_mapping: t.Optional[dict[re.Pattern, str]] = None + timestamp_mapping: t.Optional[dict[exp.DataType, exp.DataType]] = None concurrent_tasks: int = 4 register_comments: bool = True pre_ping: t.Literal[False] = False @@ -1914,6 +1916,34 @@ def _validate_regex_keys( ) return compiled + @field_validator("timestamp_mapping", mode="before") + @classmethod + def _validate_timestamp_mapping( + cls, value: t.Optional[dict[str, str]] + ) -> t.Optional[dict[exp.DataType, exp.DataType]]: + if value is None: + return value + + result: dict[exp.DataType, exp.DataType] = {} + for source_type, target_type in value.items(): + try: + source_datatype = exp.DataType.build(source_type) + except ParseError: + raise ConfigError( + f"Invalid SQL type string in timestamp_mapping: " + f"'{source_type}' is not a valid SQL data type." + ) + try: + target_datatype = exp.DataType.build(target_type) + except ParseError: + raise ConfigError( + f"Invalid SQL type string in timestamp_mapping: " + f"'{target_type}' is not a valid SQL data type." + ) + result[source_datatype] = target_datatype + + return result + @model_validator(mode="after") def _root_validator(self) -> Self: port = self.port @@ -2016,7 +2046,10 @@ def _static_connection_kwargs(self) -> t.Dict[str, t.Any]: @property def _extra_engine_config(self) -> t.Dict[str, t.Any]: - return {"schema_location_mapping": self.schema_location_mapping} + return { + "schema_location_mapping": self.schema_location_mapping, + "timestamp_mapping": self.timestamp_mapping, + } class ClickhouseConnectionConfig(ConnectionConfig): diff --git a/sqlmesh/core/engine_adapter/trino.py b/sqlmesh/core/engine_adapter/trino.py index 74df3667ff..89470728f2 100644 --- a/sqlmesh/core/engine_adapter/trino.py +++ b/sqlmesh/core/engine_adapter/trino.py @@ -74,6 +74,32 @@ class TrinoEngineAdapter( def schema_location_mapping(self) -> t.Optional[t.Dict[re.Pattern, str]]: return self._extra_config.get("schema_location_mapping") + @property + def timestamp_mapping(self) -> t.Optional[t.Dict[exp.DataType, exp.DataType]]: + return self._extra_config.get("timestamp_mapping") + + def _apply_timestamp_mapping( + self, columns_to_types: t.Dict[str, exp.DataType] + ) -> t.Tuple[t.Dict[str, exp.DataType], t.Set[str]]: + """Apply custom timestamp mapping to column types. + + Returns: + A tuple of (mapped_columns_to_types, mapped_column_names) where mapped_column_names + contains the names of columns that were found in the mapping. + """ + if not self.timestamp_mapping: + return columns_to_types, set() + + result = {} + mapped_columns: t.Set[str] = set() + for column, column_type in columns_to_types.items(): + if column_type in self.timestamp_mapping: + result[column] = self.timestamp_mapping[column_type] + mapped_columns.add(column) + else: + result[column] = column_type + return result, mapped_columns + @property def catalog_support(self) -> CatalogSupport: return CatalogSupport.FULL_SUPPORT @@ -117,7 +143,7 @@ def session(self, properties: SessionProperties) -> t.Iterator[None]: try: yield finally: - self.execute(f"RESET SESSION AUTHORIZATION") + self.execute("RESET SESSION AUTHORIZATION") def replace_query( self, @@ -286,8 +312,11 @@ def _build_schema_exp( is_view: bool = False, materialized: bool = False, ) -> exp.Schema: + target_columns_to_types, mapped_columns = self._apply_timestamp_mapping( + target_columns_to_types + ) if "delta_lake" in self.get_catalog_type_from_table(table): - target_columns_to_types = self._to_delta_ts(target_columns_to_types) + target_columns_to_types = self._to_delta_ts(target_columns_to_types, mapped_columns) return super()._build_schema_exp( table, target_columns_to_types, column_descriptions, expressions, is_view @@ -313,10 +342,15 @@ def _scd_type_2( source_columns: t.Optional[t.List[str]] = None, **kwargs: t.Any, ) -> None: + mapped_columns: t.Set[str] = set() + if target_columns_to_types: + target_columns_to_types, mapped_columns = self._apply_timestamp_mapping( + target_columns_to_types + ) if target_columns_to_types and "delta_lake" in self.get_catalog_type_from_table( target_table ): - target_columns_to_types = self._to_delta_ts(target_columns_to_types) + target_columns_to_types = self._to_delta_ts(target_columns_to_types, mapped_columns) return super()._scd_type_2( target_table, @@ -346,18 +380,21 @@ def _scd_type_2( # - `timestamp(3) with time zone` for timezone-aware # https://trino.io/docs/current/connector/delta-lake.html#delta-lake-to-trino-type-mapping def _to_delta_ts( - self, columns_to_types: t.Dict[str, exp.DataType] + self, + columns_to_types: t.Dict[str, exp.DataType], + skip_columns: t.Optional[t.Set[str]] = None, ) -> t.Dict[str, exp.DataType]: ts6 = exp.DataType.build("timestamp(6)") ts3_tz = exp.DataType.build("timestamp(3) with time zone") + skip = skip_columns or set() delta_columns_to_types = { - k: ts6 if v.is_type(exp.DataType.Type.TIMESTAMP) else v + k: ts6 if k not in skip and v.is_type(exp.DataType.Type.TIMESTAMP) else v for k, v in columns_to_types.items() } delta_columns_to_types = { - k: ts3_tz if v.is_type(exp.DataType.Type.TIMESTAMPTZ) else v + k: ts3_tz if k not in skip and v.is_type(exp.DataType.Type.TIMESTAMPTZ) else v for k, v in delta_columns_to_types.items() } diff --git a/tests/core/engine_adapter/test_trino.py b/tests/core/engine_adapter/test_trino.py index bf925c875a..a3c67eb023 100644 --- a/tests/core/engine_adapter/test_trino.py +++ b/tests/core/engine_adapter/test_trino.py @@ -404,6 +404,119 @@ def test_delta_timestamps(make_mocked_engine_adapter: t.Callable): } +def test_timestamp_mapping(): + """Test that timestamp_mapping config property is properly defined and accessible.""" + config = TrinoConnectionConfig( + user="user", + host="host", + catalog="catalog", + ) + + adapter = config.create_engine_adapter() + assert adapter.timestamp_mapping is None + + config = TrinoConnectionConfig( + user="user", + host="host", + catalog="catalog", + timestamp_mapping={ + "TIMESTAMP": "TIMESTAMP(6)", + "TIMESTAMP(3)": "TIMESTAMP WITH TIME ZONE", + }, + ) + adapter = config.create_engine_adapter() + assert adapter.timestamp_mapping is not None + assert adapter.timestamp_mapping[exp.DataType.build("TIMESTAMP")] == exp.DataType.build( + "TIMESTAMP(6)" + ) + + +def test_delta_timestamps_with_custom_mapping(make_mocked_engine_adapter: t.Callable): + """Test that _apply_timestamp_mapping + _to_delta_ts respects custom timestamp_mapping.""" + # Create config with custom timestamp mapping + # Mapped columns are skipped by _to_delta_ts + config = TrinoConnectionConfig( + user="user", + host="host", + catalog="catalog", + timestamp_mapping={ + "TIMESTAMP": "TIMESTAMP(3)", + "TIMESTAMP(1)": "TIMESTAMP(3)", + "TIMESTAMP WITH TIME ZONE": "TIMESTAMP(6) WITH TIME ZONE", + "TIMESTAMP(1) WITH TIME ZONE": "TIMESTAMP(6) WITH TIME ZONE", + }, + ) + + adapter = make_mocked_engine_adapter( + TrinoEngineAdapter, timestamp_mapping=config.timestamp_mapping + ) + + ts3 = exp.DataType.build("timestamp(3)") + ts6_tz = exp.DataType.build("timestamp(6) with time zone") + + columns_to_types = { + "ts": exp.DataType.build("TIMESTAMP"), + "ts_1": exp.DataType.build("TIMESTAMP(1)"), + "ts_tz": exp.DataType.build("TIMESTAMP WITH TIME ZONE"), + "ts_tz_1": exp.DataType.build("TIMESTAMP(1) WITH TIME ZONE"), + } + + # Apply mapping first, then convert to delta types (skipping mapped columns) + mapped_columns_to_types, mapped_column_names = adapter._apply_timestamp_mapping( + columns_to_types + ) + delta_columns_to_types = adapter._to_delta_ts(mapped_columns_to_types, mapped_column_names) + + # All types were mapped, so _to_delta_ts skips them - they keep their mapped types + assert delta_columns_to_types == { + "ts": ts3, + "ts_1": ts3, + "ts_tz": ts6_tz, + "ts_tz_1": ts6_tz, + } + + +def test_delta_timestamps_with_partial_mapping(make_mocked_engine_adapter: t.Callable): + """Test that _apply_timestamp_mapping + _to_delta_ts uses custom mapping for specified types.""" + config = TrinoConnectionConfig( + user="user", + host="host", + catalog="catalog", + timestamp_mapping={ + "TIMESTAMP": "TIMESTAMP(3)", + }, + ) + + adapter = make_mocked_engine_adapter( + TrinoEngineAdapter, timestamp_mapping=config.timestamp_mapping + ) + + ts3 = exp.DataType.build("TIMESTAMP(3)") + ts6 = exp.DataType.build("timestamp(6)") + ts3_tz = exp.DataType.build("timestamp(3) with time zone") + + columns_to_types = { + "ts": exp.DataType.build("TIMESTAMP"), + "ts_1": exp.DataType.build("TIMESTAMP(1)"), + "ts_tz": exp.DataType.build("TIMESTAMP WITH TIME ZONE"), + } + + # Apply mapping first, then convert to delta types (skipping mapped columns) + mapped_columns_to_types, mapped_column_names = adapter._apply_timestamp_mapping( + columns_to_types + ) + delta_columns_to_types = adapter._to_delta_ts(mapped_columns_to_types, mapped_column_names) + + # TIMESTAMP is in mapping → TIMESTAMP(3), skipped by _to_delta_ts + # TIMESTAMP(1) is NOT in mapping, uses default TIMESTAMP → ts6 + # TIMESTAMP WITH TIME ZONE is NOT in mapping, uses default TIMESTAMPTZ → ts3_tz + assert delta_columns_to_types == { + "ts": ts3, # Mapped to TIMESTAMP(3), skipped by _to_delta_ts + "ts_1": ts6, # Not in mapping, uses default + "ts_tz": ts3_tz, # Not in mapping, uses default + } + + def test_table_format(trino_mocked_engine_adapter: TrinoEngineAdapter, mocker: MockerFixture): adapter = trino_mocked_engine_adapter mocker.patch( @@ -755,3 +868,77 @@ def test_insert_overwrite_time_partition_iceberg( 'DELETE FROM "my_catalog"."schema"."test_table" WHERE "b" BETWEEN \'2022-01-01\' AND \'2022-01-02\'', 'INSERT INTO "my_catalog"."schema"."test_table" ("a", "b") SELECT "a", "b" FROM (SELECT "a", "b" FROM "tbl") AS "_subquery" WHERE "b" BETWEEN \'2022-01-01\' AND \'2022-01-02\'', ] + + +def test_delta_timestamps_with_non_timestamp_columns(make_mocked_engine_adapter: t.Callable): + """Test that _apply_timestamp_mapping + _to_delta_ts handles non-timestamp columns.""" + config = TrinoConnectionConfig( + user="user", + host="host", + catalog="catalog", + timestamp_mapping={ + "TIMESTAMP": "TIMESTAMP(3)", + }, + ) + + adapter = make_mocked_engine_adapter( + TrinoEngineAdapter, timestamp_mapping=config.timestamp_mapping + ) + + ts3 = exp.DataType.build("TIMESTAMP(3)") + ts6 = exp.DataType.build("timestamp(6)") + + columns_to_types = { + "ts": exp.DataType.build("TIMESTAMP"), + "ts_1": exp.DataType.build("TIMESTAMP(1)"), + "int_col": exp.DataType.build("INT"), + "varchar_col": exp.DataType.build("VARCHAR(100)"), + "decimal_col": exp.DataType.build("DECIMAL(10,2)"), + } + + # Apply mapping first, then convert to delta types (skipping mapped columns) + mapped_columns_to_types, mapped_column_names = adapter._apply_timestamp_mapping( + columns_to_types + ) + delta_columns_to_types = adapter._to_delta_ts(mapped_columns_to_types, mapped_column_names) + + # TIMESTAMP is in mapping → TIMESTAMP(3), skipped by _to_delta_ts + # TIMESTAMP(1) is NOT in mapping (exact match), uses default TIMESTAMP → ts6 + # Non-timestamp columns should pass through unchanged + assert delta_columns_to_types == { + "ts": ts3, # Mapped to TIMESTAMP(3), skipped by _to_delta_ts + "ts_1": ts6, # Not in mapping, uses default + "int_col": exp.DataType.build("INT"), + "varchar_col": exp.DataType.build("VARCHAR(100)"), + "decimal_col": exp.DataType.build("DECIMAL(10,2)"), + } + + +def test_delta_timestamps_with_empty_mapping(make_mocked_engine_adapter: t.Callable): + """Test that _to_delta_ts handles empty custom mapping dictionary.""" + config = TrinoConnectionConfig( + user="user", + host="host", + catalog="catalog", + timestamp_mapping={}, + ) + + adapter = make_mocked_engine_adapter( + TrinoEngineAdapter, timestamp_mapping=config.timestamp_mapping + ) + + ts6 = exp.DataType.build("timestamp(6)") + ts3_tz = exp.DataType.build("timestamp(3) with time zone") + + columns_to_types = { + "ts": exp.DataType.build("TIMESTAMP"), + "ts_tz": exp.DataType.build("TIMESTAMP WITH TIME ZONE"), + } + + delta_columns_to_types = adapter._to_delta_ts(columns_to_types) + + # With empty custom mapping, should fall back to defaults + assert delta_columns_to_types == { + "ts": ts6, + "ts_tz": ts3_tz, + } diff --git a/tests/core/test_connection_config.py b/tests/core/test_connection_config.py index a0d54e03dd..dd979a2551 100644 --- a/tests/core/test_connection_config.py +++ b/tests/core/test_connection_config.py @@ -4,6 +4,7 @@ import pytest from _pytest.fixtures import FixtureRequest +from sqlglot import exp from unittest.mock import patch, MagicMock from sqlmesh.core.config.connection import ( @@ -444,6 +445,63 @@ def test_trino_catalog_type_override(make_config): assert config.catalog_type_overrides == {"my_catalog": "iceberg"} +def test_trino_timestamp_mapping(make_config): + required_kwargs = dict( + type="trino", + user="user", + host="host", + catalog="catalog", + ) + + # Test config without timestamp_mapping + config = make_config(**required_kwargs) + assert config.timestamp_mapping is None + + # Test config with timestamp_mapping + config = make_config( + **required_kwargs, + timestamp_mapping={ + "TIMESTAMP": "TIMESTAMP(6)", + "TIMESTAMP(3)": "TIMESTAMP WITH TIME ZONE", + }, + ) + + assert config.timestamp_mapping is not None + assert config.timestamp_mapping[exp.DataType.build("TIMESTAMP")] == exp.DataType.build( + "TIMESTAMP(6)" + ) + + # Test with invalid source type + with pytest.raises(ConfigError) as exc_info: + make_config( + **required_kwargs, + timestamp_mapping={ + "INVALID_TYPE": "TIMESTAMP", + }, + ) + assert "Invalid SQL type string" in str(exc_info.value) + assert "INVALID_TYPE" in str(exc_info.value) + + # Test with invalid target type (not a valid SQL type) + with pytest.raises(ConfigError) as exc_info: + make_config( + **required_kwargs, + timestamp_mapping={ + "TIMESTAMP": "INVALID_TARGET_TYPE", + }, + ) + assert "Invalid SQL type string" in str(exc_info.value) + assert "INVALID_TARGET_TYPE" in str(exc_info.value) + + # Test with empty mapping + config = make_config( + **required_kwargs, + timestamp_mapping={}, + ) + assert config.timestamp_mapping is not None + assert config.timestamp_mapping == {} + + def test_duckdb(make_config): config = make_config( type="duckdb", From 3fd4dd984773e7e986c72426e4e5a28262e36df6 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Fri, 19 Dec 2025 12:07:05 -0800 Subject: [PATCH 07/39] Chore: Update type annotation for pandas and pandas-stubs >= v2.3.3 (#5637) --- sqlmesh/core/test/definition.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/sqlmesh/core/test/definition.py b/sqlmesh/core/test/definition.py index 1c9807cfa1..8694ec6024 100644 --- a/sqlmesh/core/test/definition.py +++ b/sqlmesh/core/test/definition.py @@ -355,11 +355,12 @@ def _to_hashable(x: t.Any) -> t.Any: for df in _split_df_by_column_pairs(diff) ) else: - from pandas import MultiIndex + from pandas import DataFrame, MultiIndex levels = t.cast(MultiIndex, diff.columns).levels[0] for col in levels: - col_diff = diff[col] + # diff[col] returns a DataFrame when columns is a MultiIndex + col_diff = t.cast(DataFrame, diff[col]) if not col_diff.empty: table = df_to_table( f"[bold red]Column '{col}' mismatch{failed_subtest}[/bold red]", From d4a3acbd7b46e929d51a4852fbe8c92b95ea39fc Mon Sep 17 00:00:00 2001 From: Elena Felder <41136058+elefeint@users.noreply.github.com> Date: Thu, 8 Jan 2026 12:38:17 -0500 Subject: [PATCH 08/39] Chore(deps): update the minimum required duckdb version for motherduck (#5650) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 97c190a290..2c140d4770 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,7 +111,7 @@ duckdb = [] fabric = ["pyodbc>=5.0.0"] gcppostgres = ["cloud-sql-python-connector[pg8000]>=1.8.0"] github = ["PyGithub>=2.6.0"] -motherduck = ["duckdb>=1.2.0"] +motherduck = ["duckdb>=1.3.2"] mssql = ["pymssql"] mssql-odbc = ["pyodbc>=5.0.0"] mysql = ["pymysql"] From c668eef01ce844827099a046c054909efc2f6c72 Mon Sep 17 00:00:00 2001 From: Trey Spiller <1831878+treysp@users.noreply.github.com> Date: Fri, 9 Jan 2026 12:14:22 -0600 Subject: [PATCH 09/39] Fix: ignore non-key "dialect" in MODEL/AUDIT block (#5651) --- sqlmesh/core/dialect.py | 12 +++- tests/core/test_model.py | 150 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 2 deletions(-) diff --git a/sqlmesh/core/dialect.py b/sqlmesh/core/dialect.py index 332550d57c..72115fc4a3 100644 --- a/sqlmesh/core/dialect.py +++ b/sqlmesh/core/dialect.py @@ -803,8 +803,15 @@ def text_diff( return "\n".join(unified_diff(a_sql, b_sql)) +WS_OR_COMMENT = r"(?:\s|--[^\n]*\n|/\*.*?\*/)" +HEADER = r"\b(?:model|audit)\b(?=\s*\()" +KEY_BOUNDARY = r"(?:\(|,)" # key is preceded by either '(' or ',' +DIALECT_VALUE = r"['\"]?(?P[a-z][a-z0-9]*)['\"]?" +VALUE_BOUNDARY = r"(?=,|\))" # value is followed by comma or closing paren + DIALECT_PATTERN = re.compile( - r"(model|audit).*?\(.*?dialect\s+'?([a-z]*)", re.IGNORECASE | re.DOTALL + rf"{HEADER}.*?{KEY_BOUNDARY}{WS_OR_COMMENT}*dialect{WS_OR_COMMENT}+{DIALECT_VALUE}{WS_OR_COMMENT}*{VALUE_BOUNDARY}", + re.IGNORECASE | re.DOTALL, ) @@ -895,7 +902,8 @@ def parse( A list of the parsed expressions: [Model, *Statements, Query, *Statements] """ match = match_dialect and DIALECT_PATTERN.search(sql[:MAX_MODEL_DEFINITION_SIZE]) - dialect = Dialect.get_or_raise(match.group(2) if match else default_dialect) + dialect_str = match.group("dialect") if match else None + dialect = Dialect.get_or_raise(dialect_str or default_dialect) tokens = dialect.tokenize(sql) chunks: t.List[t.Tuple[t.List[Token], ChunkType]] = [([], ChunkType.SQL)] diff --git a/tests/core/test_model.py b/tests/core/test_model.py index 3e0f6d40b9..f9ef97ecc0 100644 --- a/tests/core/test_model.py +++ b/tests/core/test_model.py @@ -2727,6 +2727,156 @@ def test_parse(assert_exp_eq): ) +def test_dialect_pattern(): + def make_test_sql(text: str) -> str: + return f""" + MODEL ( + name test_model, + kind INCREMENTAL_BY_TIME_RANGE( + time_column ds + ), + {text} + ); + + SELECT 1; + """ + + def assert_match(test_sql: str, expected_value: t.Optional[str] = "duckdb"): + match = d.DIALECT_PATTERN.search(test_sql) + + dialect_str: t.Optional[str] = None + if expected_value is not None: + assert match + dialect_str = match.group("dialect") + + assert dialect_str == expected_value + + # single-quoted dialect + assert_match( + make_test_sql( + """ + dialect 'duckdb', + description 'there's a dialect foo in here too!' + """ + ) + ) + + # bare dialect + assert_match( + make_test_sql( + """ + dialect duckdb, + description 'there's a dialect foo in here too!' + """ + ) + ) + + # double-quoted dialect (allowed in BQ) + assert_match( + make_test_sql( + """ + dialect "duckdb", + description 'there's a dialect foo in here too!' + """ + ) + ) + + # no dialect specified, "dialect" in description + test_sql = make_test_sql( + """ + description 'there's a dialect foo in here too!' + """ + ) + + matches = list(d.DIALECT_PATTERN.finditer(test_sql)) + assert not matches + + # line comment between properties + assert_match( + make_test_sql( + """ + tag my_tag, -- comment + dialect duckdb + """ + ) + ) + + # block comment between properties + assert_match( + make_test_sql( + """ + tag my_tag, /* comment */ + dialect duckdb + """ + ) + ) + + # quoted empty dialect + assert_match( + make_test_sql( + """ + dialect '', + tag my_tag + """ + ), + None, + ) + + # double-quoted empty dialect + assert_match( + make_test_sql( + """ + dialect "", + tag my_tag + """ + ), + None, + ) + + # trailing comment after dialect value + assert_match( + make_test_sql( + """ + dialect duckdb -- trailing comment + """ + ) + ) + + # dialect value isn't terminated by ',' or ')' + test_sql = make_test_sql( + """ + dialect duckdb -- trailing comment + tag my_tag + """ + ) + + matches = list(d.DIALECT_PATTERN.finditer(test_sql)) + assert not matches + + # dialect first + assert_match( + """ + MODEL( + dialect duckdb, + name my_name + ); + """ + ) + + # full parse + sql = """ + MODEL ( + name test_model, + description 'this text mentions dialect foo but is not a property' + ); + + SELECT 1; + """ + expressions = d.parse(sql, default_dialect="duckdb") + model = load_sql_based_model(expressions) + assert model.dialect == "" + + CONST = "bar" From 529ed0050c3e940834cd8833c7c747e86575a9d8 Mon Sep 17 00:00:00 2001 From: Erin Drummond Date: Mon, 12 Jan 2026 12:42:28 +1300 Subject: [PATCH 10/39] Chore: update databricks and snowflake auth in integration tests (#5652) --- .circleci/continue_config.yml | 6 +++- .circleci/manage-test-db.sh | 16 +--------- .github/workflows/pr.yaml | 2 ++ Makefile | 4 +-- sqlmesh/core/engine_adapter/databricks.py | 32 +++++++++---------- .../engine_adapter/integration/__init__.py | 5 ++- .../engine_adapter/integration/config.yaml | 7 ++-- .../engine_adapter/integration/conftest.py | 1 - .../integration/test_freshness.py | 10 ++++++ 9 files changed, 45 insertions(+), 38 deletions(-) diff --git a/.circleci/continue_config.yml b/.circleci/continue_config.yml index d5ad6d5ee1..bf27e03f47 100644 --- a/.circleci/continue_config.yml +++ b/.circleci/continue_config.yml @@ -258,6 +258,10 @@ jobs: echo "export REDSHIFT_DATABASE='$TEST_DB_NAME'" >> "$BASH_ENV" echo "export GCP_POSTGRES_DATABASE='$TEST_DB_NAME'" >> "$BASH_ENV" echo "export FABRIC_DATABASE='$TEST_DB_NAME'" >> "$BASH_ENV" + + # Make snowflake private key available + echo $SNOWFLAKE_PRIVATE_KEY_RAW | base64 -d > /tmp/snowflake-keyfile.p8 + echo "export SNOWFLAKE_PRIVATE_KEY_FILE='/tmp/snowflake-keyfile.p8'" >> "$BASH_ENV" - run: name: Create test database command: ./.circleci/manage-test-db.sh << parameters.engine >> "$TEST_DB_NAME" up @@ -309,7 +313,7 @@ workflows: matrix: parameters: engine: - #- snowflake + - snowflake - databricks - redshift - bigquery diff --git a/.circleci/manage-test-db.sh b/.circleci/manage-test-db.sh index f90b567ce8..b6e9c265c9 100755 --- a/.circleci/manage-test-db.sh +++ b/.circleci/manage-test-db.sh @@ -25,7 +25,7 @@ function_exists() { # Snowflake snowflake_init() { echo "Installing Snowflake CLI" - pip install "snowflake-cli-labs<3.8.0" + pip install "snowflake-cli" } snowflake_up() { @@ -40,20 +40,6 @@ snowflake_down() { databricks_init() { echo "Installing Databricks CLI" curl -fsSL https://raw.githubusercontent.com/databricks/setup-cli/main/install.sh | sudo sh || true - - echo "Writing out Databricks CLI config file" - echo -e "[DEFAULT]\nhost = $DATABRICKS_SERVER_HOSTNAME\ntoken = $DATABRICKS_ACCESS_TOKEN" > ~/.databrickscfg - - # this takes a path like 'sql/protocolv1/o/2934659247569/0723-005339-foobar' and extracts '0723-005339-foobar' from it - CLUSTER_ID=${DATABRICKS_HTTP_PATH##*/} - - echo "Extracted cluster id: $CLUSTER_ID from '$DATABRICKS_HTTP_PATH'" - - # Note: the cluster doesnt need to be running to create / drop catalogs, but it does need to be running to run the integration tests - echo "Ensuring cluster is running" - # the || true is to prevent the following error from causing an abort: - # > Error: is in unexpected state Running. - databricks clusters start $CLUSTER_ID || true } databricks_up() { diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 08ac729206..69e93635dc 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -30,6 +30,8 @@ jobs: test-vscode-e2e: runs-on: labels: [ubuntu-2204-8] + # As at 2026-01-12 this job flakes 100% of the time. It needs investigation + if: false steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v6 diff --git a/Makefile b/Makefile index 2b3e10cb1b..611b179eba 100644 --- a/Makefile +++ b/Makefile @@ -212,14 +212,14 @@ risingwave-test: engine-risingwave-up # Cloud Engines # ################# -snowflake-test: guard-SNOWFLAKE_ACCOUNT guard-SNOWFLAKE_WAREHOUSE guard-SNOWFLAKE_DATABASE guard-SNOWFLAKE_USER guard-SNOWFLAKE_PASSWORD engine-snowflake-install +snowflake-test: guard-SNOWFLAKE_ACCOUNT guard-SNOWFLAKE_WAREHOUSE guard-SNOWFLAKE_DATABASE guard-SNOWFLAKE_USER engine-snowflake-install pytest -n auto -m "snowflake" --reruns 3 --junitxml=test-results/junit-snowflake.xml bigquery-test: guard-BIGQUERY_KEYFILE engine-bigquery-install $(PIP) install -e ".[bigframes]" pytest -n auto -m "bigquery" --reruns 3 --junitxml=test-results/junit-bigquery.xml -databricks-test: guard-DATABRICKS_CATALOG guard-DATABRICKS_SERVER_HOSTNAME guard-DATABRICKS_HTTP_PATH guard-DATABRICKS_ACCESS_TOKEN guard-DATABRICKS_CONNECT_VERSION engine-databricks-install +databricks-test: guard-DATABRICKS_CATALOG guard-DATABRICKS_SERVER_HOSTNAME guard-DATABRICKS_HTTP_PATH guard-DATABRICKS_CONNECT_VERSION engine-databricks-install $(PIP) install 'databricks-connect==${DATABRICKS_CONNECT_VERSION}' pytest -n auto -m "databricks" --reruns 3 --junitxml=test-results/junit-databricks.xml diff --git a/sqlmesh/core/engine_adapter/databricks.py b/sqlmesh/core/engine_adapter/databricks.py index 97190492f2..870b946e7d 100644 --- a/sqlmesh/core/engine_adapter/databricks.py +++ b/sqlmesh/core/engine_adapter/databricks.py @@ -78,21 +78,21 @@ def can_access_databricks_connect(cls, disable_databricks_connect: bool) -> bool def _use_spark_session(self) -> bool: if self.can_access_spark_session(bool(self._extra_config.get("disable_spark_session"))): return True - return ( - self.can_access_databricks_connect( - bool(self._extra_config.get("disable_databricks_connect")) - ) - and ( - { - "databricks_connect_server_hostname", - "databricks_connect_access_token", - }.issubset(self._extra_config) - ) - and ( - "databricks_connect_cluster_id" in self._extra_config - or "databricks_connect_use_serverless" in self._extra_config - ) - ) + + if self.can_access_databricks_connect( + bool(self._extra_config.get("disable_databricks_connect")) + ): + if self._extra_config.get("databricks_connect_use_serverless"): + return True + + if { + "databricks_connect_cluster_id", + "databricks_connect_server_hostname", + "databricks_connect_access_token", + }.issubset(self._extra_config): + return True + + return False @property def is_spark_session_connection(self) -> bool: @@ -108,7 +108,7 @@ def _set_spark_engine_adapter_if_needed(self) -> None: connect_kwargs = dict( host=self._extra_config["databricks_connect_server_hostname"], - token=self._extra_config["databricks_connect_access_token"], + token=self._extra_config.get("databricks_connect_access_token"), ) if "databricks_connect_use_serverless" in self._extra_config: connect_kwargs["serverless"] = True diff --git a/tests/core/engine_adapter/integration/__init__.py b/tests/core/engine_adapter/integration/__init__.py index 49624154e4..4ad6a17944 100644 --- a/tests/core/engine_adapter/integration/__init__.py +++ b/tests/core/engine_adapter/integration/__init__.py @@ -756,7 +756,10 @@ def _get_create_user_or_role( return username, f"CREATE ROLE {username}" if self.dialect == "databricks": # Creating an account-level group in Databricks requires making REST API calls so we are going to - # use a pre-created group instead. We assume the suffix on the name is the unique id + # use a pre-created group instead. We assume the suffix on the name is the unique id. + # In the Databricks UI, Workspace Settings -> Identity and Access, create the following groups: + # - test_user, test_analyst, test_etl_user, test_reader, test_writer, test_admin + # (there do not need to be any users assigned to these groups) return "_".join(username.split("_")[:-1]), None if self.dialect == "bigquery": # BigQuery uses IAM service accounts that need to be pre-created diff --git a/tests/core/engine_adapter/integration/config.yaml b/tests/core/engine_adapter/integration/config.yaml index 8e87b2c3c8..0b1ecd8193 100644 --- a/tests/core/engine_adapter/integration/config.yaml +++ b/tests/core/engine_adapter/integration/config.yaml @@ -128,7 +128,7 @@ gateways: warehouse: {{ env_var('SNOWFLAKE_WAREHOUSE') }} database: {{ env_var('SNOWFLAKE_DATABASE') }} user: {{ env_var('SNOWFLAKE_USER') }} - password: {{ env_var('SNOWFLAKE_PASSWORD') }} + private_key_path: {{ env_var('SNOWFLAKE_PRIVATE_KEY_FILE', 'tests/fixtures/snowflake/rsa_key_no_pass.p8') }} check_import: false state_connection: type: duckdb @@ -139,7 +139,10 @@ gateways: catalog: {{ env_var('DATABRICKS_CATALOG') }} server_hostname: {{ env_var('DATABRICKS_SERVER_HOSTNAME') }} http_path: {{ env_var('DATABRICKS_HTTP_PATH') }} - access_token: {{ env_var('DATABRICKS_ACCESS_TOKEN') }} + auth_type: {{ env_var('DATABRICKS_AUTH_TYPE', 'databricks-oauth') }} + oauth_client_id: {{ env_var('DATABRICKS_CLIENT_ID') }} + oauth_client_secret: {{ env_var('DATABRICKS_CLIENT_SECRET') }} + databricks_connect_use_serverless: true check_import: false inttest_redshift: diff --git a/tests/core/engine_adapter/integration/conftest.py b/tests/core/engine_adapter/integration/conftest.py index 308819b671..3fb4bc15f1 100644 --- a/tests/core/engine_adapter/integration/conftest.py +++ b/tests/core/engine_adapter/integration/conftest.py @@ -7,7 +7,6 @@ import logging from pytest import FixtureRequest - from sqlmesh import Config, EngineAdapter from sqlmesh.core.constants import SQLMESH_PATH from sqlmesh.core.config.connection import ( diff --git a/tests/core/engine_adapter/integration/test_freshness.py b/tests/core/engine_adapter/integration/test_freshness.py index 5e4c4cf439..e5ee574e7e 100644 --- a/tests/core/engine_adapter/integration/test_freshness.py +++ b/tests/core/engine_adapter/integration/test_freshness.py @@ -25,6 +25,16 @@ EVALUATION_SPY = None +@pytest.fixture(autouse=True) +def _skip_snowflake(ctx: TestContext): + if ctx.dialect == "snowflake": + # these tests use callbacks that need to run db queries within a time_travel context that changes the system time to be in the future + # this causes invalid JWT's to be generated when the callbacks try to run a db query + pytest.skip( + "snowflake.connector generates an invalid JWT when time_travel changes the system time" + ) + + # Mock the snapshot evaluator's evaluate function to count the number of times it is called @pytest.fixture(autouse=True, scope="function") def _install_evaluation_spy(mocker: MockerFixture): From d5ceeb27c489875857d7c644bff5dd60417cc4bc Mon Sep 17 00:00:00 2001 From: Vaggelis Danias Date: Tue, 13 Jan 2026 10:23:33 +0200 Subject: [PATCH 11/39] Chore(cicd_bot): Make console printing optional (#5656) --- sqlmesh/integrations/github/cicd/command.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/sqlmesh/integrations/github/cicd/command.py b/sqlmesh/integrations/github/cicd/command.py index f1b611150a..5506d4917b 100644 --- a/sqlmesh/integrations/github/cicd/command.py +++ b/sqlmesh/integrations/github/cicd/command.py @@ -25,12 +25,21 @@ envvar="GITHUB_TOKEN", help="The Github Token to be used. Pass in `${{ secrets.GITHUB_TOKEN }}` if you want to use the one created by Github actions", ) +@click.option( + "--full-logs", + is_flag=True, + help="Whether to print all logs in the Github Actions output or only in their relevant GA check", +) @click.pass_context -def github(ctx: click.Context, token: str) -> None: +def github(ctx: click.Context, token: str, full_logs: bool = False) -> None: """Github Action CI/CD Bot. See https://sqlmesh.readthedocs.io/en/stable/integrations/github/ for details""" # set a larger width because if none is specified, it auto-detects 80 characters when running in GitHub Actions # which can result in surprise newlines when outputting dates to backfill - set_console(MarkdownConsole(width=1000, warning_capture_only=True, error_capture_only=True)) + set_console( + MarkdownConsole( + width=1000, warning_capture_only=not full_logs, error_capture_only=not full_logs + ) + ) ctx.obj["github"] = GithubController( paths=ctx.obj["paths"], token=token, From 2e5587700a7028824f7581dfb18fc6547b524b8e Mon Sep 17 00:00:00 2001 From: Jesse Hodges Date: Tue, 20 Jan 2026 11:56:11 -0600 Subject: [PATCH 12/39] expose a source option for trino (#5672) --- docs/integrations/engines/trino.md | 1 + sqlmesh/core/config/connection.py | 4 ++- tests/core/engine_adapter/test_trino.py | 4 +++ tests/core/test_config.py | 33 +++++++++++++++++++++++++ 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/docs/integrations/engines/trino.md b/docs/integrations/engines/trino.md index ec1139e20d..db732f0cc1 100644 --- a/docs/integrations/engines/trino.md +++ b/docs/integrations/engines/trino.md @@ -90,6 +90,7 @@ hive.metastore.glue.default-warehouse-dir=s3://my-bucket/ | `http_scheme` | The HTTP scheme to use when connecting to your cluster. By default, it's `https` and can only be `http` for no-auth or basic auth. | string | N | | `port` | The port to connect to your cluster. By default, it's `443` for `https` scheme and `80` for `http` | int | N | | `roles` | Mapping of catalog name to a role | dict | N | +| `source` | Value to send as Trino's `source` field for query attribution / auditing. Default: `sqlmesh`. | string | N | | `http_headers` | Additional HTTP headers to send with each request. | dict | N | | `session_properties` | Trino session properties. Run `SHOW SESSION` to see all options. | dict | N | | `retries` | Number of retries to attempt when a request fails. Default: `3` | int | N | diff --git a/sqlmesh/core/config/connection.py b/sqlmesh/core/config/connection.py index 638f0c28c8..4e11fc626f 100644 --- a/sqlmesh/core/config/connection.py +++ b/sqlmesh/core/config/connection.py @@ -1888,6 +1888,7 @@ class TrinoConnectionConfig(ConnectionConfig): client_certificate: t.Optional[str] = None client_private_key: t.Optional[str] = None cert: t.Optional[str] = None + source: str = "sqlmesh" # SQLMesh options schema_location_mapping: t.Optional[dict[re.Pattern, str]] = None @@ -1984,6 +1985,7 @@ def _connection_kwargs_keys(self) -> t.Set[str]: "port", "catalog", "roles", + "source", "http_scheme", "http_headers", "session_properties", @@ -2041,7 +2043,7 @@ def _static_connection_kwargs(self) -> t.Dict[str, t.Any]: "user": self.impersonation_user or self.user, "max_attempts": self.retries, "verify": self.cert if self.cert is not None else self.verify, - "source": "sqlmesh", + "source": self.source, } @property diff --git a/tests/core/engine_adapter/test_trino.py b/tests/core/engine_adapter/test_trino.py index a3c67eb023..1bfe82b858 100644 --- a/tests/core/engine_adapter/test_trino.py +++ b/tests/core/engine_adapter/test_trino.py @@ -412,6 +412,8 @@ def test_timestamp_mapping(): catalog="catalog", ) + assert config._connection_factory_with_kwargs.keywords["source"] == "sqlmesh" + adapter = config.create_engine_adapter() assert adapter.timestamp_mapping is None @@ -419,11 +421,13 @@ def test_timestamp_mapping(): user="user", host="host", catalog="catalog", + source="my_source", timestamp_mapping={ "TIMESTAMP": "TIMESTAMP(6)", "TIMESTAMP(3)": "TIMESTAMP WITH TIME ZONE", }, ) + assert config._connection_factory_with_kwargs.keywords["source"] == "my_source" adapter = config.create_engine_adapter() assert adapter.timestamp_mapping is not None assert adapter.timestamp_mapping[exp.DataType.build("TIMESTAMP")] == exp.DataType.build( diff --git a/tests/core/test_config.py b/tests/core/test_config.py index d0fad16e76..f3a0de6672 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -862,6 +862,39 @@ def test_trino_schema_location_mapping_syntax(tmp_path): assert len(conn.schema_location_mapping) == 2 +def test_trino_source_option(tmp_path): + config_path = tmp_path / "config_trino_source.yaml" + with open(config_path, "w", encoding="utf-8") as fd: + fd.write( + """ + gateways: + trino: + connection: + type: trino + user: trino + host: trino + catalog: trino + source: my_sqlmesh_source + + default_gateway: trino + + model_defaults: + dialect: trino + """ + ) + + config = load_config_from_paths( + Config, + project_paths=[config_path], + ) + + from sqlmesh.core.config.connection import TrinoConnectionConfig + + conn = config.gateways["trino"].connection + assert isinstance(conn, TrinoConnectionConfig) + assert conn.source == "my_sqlmesh_source" + + def test_gcp_postgres_ip_and_scopes(tmp_path): config_path = tmp_path / "config_gcp_postgres.yaml" with open(config_path, "w", encoding="utf-8") as fd: From 6ddca26e92c6a515d9fc742f39d3d5a7589ffb9c Mon Sep 17 00:00:00 2001 From: Ryan Eakman <6326532+eakmanrq@users.noreply.github.com> Date: Tue, 20 Jan 2026 15:36:29 -0800 Subject: [PATCH 13/39] feat: add ability to disable blocked check merge pr (#5676) --- docs/integrations/github.md | 31 ++++++++++--------- sqlmesh/integrations/github/cicd/config.py | 1 + .../integrations/github/cicd/controller.py | 4 +-- .../github/cicd/test_github_controller.py | 12 +++++++ 4 files changed, 31 insertions(+), 17 deletions(-) diff --git a/docs/integrations/github.md b/docs/integrations/github.md index a11d90d044..923714888e 100644 --- a/docs/integrations/github.md +++ b/docs/integrations/github.md @@ -286,21 +286,22 @@ Below is an example of how to define the default config for the bot in either YA ### Configuration Properties -| Option | Description | Type | Required | -|---------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:------:|:--------:| -| `invalidate_environment_after_deploy` | Indicates if the PR environment created should be automatically invalidated after changes are deployed. Invalidated environments are cleaned up automatically by the Janitor. Default: `True` | bool | N | -| `merge_method` | The merge method to use when automatically merging a PR after deploying to prod. Defaults to `None` meaning automatic merge is not done. Options: `merge`, `squash`, `rebase` | string | N | -| `enable_deploy_command` | Indicates if the `/deploy` command should be enabled in order to allowed synchronized deploys to production. Default: `False` | bool | N | -| `command_namespace` | The namespace to use for SQLMesh commands. For example if you provide `#SQLMesh` as a value then commands will be expected in the format of `#SQLMesh/`. Default: `None` meaning no namespace is used. | string | N | -| `auto_categorize_changes` | Auto categorization behavior to use for the bot. If not provided then the project-wide categorization behavior is used. See [Auto-categorize model changes](https://sqlmesh.readthedocs.io/en/stable/guides/configuration/#auto-categorize-model-changes) for details. | dict | N | -| `default_pr_start` | Default start when creating PR environment plans. If running in a mode where the bot automatically backfills models (based on `auto_categorize_changes` behavior) then this can be used to limit the amount of data backfilled. Defaults to `None` meaning the start date is set to the earliest model's start or to 1 day ago if [data previews](../concepts/plans.md#data-preview) need to be computed.| str | N | -| `pr_min_intervals` | Intended for use when `default_pr_start` is set to a relative time, eg `1 week ago`. This ensures that at least this many intervals across every model are included for backfill in the PR environment. Without this, models with an interval unit wider than `default_pr_start` (such as `@monthly` models if `default_pr_start` was set to `1 week ago`) will be excluded from backfill entirely. | int | N | -| `skip_pr_backfill` | Indicates if the bot should skip backfilling models in the PR environment. Default: `True` | bool | N | -| `pr_include_unmodified` | Indicates whether to include unmodified models in the PR environment. Default to the project's config value (which defaults to `False`) | bool | N | -| `run_on_deploy_to_prod` | Indicates whether to run latest intervals when deploying to prod. If set to false, the deployment will backfill only the changed models up to the existing latest interval in production, ignoring any missing intervals beyond this point. Default: `False` | bool | N | -| `pr_environment_name` | The name of the PR environment to create for which a PR number will be appended to. Defaults to the repo name if not provided. Note: The name will be normalized to alphanumeric + underscore and lowercase. | str | N | -| `prod_branch_name` | The name of the git branch associated with production. Ex: `prod`. Default: `main` or `master` is considered prod | str | N | -| `forward_only_branch_suffix` | If the git branch has this suffix, trigger a [forward-only](../concepts/plans.md#forward-only-plans) plan instead of a normal plan. Default: `-forward-only` | str | N | +| Option | Description | Type | Required | +|---------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:------:|:--------:| +| `invalidate_environment_after_deploy` | Indicates if the PR environment created should be automatically invalidated after changes are deployed. Invalidated environments are cleaned up automatically by the Janitor. Default: `True` | bool | N | +| `merge_method` | The merge method to use when automatically merging a PR after deploying to prod. Defaults to `None` meaning automatic merge is not done. Options: `merge`, `squash`, `rebase` | string | N | +| `enable_deploy_command` | Indicates if the `/deploy` command should be enabled in order to allowed synchronized deploys to production. Default: `False` | bool | N | +| `command_namespace` | The namespace to use for SQLMesh commands. For example if you provide `#SQLMesh` as a value then commands will be expected in the format of `#SQLMesh/`. Default: `None` meaning no namespace is used. | string | N | +| `auto_categorize_changes` | Auto categorization behavior to use for the bot. If not provided then the project-wide categorization behavior is used. See [Auto-categorize model changes](https://sqlmesh.readthedocs.io/en/stable/guides/configuration/#auto-categorize-model-changes) for details. | dict | N | +| `default_pr_start` | Default start when creating PR environment plans. If running in a mode where the bot automatically backfills models (based on `auto_categorize_changes` behavior) then this can be used to limit the amount of data backfilled. Defaults to `None` meaning the start date is set to the earliest model's start or to 1 day ago if [data previews](../concepts/plans.md#data-preview) need to be computed. | str | N | +| `pr_min_intervals` | Intended for use when `default_pr_start` is set to a relative time, eg `1 week ago`. This ensures that at least this many intervals across every model are included for backfill in the PR environment. Without this, models with an interval unit wider than `default_pr_start` (such as `@monthly` models if `default_pr_start` was set to `1 week ago`) will be excluded from backfill entirely. | int | N | +| `skip_pr_backfill` | Indicates if the bot should skip backfilling models in the PR environment. Default: `True` | bool | N | +| `pr_include_unmodified` | Indicates whether to include unmodified models in the PR environment. Default to the project's config value (which defaults to `False`) | bool | N | +| `run_on_deploy_to_prod` | Indicates whether to run latest intervals when deploying to prod. If set to false, the deployment will backfill only the changed models up to the existing latest interval in production, ignoring any missing intervals beyond this point. Default: `False` | bool | N | +| `pr_environment_name` | The name of the PR environment to create for which a PR number will be appended to. Defaults to the repo name if not provided. Note: The name will be normalized to alphanumeric + underscore and lowercase. | str | N | +| `prod_branch_name` | The name of the git branch associated with production. Ex: `prod`. Default: `main` or `master` is considered prod | str | N | +| `forward_only_branch_suffix` | If the git branch has this suffix, trigger a [forward-only](../concepts/plans.md#forward-only-plans) plan instead of a normal plan. Default: `-forward-only` | str | N | +| `check_if_blocked_on_deploy_to_prod` | The bot normally checks if a PR is blocked from merging before deploying to production. Setting this to `False` will skip that check. Default: `True` | bool | N | Example with all properties defined: diff --git a/sqlmesh/integrations/github/cicd/config.py b/sqlmesh/integrations/github/cicd/config.py index a287bf1af5..7fb3a0f5b6 100644 --- a/sqlmesh/integrations/github/cicd/config.py +++ b/sqlmesh/integrations/github/cicd/config.py @@ -36,6 +36,7 @@ class GithubCICDBotConfig(BaseConfig): forward_only_branch_suffix_: t.Optional[str] = Field( default=None, alias="forward_only_branch_suffix" ) + check_if_blocked_on_deploy_to_prod: bool = True @model_validator(mode="before") @classmethod diff --git a/sqlmesh/integrations/github/cicd/controller.py b/sqlmesh/integrations/github/cicd/controller.py index b27be4070b..40102b97e8 100644 --- a/sqlmesh/integrations/github/cicd/controller.py +++ b/sqlmesh/integrations/github/cicd/controller.py @@ -772,10 +772,10 @@ def deploy_to_prod(self) -> None: "PR is already merged and this event was triggered prior to the merge." ) merge_status = self._get_merge_state_status() - if merge_status.is_blocked: + if self.bot_config.check_if_blocked_on_deploy_to_prod and merge_status.is_blocked: raise CICDBotError( "Branch protection or ruleset requirement is likely not satisfied, e.g. missing CODEOWNERS approval. " - "Please check PR and resolve any issues." + "Please check PR and resolve any issues. To disable this check, set `check_if_blocked_on_deploy_to_prod` to false in the bot configuration." ) if merge_status.is_dirty: raise CICDBotError( diff --git a/tests/integrations/github/cicd/test_github_controller.py b/tests/integrations/github/cicd/test_github_controller.py index baa0fb9ad2..e4fe10e321 100644 --- a/tests/integrations/github/cicd/test_github_controller.py +++ b/tests/integrations/github/cicd/test_github_controller.py @@ -476,6 +476,18 @@ def test_deploy_to_prod_blocked_pr(github_client, make_controller): controller.deploy_to_prod() +def test_deploy_to_prod_not_blocked_pr_if_config_set(github_client, make_controller): + mock_pull_request = github_client.get_repo().get_pull() + mock_pull_request.merged = False + controller = make_controller( + "tests/fixtures/github/pull_request_synchronized.json", + github_client, + merge_state_status=MergeStateStatus.BLOCKED, + bot_config=GithubCICDBotConfig(check_if_blocked_on_deploy_to_prod=False), + ) + controller.deploy_to_prod() + + def test_deploy_to_prod_dirty_pr(github_client, make_controller): mock_pull_request = github_client.get_repo().get_pull() mock_pull_request.merged = False From a5544e273171fb3b5e43b310d000e2977f3f7bb9 Mon Sep 17 00:00:00 2001 From: Ryan Eakman <6326532+eakmanrq@users.noreply.github.com> Date: Wed, 21 Jan 2026 16:56:36 -0800 Subject: [PATCH 14/39] fix: exclude pandas 3 (#5681) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2c140d4770..1a674dea72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ "ipywidgets", "jinja2", "packaging", - "pandas", + "pandas<3.0.0", "pydantic>=2.0.0", "python-dotenv", "requests", From 4f833af9f3ad5cec0cbc8b4aca5dcf20548c9f8c Mon Sep 17 00:00:00 2001 From: Jesse Hodges Date: Mon, 26 Jan 2026 14:05:18 -0600 Subject: [PATCH 15/39] document query_label session property (#5683) --- docs/integrations/engines/bigquery.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/integrations/engines/bigquery.md b/docs/integrations/engines/bigquery.md index a454996ecd..b93d6837ed 100644 --- a/docs/integrations/engines/bigquery.md +++ b/docs/integrations/engines/bigquery.md @@ -193,6 +193,23 @@ If the `impersonated_service_account` argument is set, SQLMesh will: The user account must have [sufficient permissions to impersonate the service account](https://cloud.google.com/docs/authentication/use-service-account-impersonation). +## Query Label + +BigQuery supports a `query_label` session variable which is attached to query jobs and can be used for auditing / attribution. + +SQLMesh supports setting it via `session_properties.query_label` on a model, as an array (or tuple) of key/value tuples. + +Example: +```sql +MODEL ( + name my_project.my_dataset.my_model, + dialect 'bigquery', + session_properties ( + query_label = [('team', 'data_platform'), ('env', 'prod')] + ) +); +``` + ## Permissions Required With any of the above connection methods, ensure these BigQuery permissions are enabled to allow SQLMesh to work correctly. From 9213f60a6b76e9b5c04040ea6bb860fd4b6bc06e Mon Sep 17 00:00:00 2001 From: Trey Spiller <1831878+treysp@users.noreply.github.com> Date: Thu, 29 Jan 2026 18:09:00 -0600 Subject: [PATCH 16/39] Chore!: bump sqlglot to 28.6.0 (#5680) --- .circleci/test_migration.sh | 7 +-- examples/sushi/models/customers.sql | 2 +- pyproject.toml | 2 +- sqlmesh/core/config/connection.py | 6 +-- sqlmesh/core/config/scheduler.py | 2 +- sqlmesh/core/engine_adapter/base.py | 4 +- sqlmesh/core/linter/rules/builtin.py | 2 +- sqlmesh/core/loader.py | 2 +- sqlmesh/core/metric/rewriter.py | 2 +- sqlmesh/core/model/definition.py | 2 +- sqlmesh/core/test/definition.py | 4 +- tests/core/engine_adapter/test_bigquery.py | 4 +- tests/core/engine_adapter/test_clickhouse.py | 24 ++++----- tests/core/test_audit.py | 54 +++++++++---------- tests/core/test_context.py | 6 +-- tests/core/test_model.py | 14 ++--- tests/dbt/test_model.py | 4 +- tests/dbt/test_transformation.py | 2 +- .../lsp/test_reference_model_column_prefix.py | 11 ++-- tests/lsp/test_reference_model_find_all.py | 6 +-- tests/utils/test_cache.py | 4 +- 21 files changed, 80 insertions(+), 84 deletions(-) diff --git a/.circleci/test_migration.sh b/.circleci/test_migration.sh index 9b8fe89e6e..bb1776550a 100755 --- a/.circleci/test_migration.sh +++ b/.circleci/test_migration.sh @@ -24,13 +24,14 @@ TEST_DIR="$TMP_DIR/$EXAMPLE_NAME" echo "Running migration test for '$EXAMPLE_NAME' in '$TEST_DIR' for example project '$EXAMPLE_DIR' using options '$SQLMESH_OPTS'" +# Copy the example project from the *current* checkout so it's stable across old/new SQLMesh versions +cp -r "$EXAMPLE_DIR" "$TEST_DIR" + git checkout $LAST_TAG # Install dependencies from the previous release. make install-dev -cp -r $EXAMPLE_DIR $TEST_DIR - # this is only needed temporarily until the released tag for $LAST_TAG includes this config if [ "$EXAMPLE_NAME" == "sushi_dbt" ]; then echo 'migration_test_config = sqlmesh_config(Path(__file__).parent, dbt_target_name="duckdb")' >> $TEST_DIR/config.py @@ -53,4 +54,4 @@ make install-dev pushd $TEST_DIR sqlmesh $SQLMESH_OPTS migrate sqlmesh $SQLMESH_OPTS diff prod -popd \ No newline at end of file +popd diff --git a/examples/sushi/models/customers.sql b/examples/sushi/models/customers.sql index f91f1166e8..d2bda09ed3 100644 --- a/examples/sushi/models/customers.sql +++ b/examples/sushi/models/customers.sql @@ -42,4 +42,4 @@ LEFT JOIN ( ON o.customer_id = m.customer_id LEFT JOIN raw.demographics AS d ON o.customer_id = d.customer_id -WHERE sushi.orders.customer_id > 0 \ No newline at end of file +WHERE o.customer_id > 0 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 1a674dea72..bf86114956 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ dependencies = [ "requests", "rich[jupyter]", "ruamel.yaml", - "sqlglot[rs]~=27.28.0", + "sqlglot[rs]~=28.6.0", "tenacity", "time-machine", "json-stream" diff --git a/sqlmesh/core/config/connection.py b/sqlmesh/core/config/connection.py index 4e11fc626f..9e3a210e5e 100644 --- a/sqlmesh/core/config/connection.py +++ b/sqlmesh/core/config/connection.py @@ -2334,7 +2334,7 @@ def init(cursor: t.Any) -> None: for tpe in subclasses( __name__, ConnectionConfig, - exclude=(ConnectionConfig, BaseDuckDBConnectionConfig), + exclude={ConnectionConfig, BaseDuckDBConnectionConfig}, ) } @@ -2343,7 +2343,7 @@ def init(cursor: t.Any) -> None: for tpe in subclasses( __name__, ConnectionConfig, - exclude=(ConnectionConfig, BaseDuckDBConnectionConfig), + exclude={ConnectionConfig, BaseDuckDBConnectionConfig}, ) } @@ -2355,7 +2355,7 @@ def init(cursor: t.Any) -> None: for tpe in subclasses( __name__, ConnectionConfig, - exclude=(ConnectionConfig, BaseDuckDBConnectionConfig), + exclude={ConnectionConfig, BaseDuckDBConnectionConfig}, ) } diff --git a/sqlmesh/core/config/scheduler.py b/sqlmesh/core/config/scheduler.py index 69adcafe70..970defee62 100644 --- a/sqlmesh/core/config/scheduler.py +++ b/sqlmesh/core/config/scheduler.py @@ -146,7 +146,7 @@ def get_default_catalog_per_gateway(self, context: GenericContext) -> t.Dict[str SCHEDULER_CONFIG_TO_TYPE = { tpe.all_field_infos()["type_"].default: tpe - for tpe in subclasses(__name__, BaseConfig, exclude=(BaseConfig,)) + for tpe in subclasses(__name__, BaseConfig, exclude={BaseConfig}) } diff --git a/sqlmesh/core/engine_adapter/base.py b/sqlmesh/core/engine_adapter/base.py index a7a8e2f707..e2dbb51459 100644 --- a/sqlmesh/core/engine_adapter/base.py +++ b/sqlmesh/core/engine_adapter/base.py @@ -2861,7 +2861,7 @@ def _order_projections_and_filter( return query query = t.cast(exp.Query, query.copy()) - with_ = query.args.pop("with", None) + with_ = query.args.pop("with_", None) select_exprs: t.List[exp.Expression] = [ exp.column(c, quoted=True) for c in target_columns_to_types @@ -2877,7 +2877,7 @@ def _order_projections_and_filter( query = query.where(where, copy=False) if with_: - query.set("with", with_) + query.set("with_", with_) return query diff --git a/sqlmesh/core/linter/rules/builtin.py b/sqlmesh/core/linter/rules/builtin.py index c28822a154..4547ac0528 100644 --- a/sqlmesh/core/linter/rules/builtin.py +++ b/sqlmesh/core/linter/rules/builtin.py @@ -318,4 +318,4 @@ def check_model(self, model: Model) -> t.Optional[RuleViolation]: return None -BUILTIN_RULES = RuleSet(subclasses(__name__, Rule, (Rule,))) +BUILTIN_RULES = RuleSet(subclasses(__name__, Rule, exclude={Rule})) diff --git a/sqlmesh/core/loader.py b/sqlmesh/core/loader.py index a43f5f28ff..4b7b1bac02 100644 --- a/sqlmesh/core/loader.py +++ b/sqlmesh/core/loader.py @@ -840,7 +840,7 @@ def _load_linting_rules(self) -> RuleSet: if os.path.getsize(path): self._track_file(path) module = import_python_file(path, self.config_path) - module_rules = subclasses(module.__name__, Rule, (Rule,)) + module_rules = subclasses(module.__name__, Rule, exclude={Rule}) for user_rule in module_rules: user_rules[user_rule.name] = user_rule diff --git a/sqlmesh/core/metric/rewriter.py b/sqlmesh/core/metric/rewriter.py index 3519a77e68..bbdc6c6135 100644 --- a/sqlmesh/core/metric/rewriter.py +++ b/sqlmesh/core/metric/rewriter.py @@ -57,7 +57,7 @@ def _build_sources(self, projections: t.List[exp.Expression]) -> SourceAggsAndJo return sources def _expand(self, select: exp.Select) -> None: - base = select.args["from"].this.find(exp.Table) + base = select.args["from_"].this.find(exp.Table) base_alias = base.alias_or_name base_name = exp.table_name(base) diff --git a/sqlmesh/core/model/definition.py b/sqlmesh/core/model/definition.py index 9154b4ec2f..831b52a44e 100644 --- a/sqlmesh/core/model/definition.py +++ b/sqlmesh/core/model/definition.py @@ -753,7 +753,7 @@ def ctas_query(self, **render_kwarg: t.Any) -> exp.Query: query = self.render_query_or_raise(**render_kwarg).limit(0) for select_or_set_op in query.find_all(exp.Select, exp.SetOperation): - if isinstance(select_or_set_op, exp.Select) and select_or_set_op.args.get("from"): + if isinstance(select_or_set_op, exp.Select) and select_or_set_op.args.get("from_"): select_or_set_op.where(exp.false(), copy=False) if self.managed_columns: diff --git a/sqlmesh/core/test/definition.py b/sqlmesh/core/test/definition.py index 8694ec6024..2a838753de 100644 --- a/sqlmesh/core/test/definition.py +++ b/sqlmesh/core/test/definition.py @@ -711,7 +711,7 @@ def runTest(self) -> None: query = self._render_model_query() sql = query.sql(self._test_adapter_dialect, pretty=self.engine_adapter._pretty_sql) - with_clause = query.args.get("with") + with_clause = query.args.get("with_") if with_clause: self.test_ctes( @@ -905,7 +905,7 @@ def generate_test( if isinstance(model, SqlModel): assert isinstance(test, SqlModelTest) model_query = test._render_model_query() - with_clause = model_query.args.get("with") + with_clause = model_query.args.get("with_") if with_clause and include_ctes: ctes = {} diff --git a/tests/core/engine_adapter/test_bigquery.py b/tests/core/engine_adapter/test_bigquery.py index 047613e47a..9a6bc7d851 100644 --- a/tests/core/engine_adapter/test_bigquery.py +++ b/tests/core/engine_adapter/test_bigquery.py @@ -1245,7 +1245,7 @@ def test_sync_grants_config(make_mocked_engine_adapter: t.Callable, mocker: Mock executed_sql = executed_query.sql(dialect="bigquery") expected_sql = ( "SELECT privilege_type, grantee FROM `project`.`region-us-central1`.`INFORMATION_SCHEMA.OBJECT_PRIVILEGES` AS OBJECT_PRIVILEGES " - "WHERE object_schema = 'dataset' AND object_name = 'test_table' AND SPLIT(grantee, ':')[OFFSET(1)] <> session_user()" + "WHERE object_schema = 'dataset' AND object_name = 'test_table' AND SPLIT(grantee, ':')[OFFSET(1)] <> SESSION_USER()" ) assert executed_sql == expected_sql @@ -1306,7 +1306,7 @@ def test_sync_grants_config_with_overlaps( executed_sql = executed_query.sql(dialect="bigquery") expected_sql = ( "SELECT privilege_type, grantee FROM `project`.`region-us-central1`.`INFORMATION_SCHEMA.OBJECT_PRIVILEGES` AS OBJECT_PRIVILEGES " - "WHERE object_schema = 'dataset' AND object_name = 'test_table' AND SPLIT(grantee, ':')[OFFSET(1)] <> session_user()" + "WHERE object_schema = 'dataset' AND object_name = 'test_table' AND SPLIT(grantee, ':')[OFFSET(1)] <> SESSION_USER()" ) assert executed_sql == expected_sql diff --git a/tests/core/engine_adapter/test_clickhouse.py b/tests/core/engine_adapter/test_clickhouse.py index 188ae7f394..54fbe7c323 100644 --- a/tests/core/engine_adapter/test_clickhouse.py +++ b/tests/core/engine_adapter/test_clickhouse.py @@ -327,16 +327,16 @@ def build_properties_sql(storage_format="", order_by="", primary_key="", propert assert ( build_properties_sql( - order_by="ORDER_BY = 'timestamp with fill to toStartOfDay(toDateTime64(\\'2024-07-11\\', 3)) step toIntervalDay(1) interpolate(price as price)'," + order_by="ORDER_BY = 'timestamp with fill to dateTrunc(\\'DAY\\', toDateTime64(\\'2024-07-11\\', 3)) step toIntervalDay(1) interpolate(price as price)'," ) - == "ENGINE=MergeTree ORDER BY (timestamp WITH FILL TO toStartOfDay(toDateTime64('2024-07-11', 3)) STEP toIntervalDay(1) INTERPOLATE (price AS price))" + == "ENGINE=MergeTree ORDER BY (timestamp WITH FILL TO dateTrunc('DAY', toDateTime64('2024-07-11', 3)) STEP toIntervalDay(1) INTERPOLATE (price AS price))" ) assert ( build_properties_sql( - order_by="ORDER_BY = (\"a\", 'timestamp with fill to toStartOfDay(toDateTime64(\\'2024-07-11\\', 3)) step toIntervalDay(1) interpolate(price as price)')," + order_by="ORDER_BY = (\"a\", 'timestamp with fill to dateTrunc(\\'DAY\\', toDateTime64(\\'2024-07-11\\', 3)) step toIntervalDay(1) interpolate(price as price)')," ) - == "ENGINE=MergeTree ORDER BY (\"a\", timestamp WITH FILL TO toStartOfDay(toDateTime64('2024-07-11', 3)) STEP toIntervalDay(1) INTERPOLATE (price AS price))" + == "ENGINE=MergeTree ORDER BY (\"a\", timestamp WITH FILL TO dateTrunc('DAY', toDateTime64('2024-07-11', 3)) STEP toIntervalDay(1) INTERPOLATE (price AS price))" ) assert ( @@ -368,7 +368,7 @@ def test_partitioned_by_expr(make_mocked_engine_adapter: t.Callable): assert ( model.partitioned_by[0].sql("clickhouse") - == """toMonday(CAST("ds" AS DateTime64(9, 'UTC')))""" + == """dateTrunc('WEEK', CAST("ds" AS DateTime64(9, 'UTC')))""" ) # user specifies without time column, unknown time column type @@ -393,7 +393,7 @@ def test_partitioned_by_expr(make_mocked_engine_adapter: t.Callable): ) assert [p.sql("clickhouse") for p in model.partitioned_by] == [ - """toMonday(CAST("ds" AS DateTime64(9, 'UTC')))""", + """dateTrunc('WEEK', CAST("ds" AS DateTime64(9, 'UTC')))""", '"x"', ] @@ -417,7 +417,7 @@ def test_partitioned_by_expr(make_mocked_engine_adapter: t.Callable): ) ) - assert model.partitioned_by[0].sql("clickhouse") == 'toMonday("ds")' + assert model.partitioned_by[0].sql("clickhouse") == """dateTrunc('WEEK', "ds")""" # user doesn't specify, non-conformable time column type model = load_sql_based_model( @@ -441,7 +441,7 @@ def test_partitioned_by_expr(make_mocked_engine_adapter: t.Callable): assert ( model.partitioned_by[0].sql("clickhouse") - == """CAST(toMonday(CAST("ds" AS DateTime64(9, 'UTC'))) AS String)""" + == """CAST(dateTrunc('WEEK', CAST("ds" AS DateTime64(9, 'UTC'))) AS String)""" ) # user specifies partitioned_by with time column @@ -993,7 +993,7 @@ def test_insert_overwrite_by_condition_replace_partitioned( temp_table_mock.return_value = make_temp_table_name(table_name, "abcd") fetchone_mock = mocker.patch("sqlmesh.core.engine_adapter.ClickhouseEngineAdapter.fetchone") - fetchone_mock.return_value = "toMonday(ds)" + fetchone_mock.return_value = "dateTrunc('WEEK', ds)" insert_table_name = make_temp_table_name("new_records", "abcd") existing_table_name = make_temp_table_name("existing_records", "abcd") @@ -1069,7 +1069,7 @@ def test_insert_overwrite_by_condition_where_partitioned( temp_table_mock.return_value = make_temp_table_name(table_name, "abcd") fetchone_mock = mocker.patch("sqlmesh.core.engine_adapter.ClickhouseEngineAdapter.fetchone") - fetchone_mock.return_value = "toMonday(ds)" + fetchone_mock.return_value = "dateTrunc('WEEK', ds)" fetchall_mock = mocker.patch("sqlmesh.core.engine_adapter.ClickhouseEngineAdapter.fetchall") fetchall_mock.side_effect = [ @@ -1175,7 +1175,7 @@ def test_insert_overwrite_by_condition_by_key_partitioned( temp_table_mock.return_value = make_temp_table_name(table_name, "abcd") fetchone_mock = mocker.patch("sqlmesh.core.engine_adapter.ClickhouseEngineAdapter.fetchone") - fetchone_mock.side_effect = ["toMonday(ds)", "toMonday(ds)"] + fetchone_mock.side_effect = ["dateTrunc('WEEK', ds)", "dateTrunc('WEEK', ds)"] fetchall_mock = mocker.patch("sqlmesh.core.engine_adapter.ClickhouseEngineAdapter.fetchall") fetchall_mock.side_effect = [ @@ -1240,7 +1240,7 @@ def test_insert_overwrite_by_condition_inc_by_partition( temp_table_mock.return_value = make_temp_table_name(table_name, "abcd") fetchone_mock = mocker.patch("sqlmesh.core.engine_adapter.ClickhouseEngineAdapter.fetchone") - fetchone_mock.return_value = "toMonday(ds)" + fetchone_mock.return_value = "dateTrunc('WEEK', ds)" fetchall_mock = mocker.patch("sqlmesh.core.engine_adapter.ClickhouseEngineAdapter.fetchall") fetchall_mock.return_value = [("1",), ("2",), ("4",)] diff --git a/tests/core/test_audit.py b/tests/core/test_audit.py index 2ffcbbc4b2..66897ed088 100644 --- a/tests/core/test_audit.py +++ b/tests/core/test_audit.py @@ -397,7 +397,7 @@ def test_no_query(): def test_macro(model: Model): - expected_query = """SELECT * FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN '1970-01-01' AND '1970-01-01') AS "_q_0" WHERE "a" IS NULL""" + expected_query = """SELECT * FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN '1970-01-01' AND '1970-01-01') AS "_0" WHERE "a" IS NULL""" audit = ModelAudit( name="test_audit", @@ -456,7 +456,7 @@ def test_not_null_audit(model: Model): ) assert ( rendered_query_a.sql() - == """SELECT * FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN '1970-01-01' AND '1970-01-01') AS "_q_0" WHERE "a" IS NULL AND TRUE""" + == """SELECT * FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN '1970-01-01' AND '1970-01-01') AS "_0" WHERE "a" IS NULL AND TRUE""" ) rendered_query_a_and_b = model.render_audit_query( @@ -465,7 +465,7 @@ def test_not_null_audit(model: Model): ) assert ( rendered_query_a_and_b.sql() - == """SELECT * FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN '1970-01-01' AND '1970-01-01') AS "_q_0" WHERE ("a" IS NULL OR "b" IS NULL) AND TRUE""" + == """SELECT * FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN '1970-01-01' AND '1970-01-01') AS "_0" WHERE ("a" IS NULL OR "b" IS NULL) AND TRUE""" ) @@ -476,7 +476,7 @@ def test_not_null_audit_default_catalog(model_default_catalog: Model): ) assert ( rendered_query_a.sql() - == """SELECT * FROM (SELECT * FROM "test_catalog"."db"."test_model" AS "test_model" WHERE "ds" BETWEEN '1970-01-01' AND '1970-01-01') AS "_q_0" WHERE "a" IS NULL AND TRUE""" + == """SELECT * FROM (SELECT * FROM "test_catalog"."db"."test_model" AS "test_model" WHERE "ds" BETWEEN '1970-01-01' AND '1970-01-01') AS "_0" WHERE "a" IS NULL AND TRUE""" ) rendered_query_a_and_b = model_default_catalog.render_audit_query( @@ -485,7 +485,7 @@ def test_not_null_audit_default_catalog(model_default_catalog: Model): ) assert ( rendered_query_a_and_b.sql() - == """SELECT * FROM (SELECT * FROM "test_catalog"."db"."test_model" AS "test_model" WHERE "ds" BETWEEN '1970-01-01' AND '1970-01-01') AS "_q_0" WHERE ("a" IS NULL OR "b" IS NULL) AND TRUE""" + == """SELECT * FROM (SELECT * FROM "test_catalog"."db"."test_model" AS "test_model" WHERE "ds" BETWEEN '1970-01-01' AND '1970-01-01') AS "_0" WHERE ("a" IS NULL OR "b" IS NULL) AND TRUE""" ) @@ -495,7 +495,7 @@ def test_unique_values_audit(model: Model): ) assert ( rendered_query_a.sql() - == 'SELECT * FROM (SELECT ROW_NUMBER() OVER (PARTITION BY "a" ORDER BY "a") AS "rank_a" FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN \'1970-01-01\' AND \'1970-01-01\') AS "_q_0" WHERE "b" IS NULL) AS "_q_1" WHERE "rank_a" > 1' + == 'SELECT * FROM (SELECT ROW_NUMBER() OVER (PARTITION BY "a" ORDER BY "a") AS "rank_a" FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN \'1970-01-01\' AND \'1970-01-01\') AS "_0" WHERE "b" IS NULL) AS "_1" WHERE "rank_a" > 1' ) rendered_query_a_and_b = model.render_audit_query( @@ -503,7 +503,7 @@ def test_unique_values_audit(model: Model): ) assert ( rendered_query_a_and_b.sql() - == 'SELECT * FROM (SELECT ROW_NUMBER() OVER (PARTITION BY "a" ORDER BY "a") AS "rank_a", ROW_NUMBER() OVER (PARTITION BY "b" ORDER BY "b") AS "rank_b" FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN \'1970-01-01\' AND \'1970-01-01\') AS "_q_0" WHERE TRUE) AS "_q_1" WHERE "rank_a" > 1 OR "rank_b" > 1' + == 'SELECT * FROM (SELECT ROW_NUMBER() OVER (PARTITION BY "a" ORDER BY "a") AS "rank_a", ROW_NUMBER() OVER (PARTITION BY "b" ORDER BY "b") AS "rank_b" FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN \'1970-01-01\' AND \'1970-01-01\') AS "_0" WHERE TRUE) AS "_1" WHERE "rank_a" > 1 OR "rank_b" > 1' ) @@ -515,7 +515,7 @@ def test_accepted_values_audit(model: Model): ) assert ( rendered_query.sql() - == """SELECT * FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN '1970-01-01' AND '1970-01-01') AS "_q_0" WHERE NOT "a" IN ('value_a', 'value_b') AND TRUE""" + == """SELECT * FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN '1970-01-01' AND '1970-01-01') AS "_0" WHERE NOT "a" IN ('value_a', 'value_b') AND TRUE""" ) @@ -526,7 +526,7 @@ def test_number_of_rows_audit(model: Model): ) assert ( rendered_query.sql() - == """SELECT COUNT(*) FROM (SELECT 1 FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN '1970-01-01' AND '1970-01-01') AS "_q_0" WHERE TRUE LIMIT 0 + 1) AS "_q_1" HAVING COUNT(*) <= 0""" + == """SELECT COUNT(*) FROM (SELECT 1 FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN '1970-01-01' AND '1970-01-01') AS "_0" WHERE TRUE LIMIT 0 + 1) AS "_1" HAVING COUNT(*) <= 0""" ) @@ -537,7 +537,7 @@ def test_forall_audit(model: Model): ) assert ( rendered_query_a.sql() - == """SELECT * FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN '1970-01-01' AND '1970-01-01') AS "_q_0" WHERE NOT ("a" >= "b") AND TRUE""" + == """SELECT * FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN '1970-01-01' AND '1970-01-01') AS "_0" WHERE NOT ("a" >= "b") AND TRUE""" ) rendered_query_a = model.render_audit_query( @@ -546,7 +546,7 @@ def test_forall_audit(model: Model): ) assert ( rendered_query_a.sql() - == """SELECT * FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN '1970-01-01' AND '1970-01-01') AS "_q_0" WHERE (NOT ("a" >= "b") OR NOT ("c" + "d" - "e" < 1.0)) AND TRUE""" + == """SELECT * FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN '1970-01-01' AND '1970-01-01') AS "_0" WHERE (NOT ("a" >= "b") OR NOT ("c" + "d" - "e" < 1.0)) AND TRUE""" ) rendered_query_a = model.render_audit_query( @@ -556,7 +556,7 @@ def test_forall_audit(model: Model): ) assert ( rendered_query_a.sql() - == """SELECT * FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN '1970-01-01' AND '1970-01-01') AS "_q_0" WHERE (NOT ("a" >= "b") OR NOT ("c" + "d" - "e" < 1.0)) AND "f" = 42""" + == """SELECT * FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN '1970-01-01' AND '1970-01-01') AS "_0" WHERE (NOT ("a" >= "b") OR NOT ("c" + "d" - "e" < 1.0)) AND "f" = 42""" ) @@ -566,21 +566,21 @@ def test_accepted_range_audit(model: Model): ) assert ( rendered_query.sql() - == 'SELECT * FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN \'1970-01-01\' AND \'1970-01-01\') AS "_q_0" WHERE "a" < 0 AND TRUE' + == 'SELECT * FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN \'1970-01-01\' AND \'1970-01-01\') AS "_0" WHERE "a" < 0 AND TRUE' ) rendered_query = model.render_audit_query( builtin.accepted_range_audit, column=exp.to_column("a"), max_v=100, inclusive=exp.false() ) assert ( rendered_query.sql() - == 'SELECT * FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN \'1970-01-01\' AND \'1970-01-01\') AS "_q_0" WHERE "a" >= 100 AND TRUE' + == 'SELECT * FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN \'1970-01-01\' AND \'1970-01-01\') AS "_0" WHERE "a" >= 100 AND TRUE' ) rendered_query = model.render_audit_query( builtin.accepted_range_audit, column=exp.to_column("a"), min_v=100, max_v=100 ) assert ( rendered_query.sql() - == 'SELECT * FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN \'1970-01-01\' AND \'1970-01-01\') AS "_q_0" WHERE ("a" < 100 OR "a" > 100) AND TRUE' + == 'SELECT * FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN \'1970-01-01\' AND \'1970-01-01\') AS "_0" WHERE ("a" < 100 OR "a" > 100) AND TRUE' ) @@ -591,7 +591,7 @@ def test_at_least_one_audit(model: Model): ) assert ( rendered_query.sql() - == 'SELECT 1 AS "1" FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN \'1970-01-01\' AND \'1970-01-01\') AS "_q_0" WHERE TRUE GROUP BY 1 HAVING COUNT("a") = 0' + == 'SELECT 1 AS "1" FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN \'1970-01-01\' AND \'1970-01-01\') AS "_0" WHERE TRUE GROUP BY 1 HAVING COUNT("a") = 0' ) @@ -603,7 +603,7 @@ def test_mutually_exclusive_ranges_audit(model: Model): ) assert ( rendered_query.sql() - == '''WITH "window_functions" AS (SELECT "a" AS "lower_bound", "a" AS "upper_bound", LEAD("a") OVER (ORDER BY "a", "a") AS "next_lower_bound", ROW_NUMBER() OVER (ORDER BY "a" DESC, "a" DESC) = 1 AS "is_last_record" FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN '1970-01-01' AND '1970-01-01') AS "_q_0" WHERE TRUE), "calc" AS (SELECT *, COALESCE("lower_bound" <= "upper_bound", FALSE) AS "lower_bound_lte_upper_bound", COALESCE("upper_bound" <= "next_lower_bound", "is_last_record", FALSE) AS "upper_bound_lte_next_lower_bound" FROM "window_functions" AS "window_functions"), "validation_errors" AS (SELECT * FROM "calc" AS "calc" WHERE NOT ("lower_bound_lte_upper_bound" AND "upper_bound_lte_next_lower_bound")) SELECT * FROM "validation_errors" AS "validation_errors"''' + == '''WITH "window_functions" AS (SELECT "a" AS "lower_bound", "a" AS "upper_bound", LEAD("a") OVER (ORDER BY "a", "a") AS "next_lower_bound", ROW_NUMBER() OVER (ORDER BY "a" DESC, "a" DESC) = 1 AS "is_last_record" FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN '1970-01-01' AND '1970-01-01') AS "_0" WHERE TRUE), "calc" AS (SELECT *, COALESCE("lower_bound" <= "upper_bound", FALSE) AS "lower_bound_lte_upper_bound", COALESCE("upper_bound" <= "next_lower_bound", "is_last_record", FALSE) AS "upper_bound_lte_next_lower_bound" FROM "window_functions" AS "window_functions"), "validation_errors" AS (SELECT * FROM "calc" AS "calc" WHERE NOT ("lower_bound_lte_upper_bound" AND "upper_bound_lte_next_lower_bound")) SELECT * FROM "validation_errors" AS "validation_errors"''' ) @@ -614,7 +614,7 @@ def test_sequential_values_audit(model: Model): ) assert ( rendered_query.sql() - == '''WITH "windowed" AS (SELECT "a", LAG("a") OVER (ORDER BY "a") AS "prv" FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN '1970-01-01' AND '1970-01-01') AS "_q_0" WHERE TRUE), "validation_errors" AS (SELECT * FROM "windowed" AS "windowed" WHERE NOT ("a" = "prv" + 1)) SELECT * FROM "validation_errors" AS "validation_errors"''' + == '''WITH "windowed" AS (SELECT "a", LAG("a") OVER (ORDER BY "a") AS "prv" FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN '1970-01-01' AND '1970-01-01') AS "_0" WHERE TRUE), "validation_errors" AS (SELECT * FROM "windowed" AS "windowed" WHERE NOT ("a" = "prv" + 1)) SELECT * FROM "validation_errors" AS "validation_errors"''' ) @@ -627,7 +627,7 @@ def test_chi_square_audit(model: Model): ) assert ( rendered_query.sql() - == """WITH "samples" AS (SELECT "a" AS "x_a", "b" AS "x_b" FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN '1970-01-01' AND '1970-01-01') AS "_q_0" WHERE (NOT "a" IS NULL AND NOT "b" IS NULL) AND TRUE), "contingency_table" AS (SELECT "x_a", "x_b", COUNT(*) AS "observed", (SELECT COUNT(*) FROM "samples" AS "t" WHERE "r"."x_a" = "t"."x_a") AS "tot_a", (SELECT COUNT(*) FROM "samples" AS "t" WHERE "r"."x_b" = "t"."x_b") AS "tot_b", (SELECT COUNT(*) FROM "samples" AS "samples") AS "g_t" /* g_t is the grand total */ FROM "samples" AS "r" GROUP BY "x_a", "x_b") SELECT ((SELECT COUNT(DISTINCT "x_a") FROM "contingency_table" AS "contingency_table") - 1) * ((SELECT COUNT(DISTINCT "x_b") FROM "contingency_table" AS "contingency_table") - 1) AS "degrees_of_freedom", SUM(("observed" - ("tot_a" * "tot_b" / "g_t")) * ("observed" - ("tot_a" * "tot_b" / "g_t")) / ("tot_a" * "tot_b" / "g_t")) AS "chi_square" FROM "contingency_table" AS "contingency_table" /* H0: the two variables are independent */ /* H1: the two variables are dependent */ /* if chi_square > critical_value, reject H0 */ /* if chi_square <= critical_value, fail to reject H0 */ HAVING NOT "chi_square" > 9.48773""" + == """WITH "samples" AS (SELECT "a" AS "x_a", "b" AS "x_b" FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN '1970-01-01' AND '1970-01-01') AS "_0" WHERE (NOT "a" IS NULL AND NOT "b" IS NULL) AND TRUE), "contingency_table" AS (SELECT "x_a", "x_b", COUNT(*) AS "observed", (SELECT COUNT(*) FROM "samples" AS "t" WHERE "r"."x_a" = "t"."x_a") AS "tot_a", (SELECT COUNT(*) FROM "samples" AS "t" WHERE "r"."x_b" = "t"."x_b") AS "tot_b", (SELECT COUNT(*) FROM "samples" AS "samples") AS "g_t" /* g_t is the grand total */ FROM "samples" AS "r" GROUP BY "x_a", "x_b") SELECT ((SELECT COUNT(DISTINCT "x_a") FROM "contingency_table" AS "contingency_table") - 1) * ((SELECT COUNT(DISTINCT "x_b") FROM "contingency_table" AS "contingency_table") - 1) AS "degrees_of_freedom", SUM(("observed" - ("tot_a" * "tot_b" / "g_t")) * ("observed" - ("tot_a" * "tot_b" / "g_t")) / ("tot_a" * "tot_b" / "g_t")) AS "chi_square" FROM "contingency_table" AS "contingency_table" /* H0: the two variables are independent */ /* H1: the two variables are dependent */ /* if chi_square > critical_value, reject H0 */ /* if chi_square <= critical_value, fail to reject H0 */ HAVING NOT "chi_square" > 9.48773""" ) @@ -639,7 +639,7 @@ def test_pattern_audits(model: Model): ) assert ( rendered_query.sql() - == """SELECT * FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN \'1970-01-01\' AND \'1970-01-01\') AS "_q_0" WHERE (NOT REGEXP_LIKE("a", \'^\\d.*\') AND NOT REGEXP_LIKE("a", \'.*!$\')) AND TRUE""" + == """SELECT * FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN \'1970-01-01\' AND \'1970-01-01\') AS "_0" WHERE (NOT REGEXP_LIKE("a", \'^\\d.*\') AND NOT REGEXP_LIKE("a", \'.*!$\')) AND TRUE""" ) rendered_query = model.render_audit_query( @@ -649,7 +649,7 @@ def test_pattern_audits(model: Model): ) assert ( rendered_query.sql() - == """SELECT * FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN \'1970-01-01\' AND \'1970-01-01\') AS "_q_0" WHERE (REGEXP_LIKE("a", \'^\\d.*\') OR REGEXP_LIKE("a", \'.*!$\')) AND TRUE""" + == """SELECT * FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN \'1970-01-01\' AND \'1970-01-01\') AS "_0" WHERE (REGEXP_LIKE("a", \'^\\d.*\') OR REGEXP_LIKE("a", \'.*!$\')) AND TRUE""" ) rendered_query = model.render_audit_query( @@ -659,7 +659,7 @@ def test_pattern_audits(model: Model): ) assert ( rendered_query.sql() - == """SELECT * FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN \'1970-01-01\' AND \'1970-01-01\') AS "_q_0" WHERE (NOT "a" LIKE \'jim%\' AND NOT "a" LIKE \'pam%\') AND TRUE""" + == """SELECT * FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN \'1970-01-01\' AND \'1970-01-01\') AS "_0" WHERE (NOT "a" LIKE \'jim%\' AND NOT "a" LIKE \'pam%\') AND TRUE""" ) rendered_query = model.render_audit_query( @@ -669,7 +669,7 @@ def test_pattern_audits(model: Model): ) assert ( rendered_query.sql() - == """SELECT * FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN \'1970-01-01\' AND \'1970-01-01\') AS "_q_0" WHERE ("a" LIKE \'jim%\' OR "a" LIKE \'pam%\') AND TRUE""" + == """SELECT * FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN \'1970-01-01\' AND \'1970-01-01\') AS "_0" WHERE ("a" LIKE \'jim%\' OR "a" LIKE \'pam%\') AND TRUE""" ) @@ -814,7 +814,7 @@ def test_string_length_between_audit(model: Model): ) assert ( rendered_query.sql() - == """SELECT * FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN '1970-01-01' AND '1970-01-01') AS "_q_0" WHERE (LENGTH("x") < 1 OR LENGTH("x") > 5) AND TRUE""" + == """SELECT * FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN '1970-01-01' AND '1970-01-01') AS "_0" WHERE (LENGTH("x") < 1 OR LENGTH("x") > 5) AND TRUE""" ) @@ -824,7 +824,7 @@ def test_not_constant_audit(model: Model): ) assert ( rendered_query.sql() - == """SELECT 1 AS "1" FROM (SELECT COUNT(DISTINCT "x") AS "t_cardinality" FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN '1970-01-01' AND '1970-01-01') AS "_q_0" WHERE "x" > 1) AS "r" WHERE "r"."t_cardinality" <= 1""" + == """SELECT 1 AS "1" FROM (SELECT COUNT(DISTINCT "x") AS "t_cardinality" FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN '1970-01-01' AND '1970-01-01') AS "_0" WHERE "x" > 1) AS "r" WHERE "r"."t_cardinality" <= 1""" ) @@ -836,7 +836,7 @@ def test_condition_with_macro_var(model: Model): ) assert ( rendered_query.sql(dialect="duckdb") - == """SELECT * FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN '1970-01-01' AND '1970-01-01') AS "_q_0" WHERE "x" IS NULL AND "dt" BETWEEN CAST('1970-01-01 00:00:00+00:00' AS TIMESTAMPTZ) AND CAST('1970-01-01 23:59:59.999999+00:00' AS TIMESTAMPTZ)""" + == """SELECT * FROM (SELECT * FROM "db"."test_model" AS "test_model" WHERE "ds" BETWEEN '1970-01-01' AND '1970-01-01') AS "_0" WHERE "x" IS NULL AND "dt" BETWEEN CAST('1970-01-01 00:00:00+00:00' AS TIMESTAMPTZ) AND CAST('1970-01-01 23:59:59.999999+00:00' AS TIMESTAMPTZ)""" ) @@ -907,7 +907,7 @@ def test_load_inline_audits(assert_exp_eq): def test_model_inline_audits(sushi_context: Context): model_name = "sushi.waiter_names" - expected_query = 'SELECT * FROM (SELECT * FROM "memory"."sushi"."waiter_names" AS "waiter_names") AS "_q_0" WHERE "id" < 0' + expected_query = 'SELECT * FROM (SELECT * FROM "memory"."sushi"."waiter_names" AS "waiter_names") AS "_0" WHERE "id" < 0' model = sushi_context.get_snapshot(model_name, raise_if_missing=True).node assert isinstance(model, SeedModel) diff --git a/tests/core/test_context.py b/tests/core/test_context.py index 54b8cd891a..959bedfc00 100644 --- a/tests/core/test_context.py +++ b/tests/core/test_context.py @@ -1985,7 +1985,7 @@ def access_adapter(evaluator): assert ( model.pre_statements[0].sql() - == "@IF(@runtime_stage IN ('evaluating', 'creating'), SET VARIABLE stats_model_start = NOW())" + == "@IF(@runtime_stage IN ('evaluating', 'creating'), SET stats_model_start = NOW())" ) assert ( model.post_statements[0].sql() @@ -2337,13 +2337,13 @@ def test_plan_audit_intervals(tmp_path: pathlib.Path, caplog): # Case 1: The timestamp audit should be in the inclusive range ['2025-02-01 00:00:00', '2025-02-01 23:59:59.999999'] assert ( - f"""SELECT COUNT(*) FROM (SELECT "timestamp_id" AS "timestamp_id" FROM (SELECT * FROM "sqlmesh__sqlmesh_audit"."sqlmesh_audit__timestamp_example__{timestamp_snapshot.version}" AS "sqlmesh_audit__timestamp_example__{timestamp_snapshot.version}" WHERE "timestamp_id" BETWEEN CAST('2025-02-01 00:00:00' AS TIMESTAMP) AND CAST('2025-02-01 23:59:59.999999' AS TIMESTAMP)) AS "_q_0" WHERE TRUE GROUP BY "timestamp_id" HAVING COUNT(*) > 1) AS "audit\"""" + f"""SELECT COUNT(*) FROM (SELECT "timestamp_id" AS "timestamp_id" FROM (SELECT * FROM "sqlmesh__sqlmesh_audit"."sqlmesh_audit__timestamp_example__{timestamp_snapshot.version}" AS "sqlmesh_audit__timestamp_example__{timestamp_snapshot.version}" WHERE "timestamp_id" BETWEEN CAST('2025-02-01 00:00:00' AS TIMESTAMP) AND CAST('2025-02-01 23:59:59.999999' AS TIMESTAMP)) AS "_0" WHERE TRUE GROUP BY "timestamp_id" HAVING COUNT(*) > 1) AS "audit\"""" in caplog.text ) # Case 2: The date audit should be in the inclusive range ['2025-02-01', '2025-02-01'] assert ( - f"""SELECT COUNT(*) FROM (SELECT "date_id" AS "date_id" FROM (SELECT * FROM "sqlmesh__sqlmesh_audit"."sqlmesh_audit__date_example__{date_snapshot.version}" AS "sqlmesh_audit__date_example__{date_snapshot.version}" WHERE "date_id" BETWEEN CAST('2025-02-01' AS DATE) AND CAST('2025-02-01' AS DATE)) AS "_q_0" WHERE TRUE GROUP BY "date_id" HAVING COUNT(*) > 1) AS "audit\"""" + f"""SELECT COUNT(*) FROM (SELECT "date_id" AS "date_id" FROM (SELECT * FROM "sqlmesh__sqlmesh_audit"."sqlmesh_audit__date_example__{date_snapshot.version}" AS "sqlmesh_audit__date_example__{date_snapshot.version}" WHERE "date_id" BETWEEN CAST('2025-02-01' AS DATE) AND CAST('2025-02-01' AS DATE)) AS "_0" WHERE TRUE GROUP BY "date_id" HAVING COUNT(*) > 1) AS "audit\"""" in caplog.text ) diff --git a/tests/core/test_model.py b/tests/core/test_model.py index f9ef97ecc0..cfcb843739 100644 --- a/tests/core/test_model.py +++ b/tests/core/test_model.py @@ -464,10 +464,10 @@ def test_model_qualification(tmp_path: Path): ctx.upsert_model(load_sql_based_model(expressions)) ctx.plan_builder("dev") - assert ( - """Column '"a"' could not be resolved for model '"db"."table"', the column may not exist or is ambiguous.""" - in mock_logger.call_args[0][0] - ) + warning_msg = mock_logger.call_args[0][0] + assert "ambiguousorinvalidcolumn:" in warning_msg + assert "could not be resolved" in warning_msg + assert "db.table" in warning_msg @use_terminal_console @@ -3650,7 +3650,7 @@ def test_model_ctas_query(): assert ( load_sql_based_model(expressions, dialect="bigquery").ctas_query().sql() - == 'WITH RECURSIVE "a" AS (SELECT * FROM (SELECT * FROM (SELECT * FROM "x" AS "x" WHERE FALSE) AS "_q_0" WHERE FALSE) AS "_q_1" WHERE FALSE), "b" AS (SELECT * FROM "a" AS "a" WHERE FALSE UNION ALL SELECT * FROM "a" AS "a" WHERE FALSE) SELECT * FROM "b" AS "b" WHERE FALSE LIMIT 0' + == 'WITH RECURSIVE "a" AS (SELECT * FROM (SELECT * FROM (SELECT * FROM "x" AS "x" WHERE FALSE) AS "_0" WHERE FALSE) AS "_1" WHERE FALSE), "b" AS (SELECT * FROM "a" AS "a" WHERE FALSE UNION ALL SELECT * FROM "a" AS "a" WHERE FALSE) SELECT * FROM "b" AS "b" WHERE FALSE LIMIT 0' ) expressions = d.parse( @@ -3671,7 +3671,7 @@ def test_model_ctas_query(): assert ( load_sql_based_model(expressions, dialect="bigquery").ctas_query().sql() - == 'WITH RECURSIVE "a" AS (WITH "nested_a" AS (SELECT * FROM (SELECT * FROM (SELECT * FROM "x" AS "x" WHERE FALSE) AS "_q_0" WHERE FALSE) AS "_q_1" WHERE FALSE) SELECT * FROM "nested_a" AS "nested_a" WHERE FALSE), "b" AS (SELECT * FROM "a" AS "a" WHERE FALSE UNION ALL SELECT * FROM "a" AS "a" WHERE FALSE) SELECT * FROM "b" AS "b" WHERE FALSE LIMIT 0' + == 'WITH RECURSIVE "a" AS (WITH "nested_a" AS (SELECT * FROM (SELECT * FROM (SELECT * FROM "x" AS "x" WHERE FALSE) AS "_0" WHERE FALSE) AS "_1" WHERE FALSE) SELECT * FROM "nested_a" AS "nested_a" WHERE FALSE), "b" AS (SELECT * FROM "a" AS "a" WHERE FALSE UNION ALL SELECT * FROM "a" AS "a" WHERE FALSE) SELECT * FROM "b" AS "b" WHERE FALSE LIMIT 0' ) @@ -4995,7 +4995,7 @@ def test_model_session_properties(sushi_context): ) ) assert model.session_properties == { - "query_label": parse_one("[('key1', 'value1'), ('key2', 'value2')]") + "query_label": parse_one("[('key1', 'value1'), ('key2', 'value2')]", dialect="bigquery") } model = load_sql_based_model( diff --git a/tests/dbt/test_model.py b/tests/dbt/test_model.py index 6d100e6aa5..a954f98f41 100644 --- a/tests/dbt/test_model.py +++ b/tests/dbt/test_model.py @@ -626,11 +626,11 @@ def test_load_microbatch_with_ref( context = Context(paths=project_dir) assert ( context.render(microbatch_snapshot_fqn, start="2025-01-01", end="2025-01-10").sql() - == 'SELECT "cola" AS "cola", "ds_source" AS "ds" FROM (SELECT * FROM "local"."my_source"."my_table" AS "my_table" WHERE "ds_source" >= \'2025-01-01 00:00:00+00:00\' AND "ds_source" < \'2025-01-11 00:00:00+00:00\') AS "_q_0"' + == 'SELECT "cola" AS "cola", "ds_source" AS "ds" FROM (SELECT * FROM "local"."my_source"."my_table" AS "my_table" WHERE "ds_source" >= \'2025-01-01 00:00:00+00:00\' AND "ds_source" < \'2025-01-11 00:00:00+00:00\') AS "_0"' ) assert ( context.render(microbatch_two_snapshot_fqn, start="2025-01-01", end="2025-01-10").sql() - == 'SELECT "_q_0"."cola" AS "cola", "_q_0"."ds" AS "ds" FROM (SELECT "microbatch"."cola" AS "cola", "microbatch"."ds" AS "ds" FROM "local"."main"."microbatch" AS "microbatch" WHERE "microbatch"."ds" < \'2025-01-11 00:00:00+00:00\' AND "microbatch"."ds" >= \'2025-01-01 00:00:00+00:00\') AS "_q_0"' + == 'SELECT "_0"."cola" AS "cola", "_0"."ds" AS "ds" FROM (SELECT "microbatch"."cola" AS "cola", "microbatch"."ds" AS "ds" FROM "local"."main"."microbatch" AS "microbatch" WHERE "microbatch"."ds" < \'2025-01-11 00:00:00+00:00\' AND "microbatch"."ds" >= \'2025-01-01 00:00:00+00:00\') AS "_0"' ) diff --git a/tests/dbt/test_transformation.py b/tests/dbt/test_transformation.py index 3b4df916d3..fe6073dfad 100644 --- a/tests/dbt/test_transformation.py +++ b/tests/dbt/test_transformation.py @@ -2213,7 +2213,7 @@ def test_clickhouse_properties(mocker: MockerFixture): ] assert [e.sql("clickhouse") for e in model_to_sqlmesh.partitioned_by] == [ - 'toMonday("ds")', + "dateTrunc('WEEK', \"ds\")", '"partition_col"', ] assert model_to_sqlmesh.storage_format == "MergeTree()" diff --git a/tests/lsp/test_reference_model_column_prefix.py b/tests/lsp/test_reference_model_column_prefix.py index 3cd25a080e..082ee9c8e6 100644 --- a/tests/lsp/test_reference_model_column_prefix.py +++ b/tests/lsp/test_reference_model_column_prefix.py @@ -41,7 +41,7 @@ def test_model_reference_with_column_prefix(): model_refs = get_all_references(lsp_context, URI.from_path(sushi_customers_path), position) - assert len(model_refs) >= 7 + assert len(model_refs) >= 6 # Verify that we have the FROM clause reference assert any(ref.range.start.line == from_clause_range.start.line for ref in model_refs), ( @@ -65,8 +65,8 @@ def test_column_prefix_references_are_found(): # Find all occurrences of sushi.orders in the file ranges = find_ranges_from_regex(read_file, r"sushi\.orders") - # Should find exactly 2: FROM clause and WHERE clause with column prefix - assert len(ranges) == 2, f"Expected 2 occurrences of 'sushi.orders', found {len(ranges)}" + # Should find exactly 1 in FROM clause with column prefix + assert len(ranges) == 1, f"Expected 1 occurrence of 'sushi.orders', found {len(ranges)}" # Verify we have the expected lines line_contents = [read_file[r.start.line].strip() for r in ranges] @@ -76,11 +76,6 @@ def test_column_prefix_references_are_found(): "Should find FROM clause with sushi.orders" ) - # Should find customer_id in WHERE clause with column prefix - assert any("WHERE sushi.orders.customer_id" in content for content in line_contents), ( - "Should find WHERE clause with sushi.orders.customer_id" - ) - def test_quoted_uppercase_table_and_column_references(tmp_path: Path): # Initialize example project in temporary directory with case sensitive normalization diff --git a/tests/lsp/test_reference_model_find_all.py b/tests/lsp/test_reference_model_find_all.py index 7c0077d6cd..cd9c0a3a1c 100644 --- a/tests/lsp/test_reference_model_find_all.py +++ b/tests/lsp/test_reference_model_find_all.py @@ -30,8 +30,8 @@ def test_find_references_for_model_usages(): # Click on the model reference position = Position(line=ranges[0].start.line, character=ranges[0].start.character + 6) references = get_model_find_all_references(lsp_context, URI.from_path(customers_path), position) - assert len(references) >= 7, ( - f"Expected at least 7 references to sushi.orders (including column prefix), found {len(references)}" + assert len(references) >= 6, ( + f"Expected at least 6 references to sushi.orders (including column prefix), found {len(references)}" ) # Verify expected files are present @@ -53,7 +53,7 @@ def test_find_references_for_model_usages(): # Note: customers file has multiple references due to column prefix support expected_ranges = { "orders": [(0, 0, 0, 0)], # the start for the model itself - "customers": [(30, 7, 30, 19), (44, 6, 44, 18)], # FROM clause and WHERE clause + "customers": [(30, 7, 30, 19)], # FROM clause "waiter_revenue_by_day": [(19, 5, 19, 17)], "customer_revenue_lifetime": [(38, 7, 38, 19)], "customer_revenue_by_day": [(33, 5, 33, 17)], diff --git a/tests/utils/test_cache.py b/tests/utils/test_cache.py index 0b6d335446..ed19765b8a 100644 --- a/tests/utils/test_cache.py +++ b/tests/utils/test_cache.py @@ -106,7 +106,7 @@ def test_optimized_query_cache_macro_def_change(tmp_path: Path, mocker: MockerFi assert cache.with_optimized_query(model) assert ( model.render_query_or_raise().sql() - == 'SELECT "_q_0"."a" AS "a" FROM (SELECT 1 AS "a") AS "_q_0" WHERE "_q_0"."a" = 1' + == 'SELECT "_0"."a" AS "a" FROM (SELECT 1 AS "a") AS "_0" WHERE "_0"."a" = 1' ) # Change the filter_ definition @@ -129,5 +129,5 @@ def test_optimized_query_cache_macro_def_change(tmp_path: Path, mocker: MockerFi assert cache.with_optimized_query(new_model) assert ( new_model.render_query_or_raise().sql() - == 'SELECT "_q_0"."a" AS "a" FROM (SELECT 1 AS "a") AS "_q_0" WHERE "_q_0"."a" = 2' + == 'SELECT "_0"."a" AS "a" FROM (SELECT 1 AS "a") AS "_0" WHERE "_0"."a" = 2' ) From 5211a5720e76a2fdf7e03af4fbdc3d58f34eb82d Mon Sep 17 00:00:00 2001 From: Jo <46752250+georgesittas@users.noreply.github.com> Date: Fri, 30 Jan 2026 18:14:20 +0200 Subject: [PATCH 17/39] Chore!: bump sqlglot to v28.7.0 (#5686) --- pyproject.toml | 2 +- sqlmesh/core/state_sync/common.py | 2 +- tests/core/test_snapshot_evaluator.py | 10 ++++++---- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bf86114956..bda22257ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ dependencies = [ "requests", "rich[jupyter]", "ruamel.yaml", - "sqlglot[rs]~=28.6.0", + "sqlglot[rs]~=28.7.0", "tenacity", "time-machine", "json-stream" diff --git a/sqlmesh/core/state_sync/common.py b/sqlmesh/core/state_sync/common.py index 056565b060..2e8c67ac29 100644 --- a/sqlmesh/core/state_sync/common.py +++ b/sqlmesh/core/state_sync/common.py @@ -140,7 +140,7 @@ def all_batch_range(cls) -> ExpiredBatchRange: def _expanded_tuple_comparison( cls, columns: t.List[exp.Column], - values: t.List[exp.Literal], + values: t.List[t.Union[exp.Literal, exp.Neg]], operator: t.Type[exp.Expression], ) -> exp.Expression: """Generate expanded tuple comparison that works across all SQL engines. diff --git a/tests/core/test_snapshot_evaluator.py b/tests/core/test_snapshot_evaluator.py index 9dd645ac15..1413ac81f1 100644 --- a/tests/core/test_snapshot_evaluator.py +++ b/tests/core/test_snapshot_evaluator.py @@ -2131,7 +2131,7 @@ def test_temp_table_includes_schema_for_ignore_changes( model = SqlModel( name="test_schema.test_model", kind=IncrementalByTimeRangeKind( - time_column="a", on_destructive_change=OnDestructiveChange.IGNORE + time_column="ds", on_destructive_change=OnDestructiveChange.IGNORE ), query=parse_one("SELECT c, a FROM tbl WHERE ds BETWEEN @start_ds and @end_ds"), ) @@ -2148,6 +2148,7 @@ def columns(table_name): return { "c": exp.DataType.build("int"), "a": exp.DataType.build("int"), + "ds": exp.DataType.build("timestamp"), } adapter.columns = columns # type: ignore @@ -4321,13 +4322,14 @@ def test_multiple_engine_promotion(mocker: MockerFixture, adapter_mock, make_sna def columns(table_name): return { "a": exp.DataType.build("int"), + "ds": exp.DataType.build("timestamp"), } adapter.columns = columns # type: ignore model = SqlModel( name="test_schema.test_model", - kind=IncrementalByTimeRangeKind(time_column="a"), + kind=IncrementalByTimeRangeKind(time_column="ds"), gateway="secondary", query=parse_one("SELECT a FROM tbl WHERE ds BETWEEN @start_ds and @end_ds"), ) @@ -4350,10 +4352,10 @@ def columns(table_name): cursor_mock.execute.assert_has_calls( [ call( - f'DELETE FROM "sqlmesh__test_schema"."test_schema__test_model__{snapshot.version}" WHERE "a" BETWEEN 2020-01-01 00:00:00+00:00 AND 2020-01-02 23:59:59.999999+00:00' + f'DELETE FROM "sqlmesh__test_schema"."test_schema__test_model__{snapshot.version}" WHERE "ds" BETWEEN CAST(\'2020-01-01 00:00:00\' AS TIMESTAMP) AND CAST(\'2020-01-02 23:59:59.999999\' AS TIMESTAMP)' ), call( - f'INSERT INTO "sqlmesh__test_schema"."test_schema__test_model__{snapshot.version}" ("a") SELECT "a" FROM (SELECT "a" AS "a" FROM "tbl" AS "tbl" WHERE "ds" BETWEEN \'2020-01-01\' AND \'2020-01-02\') AS "_subquery" WHERE "a" BETWEEN 2020-01-01 00:00:00+00:00 AND 2020-01-02 23:59:59.999999+00:00' + f'INSERT INTO "sqlmesh__test_schema"."test_schema__test_model__{snapshot.version}" ("a", "ds") SELECT "a", "ds" FROM (SELECT "a" AS "a" FROM "tbl" AS "tbl" WHERE "ds" BETWEEN \'2020-01-01\' AND \'2020-01-02\') AS "_subquery" WHERE "ds" BETWEEN CAST(\'2020-01-01 00:00:00\' AS TIMESTAMP) AND CAST(\'2020-01-02 23:59:59.999999\' AS TIMESTAMP)' ), ] ) From 20e0e25f6fcd464ceca7bba4c63fa34ec01e508d Mon Sep 17 00:00:00 2001 From: Themis Valtinos <73662635+themisvaltinos@users.noreply.github.com> Date: Tue, 3 Feb 2026 18:53:41 +0200 Subject: [PATCH 18/39] Fix!: Allow init to be walked to track its dependencies (#5688) --- sqlmesh/utils/metaprogramming.py | 15 ++++-- tests/core/test_context.py | 7 ++- tests/utils/test_metaprogramming.py | 73 ++++++++++++++++++++++++++--- 3 files changed, 82 insertions(+), 13 deletions(-) diff --git a/sqlmesh/utils/metaprogramming.py b/sqlmesh/utils/metaprogramming.py index 858e8a50da..753db427f3 100644 --- a/sqlmesh/utils/metaprogramming.py +++ b/sqlmesh/utils/metaprogramming.py @@ -352,7 +352,8 @@ def walk(obj: t.Any, name: str, is_metadata: bool = False) -> None: walk(base, base.__qualname__, is_metadata) for k, v in obj.__dict__.items(): - if k.startswith("__"): + # skip dunder methods bar __init__ as it might contain user defined logic with cross class references + if k.startswith("__") and k != "__init__": continue # Traverse methods in a class to find global references @@ -362,10 +363,14 @@ def walk(obj: t.Any, name: str, is_metadata: bool = False) -> None: if callable(v): # Walk the method if it's part of the object, else it's a global function and we just store it if v.__qualname__.startswith(obj.__qualname__): - for k, v in func_globals(v).items(): - walk(v, k, is_metadata) - else: - walk(v, v.__name__, is_metadata) + try: + for k, v in func_globals(v).items(): + walk(v, k, is_metadata) + except (OSError, TypeError): + # __init__ may come from built-ins or wrapped callables + pass + else: + walk(v, k, is_metadata) elif callable(obj): for k, v in func_globals(obj).items(): walk(v, k, is_metadata) diff --git a/tests/core/test_context.py b/tests/core/test_context.py index 959bedfc00..1ae98ae4b6 100644 --- a/tests/core/test_context.py +++ b/tests/core/test_context.py @@ -1506,6 +1506,8 @@ def test_requirements(copy_to_temp_path: t.Callable): "dev", no_prompts=True, skip_tests=True, skip_backfill=True, auto_apply=True ).environment requirements = {"ipywidgets", "numpy", "pandas", "test_package"} + if IS_WINDOWS: + requirements.add("pendulum") assert environment.requirements["pandas"] == "2.2.2" assert set(environment.requirements) == requirements @@ -1513,7 +1515,10 @@ def test_requirements(copy_to_temp_path: t.Callable): context._excluded_requirements = {"ipywidgets", "ruamel.yaml", "ruamel.yaml.clib"} diff = context.plan_builder("dev", skip_tests=True, skip_backfill=True).build().context_diff assert set(diff.previous_requirements) == requirements - assert set(diff.requirements) == {"numpy", "pandas"} + reqs = {"numpy", "pandas"} + if IS_WINDOWS: + reqs.add("pendulum") + assert set(diff.requirements) == reqs def test_deactivate_automatic_requirement_inference(copy_to_temp_path: t.Callable): diff --git a/tests/utils/test_metaprogramming.py b/tests/utils/test_metaprogramming.py index 19413f68ef..4e55ae490e 100644 --- a/tests/utils/test_metaprogramming.py +++ b/tests/utils/test_metaprogramming.py @@ -83,7 +83,18 @@ class DataClass: x: int +class ReferencedClass: + def __init__(self, value: int): + self.value = value + + def get_value(self) -> int: + return self.value + + class MyClass: + def __init__(self, x: int): + self.helper = ReferencedClass(x * 2) + @staticmethod def foo(): return KLASS_X @@ -95,6 +106,13 @@ def bar(cls): def baz(self): return KLASS_Z + def use_referenced(self, value: int) -> int: + ref = ReferencedClass(value) + return ref.get_value() + + def compute_with_reference(self) -> int: + return self.helper.get_value() + 10 + def other_func(a: int) -> int: import sqlglot @@ -103,7 +121,8 @@ def other_func(a: int) -> int: pd.DataFrame([{"x": 1}]) to_table("y") my_lambda() # type: ignore - return X + a + W + obj = MyClass(a) + return X + a + W + obj.compute_with_reference() @contextmanager @@ -131,7 +150,7 @@ def function_with_custom_decorator(): def main_func(y: int, foo=exp.true(), *, bar=expressions.Literal.number(1) + 2) -> int: """DOC STRING""" sqlglot.parse_one("1") - MyClass() + MyClass(47) DataClass(x=y) normalize_model_name("test" + SQLGLOT_META) fetch_data() @@ -177,6 +196,7 @@ def test_func_globals() -> None: assert func_globals(other_func) == { "X": 1, "W": 0, + "MyClass": MyClass, "my_lambda": my_lambda, "pd": pd, "to_table": to_table, @@ -202,7 +222,7 @@ def test_normalize_source() -> None: == """def main_func(y: int, foo=exp.true(), *, bar=expressions.Literal.number(1) + 2 ): sqlglot.parse_one('1') - MyClass() + MyClass(47) DataClass(x=y) normalize_model_name('test' + SQLGLOT_META) fetch_data() @@ -223,7 +243,8 @@ def closure(z: int): pd.DataFrame([{'x': 1}]) to_table('y') my_lambda() - return X + a + W""" + obj = MyClass(a) + return X + a + W + obj.compute_with_reference()""" ) @@ -252,7 +273,7 @@ def test_serialize_env() -> None: payload="""def main_func(y: int, foo=exp.true(), *, bar=expressions.Literal.number(1) + 2 ): sqlglot.parse_one('1') - MyClass() + MyClass(47) DataClass(x=y) normalize_model_name('test' + SQLGLOT_META) fetch_data() @@ -295,6 +316,9 @@ class DataClass: path="test_metaprogramming.py", payload="""class MyClass: + def __init__(self, x: int): + self.helper = ReferencedClass(x * 2) + @staticmethod def foo(): return KLASS_X @@ -304,7 +328,26 @@ def bar(cls): return KLASS_Y def baz(self): - return KLASS_Z""", + return KLASS_Z + + def use_referenced(self, value: int): + ref = ReferencedClass(value) + return ref.get_value() + + def compute_with_reference(self): + return self.helper.get_value() + 10""", + ), + "ReferencedClass": Executable( + kind=ExecutableKind.DEFINITION, + name="ReferencedClass", + path="test_metaprogramming.py", + payload="""class ReferencedClass: + + def __init__(self, value: int): + self.value = value + + def get_value(self): + return self.value""", ), "dataclass": Executable( payload="from dataclasses import dataclass", kind=ExecutableKind.IMPORT @@ -341,7 +384,8 @@ def sample_context_manager(): pd.DataFrame([{'x': 1}]) to_table('y') my_lambda() - return X + a + W""", + obj = MyClass(a) + return X + a + W + obj.compute_with_reference()""", ), "sample_context_manager": Executable( payload="""@contextmanager @@ -424,6 +468,21 @@ def function_with_custom_decorator(): assert all(is_metadata for (_, is_metadata) in env.values()) assert serialized_env == expected_env + # Check that class references inside init are captured + init_globals = func_globals(MyClass.__init__) + assert "ReferencedClass" in init_globals + + env = {} + build_env(other_func, env=env, name="other_func_test", path=path) + serialized_env = serialize_env(env, path=path) + + assert "MyClass" in serialized_env + assert "ReferencedClass" in serialized_env + + prepared_env = prepare_env(serialized_env) + result = eval("other_func_test(2)", prepared_env) + assert result == 17 + def test_serialize_env_with_enum_import_appearing_in_two_functions() -> None: path = Path("tests/utils") From 1274484e7c49e4201f8b9235fca6f6e3f28ae80d Mon Sep 17 00:00:00 2001 From: Jo <46752250+georgesittas@users.noreply.github.com> Date: Wed, 4 Feb 2026 17:21:36 +0200 Subject: [PATCH 19/39] Chore!: bump sqlglot to v28.10.0 (#5691) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index bda22257ab..6cca104991 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ dependencies = [ "requests", "rich[jupyter]", "ruamel.yaml", - "sqlglot[rs]~=28.7.0", + "sqlglot[rs]~=28.10.0", "tenacity", "time-machine", "json-stream" From 0503faee7bf1c9a34708f86acd8cfddb77efa66f Mon Sep 17 00:00:00 2001 From: Giorgos Michas Date: Tue, 10 Feb 2026 20:19:29 +0200 Subject: [PATCH 20/39] chore: tsql always use base parser for IF (#5694) --- sqlmesh/core/dialect.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sqlmesh/core/dialect.py b/sqlmesh/core/dialect.py index 72115fc4a3..c0a48326f2 100644 --- a/sqlmesh/core/dialect.py +++ b/sqlmesh/core/dialect.py @@ -12,7 +12,7 @@ from sqlglot import Dialect, Generator, ParseError, Parser, Tokenizer, TokenType, exp from sqlglot.dialects.dialect import DialectType -from sqlglot.dialects import DuckDB, Snowflake +from sqlglot.dialects import DuckDB, Snowflake, TSQL import sqlglot.dialects.athena as athena from sqlglot.helper import seq_get from sqlglot.optimizer.normalize_identifiers import normalize_identifiers @@ -1101,6 +1101,7 @@ def extend_sqlglot() -> None: _override(Parser, _parse_value) _override(Parser, _parse_lambda) _override(Parser, _parse_types) + _override(TSQL.Parser, Parser._parse_if) _override(Parser, _parse_if) _override(Parser, _parse_id_var) _override(Parser, _warn_unsupported) From 4fcc8a3513ae7699e6affe5f25735ea3500b6e15 Mon Sep 17 00:00:00 2001 From: Nico Becker <70146154+ncbkr@users.noreply.github.com> Date: Fri, 13 Feb 2026 20:10:21 +0100 Subject: [PATCH 21/39] Chore: Upgrade fastapi from 0.115.5 to 0.120.1 (#5699) --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6cca104991..9f24403564 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,7 +126,7 @@ snowflake = [ ] trino = ["trino"] web = [ - "fastapi==0.115.5", + "fastapi==0.120.1", "watchfiles>=0.19.0", "uvicorn[standard]==0.22.0", "sse-starlette>=0.2.2", @@ -134,7 +134,7 @@ web = [ ] lsp = [ # Duplicate of web - "fastapi==0.115.5", + "fastapi==0.120.1", "watchfiles>=0.19.0", # "uvicorn[standard]==0.22.0", "sse-starlette>=0.2.2", From 67d589447c0a4917d9208e76a9637a23d4b0c669 Mon Sep 17 00:00:00 2001 From: Themis Valtinos <73662635+themisvaltinos@users.noreply.github.com> Date: Tue, 17 Feb 2026 21:21:23 +0200 Subject: [PATCH 22/39] Chore: Unpin cryptography (#5704) --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9f24403564..81df00a373 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,7 @@ dev = [ "agate", "beautifulsoup4", "clickhouse-connect", - "cryptography<46.0.0", + "cryptography", "databricks-sql-connector", "dbt-bigquery", "dbt-core", @@ -120,7 +120,7 @@ postgres = ["psycopg2"] redshift = ["redshift_connector"] slack = ["slack_sdk"] snowflake = [ - "cryptography<46.0.0", + "cryptography", "snowflake-connector-python[pandas,secure-local-storage]", "snowflake-snowpark-python", ] From d8d653fa9ddffcd36e39e25bc31ff78d741e52d8 Mon Sep 17 00:00:00 2001 From: Themis Valtinos <73662635+themisvaltinos@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:45:39 +0200 Subject: [PATCH 23/39] Chore: Fix dbt 1.3 installation script (#5705) --- Makefile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Makefile b/Makefile index 611b179eba..e7a78de472 100644 --- a/Makefile +++ b/Makefile @@ -59,6 +59,10 @@ install-dev-dbt-%: echo "Applying overrides for dbt 1.5.0"; \ $(PIP) install 'dbt-databricks==1.5.6' 'numpy<2' --reinstall; \ fi; \ + if [ "$$version" = "1.3.0" ]; then \ + echo "Applying overrides for dbt $$version - upgrading google-cloud-bigquery"; \ + $(PIP) install 'google-cloud-bigquery>=3.0.0' --upgrade; \ + fi; \ mv pyproject.toml.backup pyproject.toml; \ echo "Restored original pyproject.toml" From ab92e3fc9d3c8ab1305a477bc68ed0669356abe5 Mon Sep 17 00:00:00 2001 From: Jo <46752250+georgesittas@users.noreply.github.com> Date: Mon, 2 Mar 2026 20:11:57 +0200 Subject: [PATCH 24/39] Chore: update blueprint docs (#5714) --- docs/concepts/models/sql_models.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/concepts/models/sql_models.md b/docs/concepts/models/sql_models.md index 28bf0fbe78..217cd7a6a2 100644 --- a/docs/concepts/models/sql_models.md +++ b/docs/concepts/models/sql_models.md @@ -149,7 +149,8 @@ MODEL ( SELECT @field_a, - @{field_b} AS field_b + @{field_b} AS field_b, + @'prefix_@{field_a}_suffix' AS literal_example FROM @customer.some_source ``` @@ -163,8 +164,9 @@ MODEL ( ); SELECT - 'x', - y AS field_b + x, + y AS field_b, + 'prefix_x_suffix' AS literal_example FROM customer1.some_source -- This uses the second variable mapping @@ -174,14 +176,13 @@ MODEL ( ); SELECT - 'z', - w AS field_b + z, + w AS field_b, + 'prefix_z_suffix' AS literal_example FROM customer2.some_source ``` -Note the use of curly brace syntax `@{field_b} AS field_b` in the model query above. It is used to tell SQLMesh that the rendered variable value should be treated as a SQL identifier instead of a string literal. - -You can see the different behavior in the first rendered model. `@field_a` is resolved to the string literal `'x'` (with single quotes) and `@{field_b}` is resolved to the identifier `y` (without quotes). Learn more about the curly brace syntax [here](../../concepts/macros/sqlmesh_macros.md#embedding-variables-in-strings). +Both `@field_a` and `@{field_b}` resolve blueprint variable values as SQL identifiers. The curly brace syntax is useful when embedding a variable within a larger string where the variable boundary would otherwise be ambiguous (e.g. `@{customer}_suffix`). To produce a string literal with interpolated variables, use the `@'...@{var}...'` syntax as shown with `literal_example` above. Learn more about the curly brace syntax [here](../../concepts/macros/sqlmesh_macros.md#embedding-variables-in-strings). Blueprint variable mappings can also be constructed dynamically, e.g., by using a macro: `blueprints @gen_blueprints()`. This is useful in cases where the `blueprints` list needs to be sourced from external sources, such as CSV files. From 78e6e9d4fe116dfb802578ffdb3696ed8ec7fd6a Mon Sep 17 00:00:00 2001 From: Tori Wei <41123940+toriwei@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:23:08 -0800 Subject: [PATCH 25/39] chore: adjust default handling for min_intervals in user_provided_flags (#5715) --- sqlmesh/cli/main.py | 2 +- sqlmesh/core/context.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/sqlmesh/cli/main.py b/sqlmesh/cli/main.py index 2f18c0a4b7..45f95d2abb 100644 --- a/sqlmesh/cli/main.py +++ b/sqlmesh/cli/main.py @@ -535,7 +535,7 @@ def diff(ctx: click.Context, environment: t.Optional[str] = None) -> None: ) @click.option( "--min-intervals", - default=0, + default=None, help="For every model, ensure at least this many intervals are covered by a missing intervals check regardless of the plan start date", ) @opt.verbose diff --git a/sqlmesh/core/context.py b/sqlmesh/core/context.py index 5d28ef9551..e6b404c597 100644 --- a/sqlmesh/core/context.py +++ b/sqlmesh/core/context.py @@ -1556,6 +1556,7 @@ def plan_builder( run = run or False diff_rendered = diff_rendered or False skip_linter = skip_linter or False + min_intervals = min_intervals or 0 environment = environment or self.config.default_target_environment environment = Environment.sanitize_name(environment) From cea841889c6f83e04b1cbd938295d13fc97b251d Mon Sep 17 00:00:00 2001 From: Trey Spiller <1831878+treysp@users.noreply.github.com> Date: Mon, 2 Mar 2026 22:31:13 +0000 Subject: [PATCH 26/39] chore!: bump sqlglot to 28.10.1 (#5716) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 81df00a373..029d043704 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ dependencies = [ "requests", "rich[jupyter]", "ruamel.yaml", - "sqlglot[rs]~=28.10.0", + "sqlglot[rs]~=28.10.1", "tenacity", "time-machine", "json-stream" From 6e69ce6cfbceab8e7c5f43cdaa7ac7a5d9015cd6 Mon Sep 17 00:00:00 2001 From: Themis Valtinos <73662635+themisvaltinos@users.noreply.github.com> Date: Fri, 6 Mar 2026 10:19:43 +0200 Subject: [PATCH 27/39] Fix: Exclude seed models from default plan end date calculation (#5720) --- sqlmesh/core/context.py | 11 +++++-- tests/core/test_context.py | 66 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/sqlmesh/core/context.py b/sqlmesh/core/context.py index e6b404c597..d736c7244e 100644 --- a/sqlmesh/core/context.py +++ b/sqlmesh/core/context.py @@ -3043,10 +3043,17 @@ def _get_plan_default_start_end( modified_model_names: t.Set[str], execution_time: t.Optional[TimeLike] = None, ) -> t.Tuple[t.Optional[int], t.Optional[int]]: - if not max_interval_end_per_model: + # exclude seeds so their stale interval ends does not become the default plan end date + # when they're the only ones that contain intervals in this plan + non_seed_interval_ends = { + model_fqn: end + for model_fqn, end in max_interval_end_per_model.items() + if model_fqn not in snapshots or not snapshots[model_fqn].is_seed + } + if not non_seed_interval_ends: return None, None - default_end = to_timestamp(max(max_interval_end_per_model.values())) + default_end = to_timestamp(max(non_seed_interval_ends.values())) default_start: t.Optional[int] = None # Infer the default start by finding the smallest interval start that corresponds to the default end. for model_name in backfill_models or modified_model_names or max_interval_end_per_model: diff --git a/tests/core/test_context.py b/tests/core/test_context.py index 1ae98ae4b6..c3d88e205e 100644 --- a/tests/core/test_context.py +++ b/tests/core/test_context.py @@ -1157,6 +1157,72 @@ def test_plan_start_ahead_of_end(copy_to_temp_path): context.close() +@pytest.mark.slow +def test_plan_seed_model_excluded_from_default_end(copy_to_temp_path: t.Callable): + path = copy_to_temp_path("examples/sushi") + with time_machine.travel("2024-06-01 00:00:00 UTC"): + context = Context(paths=path, gateway="duckdb_persistent") + context.plan("prod", no_prompts=True, auto_apply=True) + max_ends = context.state_sync.max_interval_end_per_model("prod") + seed_fqns = [k for k in max_ends if "waiter_names" in k] + assert len(seed_fqns) == 1 + assert max_ends[seed_fqns[0]] == to_timestamp("2024-06-01") + context.close() + + with time_machine.travel("2026-03-01 00:00:00 UTC"): + context = Context(paths=path, gateway="duckdb_persistent") + + # a model that depends on this seed but has no interval in prod yet so only the seed would contribute to max_interval_end_per_model + context.upsert_model( + load_sql_based_model( + parse( + """ + MODEL( + name sushi.waiter_summary, + kind INCREMENTAL_BY_TIME_RANGE ( + time_column ds + ), + start '2025-01-01', + cron '@daily' + ); + + SELECT + id, + name, + @start_ds AS ds + FROM + sushi.waiter_names + WHERE + @start_ds BETWEEN @start_ds AND @end_ds + """ + ), + default_catalog=context.default_catalog, + ) + ) + + # the seed's interval end would still be 2024-06-01 + max_ends = context.state_sync.max_interval_end_per_model("prod") + seed_fqns = [k for k in max_ends if "waiter_names" in k] + assert len(seed_fqns) == 1 + assert max_ends[seed_fqns[0]] == to_timestamp("2024-06-01") + + # the plan start date 2025-01-01 is after the seeds end date but shouldnt cause the plan to fail + plan = context.plan( + "dev", start="2025-01-01", no_prompts=True, select_models=["*waiter_summary"] + ) + + # the end should fall back to execution_time rather than seeds end + assert plan.models_to_backfill == { + '"duckdb"."sushi"."waiter_names"', + '"duckdb"."sushi"."waiter_summary"', + } + assert plan.provided_end is None + assert plan.provided_start == "2025-01-01" + assert to_timestamp(plan.end) == to_timestamp("2026-03-01") + assert to_timestamp(plan.start) == to_timestamp("2025-01-01") + context.close() + + @pytest.mark.slow def test_schema_error_no_default(sushi_context_pre_scheduling) -> None: context = sushi_context_pre_scheduling From a7751842dc43062b652470dfeafe7fc9740256b2 Mon Sep 17 00:00:00 2001 From: Themis Valtinos <73662635+themisvaltinos@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:01:21 +0200 Subject: [PATCH 28/39] Chore: Add assertions to fix mypy errors for trino and errors for latest duckdb (#5724) --- sqlmesh/core/config/connection.py | 15 +++++++++++++-- tests/core/integration/test_aux_commands.py | 8 ++++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/sqlmesh/core/config/connection.py b/sqlmesh/core/config/connection.py index 9e3a210e5e..26bfa78730 100644 --- a/sqlmesh/core/config/connection.py +++ b/sqlmesh/core/config/connection.py @@ -2013,7 +2013,17 @@ def _static_connection_kwargs(self) -> t.Dict[str, t.Any]: OAuth2Authentication, ) + auth: t.Optional[ + t.Union[ + BasicAuthentication, + KerberosAuthentication, + OAuth2Authentication, + JWTAuthentication, + CertificateAuthentication, + ] + ] = None if self.method.is_basic or self.method.is_ldap: + assert self.password is not None # for mypy since validator already checks this auth = BasicAuthentication(self.user, self.password) elif self.method.is_kerberos: if self.keytab: @@ -2032,11 +2042,12 @@ def _static_connection_kwargs(self) -> t.Dict[str, t.Any]: elif self.method.is_oauth: auth = OAuth2Authentication() elif self.method.is_jwt: + assert self.jwt_token is not None auth = JWTAuthentication(self.jwt_token) elif self.method.is_certificate: + assert self.client_certificate is not None + assert self.client_private_key is not None auth = CertificateAuthentication(self.client_certificate, self.client_private_key) - else: - auth = None return { "auth": auth, diff --git a/tests/core/integration/test_aux_commands.py b/tests/core/integration/test_aux_commands.py index ecdd3e05fc..326e81e0c1 100644 --- a/tests/core/integration/test_aux_commands.py +++ b/tests/core/integration/test_aux_commands.py @@ -287,20 +287,20 @@ def test_destroy(copy_to_temp_path): # Validate tables have been deleted as well with pytest.raises( - Exception, match=r"Catalog Error: Table with name model_two does not exist!" + Exception, match=r"Catalog Error: Table with name.*model_two.*does not exist" ): context.fetchdf("SELECT * FROM db_1.first_schema.model_two") with pytest.raises( - Exception, match=r"Catalog Error: Table with name model_one does not exist!" + Exception, match=r"Catalog Error: Table with name.*model_one.*does not exist" ): context.fetchdf("SELECT * FROM db_1.first_schema.model_one") with pytest.raises( - Exception, match=r"Catalog Error: Table with name model_two does not exist!" + Exception, match=r"Catalog Error: Table with name.*model_two.*does not exist" ): context.engine_adapters["second"].fetchdf("SELECT * FROM db_2.second_schema.model_two") with pytest.raises( - Exception, match=r"Catalog Error: Table with name model_one does not exist!" + Exception, match=r"Catalog Error: Table with name.*model_one.*does not exist" ): context.engine_adapters["second"].fetchdf("SELECT * FROM db_2.second_schema.model_one") From 72bfc06b7c75ff675f7a4ac46729535a26d00ac3 Mon Sep 17 00:00:00 2001 From: Lafir <136463254+lafirm@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:14:36 +0530 Subject: [PATCH 29/39] Fix: Handle duplicate key error when we add a project var or change the project name (#4569) Co-authored-by: lafirm Co-authored-by: Themis Valtinos <73662635+themisvaltinos@users.noreply.github.com> --- sqlmesh/core/context.py | 7 +- tests/core/integration/test_multi_repo.py | 105 ++++++++++++++++++++++ 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/sqlmesh/core/context.py b/sqlmesh/core/context.py index d736c7244e..860194278b 100644 --- a/sqlmesh/core/context.py +++ b/sqlmesh/core/context.py @@ -692,8 +692,11 @@ def load(self, update_schemas: bool = True) -> GenericContext[C]: if snapshot.node.project in self._projects: uncached.add(snapshot.name) else: - store = self._standalone_audits if snapshot.is_audit else self._models - store[snapshot.name] = snapshot.node # type: ignore + local_store = self._standalone_audits if snapshot.is_audit else self._models + if snapshot.name in local_store: + uncached.add(snapshot.name) + else: + local_store[snapshot.name] = snapshot.node # type: ignore for model in self._models.values(): self.dag.add(model.fqn, model.depends_on) diff --git a/tests/core/integration/test_multi_repo.py b/tests/core/integration/test_multi_repo.py index 6477b08741..4d72d137b3 100644 --- a/tests/core/integration/test_multi_repo.py +++ b/tests/core/integration/test_multi_repo.py @@ -421,6 +421,111 @@ def test_multi_hybrid(mocker): validate_apply_basics(context, c.PROD, plan.snapshots.values()) +def test_multi_repo_no_project_to_project(copy_to_temp_path): + paths = copy_to_temp_path("examples/multi") + repo_1_path = f"{paths[0]}/repo_1" + repo_1_config_path = f"{repo_1_path}/config.yaml" + with open(repo_1_config_path, "r") as f: + config_content = f.read() + with open(repo_1_config_path, "w") as f: + f.write(config_content.replace("project: repo_1\n", "")) + + context = Context(paths=[repo_1_path], gateway="memory") + context._new_state_sync().reset(default_catalog=context.default_catalog) + plan = context.plan_builder().build() + context.apply(plan) + + # initially models in prod have no project + prod_snapshots = context.state_reader.get_snapshots( + context.state_reader.get_environment(c.PROD).snapshots + ) + for snapshot in prod_snapshots.values(): + assert snapshot.node.project == "" + + # we now adopt multi project by adding a project name + with open(repo_1_config_path, "r") as f: + config_content = f.read() + with open(repo_1_config_path, "w") as f: + f.write("project: repo_1\n" + config_content) + + context_with_project = Context( + paths=[repo_1_path], + state_sync=context.state_sync, + gateway="memory", + ) + context_with_project._engine_adapter = context.engine_adapter + del context_with_project.engine_adapters + + # local models should take precedence to pick up the new project name + local_model_a = context_with_project.get_model("bronze.a") + assert local_model_a.project == "repo_1" + local_model_b = context_with_project.get_model("bronze.b") + assert local_model_b.project == "repo_1" + + # also verify the plan works + plan = context_with_project.plan_builder().build() + context_with_project.apply(plan) + validate_apply_basics(context_with_project, c.PROD, plan.snapshots.values()) + + +def test_multi_repo_local_model_overrides_prod_from_other_project(copy_to_temp_path): + paths = copy_to_temp_path("examples/multi") + repo_1_path = f"{paths[0]}/repo_1" + repo_2_path = f"{paths[0]}/repo_2" + + context = Context(paths=[repo_1_path, repo_2_path], gateway="memory") + context._new_state_sync().reset(default_catalog=context.default_catalog) + plan = context.plan_builder().build() + assert len(plan.new_snapshots) == 5 + context.apply(plan) + + prod_model_c = context.get_model("silver.c") + assert prod_model_c.project == "repo_2" + + with open(f"{repo_1_path}/models/c.sql", "w") as f: + f.write( + dedent("""\ + MODEL ( + name silver.c, + kind FULL + ); + + SELECT DISTINCT col_a, col_b + FROM bronze.a + """) + ) + + # silver.c exists locally in repo 1 now AND in prod under repo_2 + context_repo1 = Context( + paths=[repo_1_path], + state_sync=context.state_sync, + gateway="memory", + ) + context_repo1._engine_adapter = context.engine_adapter + del context_repo1.engine_adapters + + # local model should take precedence and its project should reflect the new project name + local_model_c = context_repo1.get_model("silver.c") + assert local_model_c.project == "repo_1" + + rendered = context_repo1.render("silver.c").sql() + assert "col_b" in rendered + + # its downstream dependencies though should still be picked up + plan = context_repo1.plan_builder().build() + directly_modified_names = {snapshot.name for snapshot in plan.directly_modified} + assert '"memory"."silver"."c"' in directly_modified_names + assert '"memory"."silver"."d"' in directly_modified_names + missing_interval_names = {s.snapshot_id.name for s in plan.missing_intervals} + assert '"memory"."silver"."c"' in missing_interval_names + assert '"memory"."silver"."d"' in missing_interval_names + + context_repo1.apply(plan) + validate_apply_basics(context_repo1, c.PROD, plan.snapshots.values()) + result = context_repo1.fetchdf("SELECT * FROM memory.silver.c") + assert "col_b" in result.columns + + def test_engine_adapters_multi_repo_all_gateways_gathered(copy_to_temp_path): paths = copy_to_temp_path("examples/multi") repo_1_path = paths[0] / "repo_1" From 62c7f51874d481edb959b48a138be8d3709f7479 Mon Sep 17 00:00:00 2001 From: tobymao Date: Wed, 11 Mar 2026 13:13:30 -0700 Subject: [PATCH 30/39] chore: first vote --- GOVERNANCE.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 GOVERNANCE.md diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 0000000000..b477ec2cb6 --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,21 @@ +# TSC Meeting Minutes + +## 2026-03-10 — Initial TSC Meeting + +**Members present:** Toby Mao (tobymao) + +### Vote 1: Elect Toby Mao as TSC Chair +- **Motion by:** Toby Mao +- **Votes:** Toby Mao: Yes +- **Result:** Approved (1-0-0, yes-no-abstain) + +### Vote 2: Elect TSC founding members +- **Question:** Shall the following members be added to the TSC? + - Alexander Butler (z3z1ma) + - Alexander Filipchik (afilipchik) + - Reid Hooper (rhooper9711) + - Yuki Kakegawa (StuffbyYuki) + - Alex Wilde (alexminerv) +- **Motion by:** Toby Mao +- **Votes:** Toby Mao: Yes +- **Result:** Approved (1-0-0, yes-no-abstain) From 6fff527f894e09ee43b214ac0cee25eb51449b22 Mon Sep 17 00:00:00 2001 From: Dan Lynn Date: Wed, 11 Mar 2026 16:16:46 -0700 Subject: [PATCH 31/39] Initial Linux Foundation project config (#5725) Signed-off-by: Dan Lynn Co-authored-by: Dan Lynn --- .github/pull_request_template.md | 16 ++++++ .github/workflows/dco.yml | 17 ++++++ CODE_OF_CONDUCT.md | 5 ++ CONTRIBUTING.md | 90 +++++++++++++++++++++++++++++++ DCO | 34 ++++++++++++ GOVERNANCE.md | 41 ++++++++++++++ LICENSE | 2 +- README.md | 26 ++++----- SECURITY.md | 17 ++++++ pyproject.toml | 6 +-- sqlmesh-technical-charter.pdf | Bin 0 -> 221170 bytes 11 files changed, 238 insertions(+), 16 deletions(-) create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/dco.yml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 DCO create mode 100644 SECURITY.md create mode 100644 sqlmesh-technical-charter.pdf diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000..7585f0ce10 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,16 @@ +## Description + + + +## Test Plan + + + +## Checklist + +- [ ] I have run `make style` and fixed any issues +- [ ] I have added tests for my changes (if applicable) +- [ ] All existing tests pass (`make fast-test`) +- [ ] My commits are signed off (`git commit -s`) per the [DCO](DCO) + + diff --git a/.github/workflows/dco.yml b/.github/workflows/dco.yml new file mode 100644 index 0000000000..a1c4e07300 --- /dev/null +++ b/.github/workflows/dco.yml @@ -0,0 +1,17 @@ +name: Sanity check +on: [pull_request] + +jobs: + commits_check_job: + runs-on: ubuntu-latest + name: Commits Check + steps: + - name: Get PR Commits + id: 'get-pr-commits' + uses: tim-actions/get-pr-commits@master + with: + token: ${{ secrets.GITHUB_TOKEN }} + - name: DCO Check + uses: tim-actions/dco@master + with: + commits: ${{ steps.get-pr-commits.outputs.commits }} diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000..287a87dab5 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,5 @@ +# Code of Conduct + +SQLMesh follows the [LF Projects Code of Conduct](https://lfprojects.org/policies/code-of-conduct/). All participants in the project are expected to abide by it. + +If you believe someone is violating the code of conduct, please report it by following the instructions in the [LF Projects Code of Conduct](https://lfprojects.org/policies/code-of-conduct/). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..0e1d8e1c6e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,90 @@ +# Contributing to SQLMesh + +## Welcome + +SQLMesh is a project of the Linux Foundation. We welcome contributions from anyone — whether you're fixing a bug, improving documentation, or proposing a new feature. + +## Technical Steering Committee (TSC) + +The TSC is responsible for technical oversight of the SQLMesh project, including coordinating technical direction, approving contribution policies, and maintaining community norms. + +Initial TSC voting members are the project's Maintainers: + +| Name | GitHub Handle | Affiliation | Role | +|---------------------|---------------|----------------|------------| +| Alexander Butler | z3z1ma | Harness | TSC Member | +| Alexander Filipchik | afilipchik | Cloud Kitchens | TSC Member | +| Reid Hooper | rhooper9711 | Benzinga | TSC Member | +| Yuki Kakegawa | StuffbyYuki | Jump.ai | TSC Member | +| Toby Mao | tobymao | Fivetran | TSC Chair | +| Alex Wilde | alexminerv | Minerva | TSC Member | + + +## Roles + +**Contributors**: Anyone who contributes code, documentation, or other technical artifacts to the project. + +**Maintainers**: Contributors who have earned the ability to modify source code, documentation, or other technical artifacts. A Contributor may become a Maintainer by majority approval of the TSC. A Maintainer may be removed by majority approval of the TSC. + +## How to Contribute + +1. Fork the repository on GitHub +2. Create a branch for your changes +3. Make your changes and commit them with a sign-off (see DCO section below) +4. Submit a pull request against the `main` branch + +File issues at [github.com/sqlmesh/sqlmesh/issues](https://github.com/sqlmesh/sqlmesh/issues). + +## Developer Certificate of Origin (DCO) + +All contributions must include a `Signed-off-by` line in the commit message per the [Developer Certificate of Origin](DCO). This certifies that you wrote the contribution or have the right to submit it under the project's open source license. + +Use `git commit -s` to add the sign-off automatically: + +```bash +git commit -s -m "Your commit message" +``` + +To fix a commit that is missing the sign-off: + +```bash +git commit --amend -s +``` + +To add a sign-off to multiple commits: + +```bash +git rebase HEAD~N --signoff +``` + +## Development Setup + +See [docs/development.md](docs/development.md) for full setup instructions. Key commands: + +```bash +python -m venv .venv +source .venv/bin/activate +make install-dev +make style # Run before submitting +make fast-test # Quick test suite +``` + +## Coding Standards + +- Run `make style` before submitting a pull request +- Follow existing code patterns and conventions in the codebase +- New files should include an SPDX license header: + ```python + # SPDX-License-Identifier: Apache-2.0 + ``` + +## Pull Request Process + +- Describe your changes clearly in the pull request description +- Ensure all CI checks pass +- Include a DCO sign-off on all commits (`git commit -s`) +- Be responsive to review feedback from maintainers + +## Licensing + +Code contributions are licensed under the [Apache License 2.0](LICENSE). Documentation contributions are licensed under [Creative Commons Attribution 4.0 International (CC-BY-4.0)](https://creativecommons.org/licenses/by/4.0/). See the LICENSE file and the [technical charter](sqlmesh-technical-charter.pdf) for details. diff --git a/DCO b/DCO new file mode 100644 index 0000000000..49b8cb0549 --- /dev/null +++ b/DCO @@ -0,0 +1,34 @@ +Developer Certificate of Origin +Version 1.1 + +Copyright (C) 2004, 2006 The Linux Foundation and its contributors. + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + + +Developer's Certificate of Origin 1.1 + +By making a contribution to this project, I certify that: + +(a) The contribution was created in whole or in part by me and I + have the right to submit it under the open source license + indicated in the file; or + +(b) The contribution is based upon previous work that, to the best + of my knowledge, is covered under an appropriate open source + license and I have the right under that license to submit that + work with modifications, whether created in whole or in part + by me, under the same open source license (unless I am + permitted to submit under a different license), as indicated + in the file; or + +(c) The contribution was provided directly to me by some other + person who certified (a), (b) or (c) and I have not modified + it. + +(d) I understand and agree that this project and the contribution + are public and that a record of the contribution (including all + personal information I submit with it, including my sign-off) is + maintained indefinitely and may be redistributed consistent with + this project or the open source license(s) involved. diff --git a/GOVERNANCE.md b/GOVERNANCE.md index b477ec2cb6..44b6bc9947 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -1,3 +1,44 @@ +# SQLMesh Project Governance + +## Overview + +SQLMesh is a Series of LF Projects, LLC. The project is governed by its [Technical Charter](sqlmesh-technical-charter.pdf) and overseen by the Technical Steering Committee (TSC). SQLMesh is a project of the [Linux Foundation](https://www.linuxfoundation.org/). + +## Technical Steering Committee + +The TSC is responsible for all technical oversight of the project, including: + +- Coordinating the technical direction of the project +- Approving project or system proposals +- Organizing sub-projects and removing sub-projects +- Creating sub-committees or working groups to focus on cross-project technical issues +- Appointing representatives to work with other open source or open standards communities +- Establishing community norms, workflows, issuing releases, and security vulnerability reports +- Approving and implementing policies for contribution requirements +- Coordinating any marketing, events, or communications regarding the project + +## TSC Composition + +TSC voting members are initially the project's Maintainers as listed in [CONTRIBUTING.md](CONTRIBUTING.md). The TSC may elect a Chair from among its voting members. The Chair presides over TSC meetings and serves as the primary point of contact with the Linux Foundation. + +## Decision Making + +The project operates as a consensus-based community. When a formal vote is required: + +- Each voting TSC member receives one vote +- A quorum of 50% of voting members is required to conduct a vote +- Decisions are made by a majority of those present when quorum is met +- Electronic votes (e.g., via GitHub issues or mailing list) require a majority of all voting members to pass +- Votes that do not meet quorum or remain unresolved may be referred to the Series Manager for resolution + +## Charter Amendments + +The technical charter may be amended by a two-thirds vote of the entire TSC, subject to approval by LF Projects, LLC. + +## Reference + +The full technical charter is available at [sqlmesh-technical-charter.pdf](sqlmesh-technical-charter.pdf). + # TSC Meeting Minutes ## 2026-03-10 — Initial TSC Meeting diff --git a/LICENSE b/LICENSE index eabfad022a..7e95724816 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2024 Tobiko Data Inc. + Copyright Contributors to the SQLMesh project Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 3215f7cceb..0a1b2af718 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@

SQLMesh logo

+

SQLMesh is a project of the Linux Foundation.

SQLMesh is a next-generation data transformation framework designed to ship data quickly, efficiently, and without error. Data teams can run and deploy data transformations written in SQL or Python with visibility and control at any size. @@ -12,7 +13,7 @@ It is more than just a [dbt alternative](https://tobikodata.com/reduce_costs_wit ## Core Features -SQLMesh Plan Mode +SQLMesh Plan Mode > Get instant SQL impact and context of your changes, both in the CLI and in the [SQLMesh VSCode Extension](https://sqlmesh.readthedocs.io/en/latest/guides/vscode/?h=vs+cod) @@ -126,14 +127,14 @@ outputs:
Level Up Your SQL Write SQL in any dialect and SQLMesh will transpile it to your target SQL dialect on the fly before sending it to the warehouse. -Transpile Example +Transpile Example
* Debug transformation errors *before* you run them in your warehouse in [10+ different SQL dialects](https://sqlmesh.readthedocs.io/en/stable/integrations/overview/#execution-engines) * Definitions using [simply SQL](https://sqlmesh.readthedocs.io/en/stable/concepts/models/sql_models/#sql-based-definition) (no need for redundant and confusing `Jinja` + `YAML`) * See impact of changes before you run them in your warehouse with column-level lineage -For more information, check out the [website](https://www.tobikodata.com/sqlmesh) and [documentation](https://sqlmesh.readthedocs.io/en/stable/). +For more information, check out the [documentation](https://sqlmesh.readthedocs.io/en/stable/). ## Getting Started Install SQLMesh through [pypi](https://pypi.org/project/sqlmesh/) by running: @@ -174,16 +175,17 @@ Follow the [crash course](https://sqlmesh.readthedocs.io/en/stable/examples/sqlm Follow this [example](https://sqlmesh.readthedocs.io/en/stable/examples/incremental_time_full_walkthrough/) to learn how to use SQLMesh in a full walkthrough. ## Join Our Community -Together, we want to build data transformation without the waste. Connect with us in the following ways: +Connect with us in the following ways: -* Join the [Tobiko Slack Community](https://tobikodata.com/slack) to ask questions, or just to say hi! -* File an issue on our [GitHub](https://github.com/TobikoData/sqlmesh/issues/new) -* Send us an email at [hello@tobikodata.com](mailto:hello@tobikodata.com) with your questions or feedback -* Read our [blog](https://tobikodata.com/blog) +* Join the [SQLMesh Slack Community](https://tobikodata.com/slack) to ask questions, or just to say hi! +* File an issue on our [GitHub](https://github.com/sqlmesh/sqlmesh/issues/new) -## Contribution -Contributions in the form of issues or pull requests (from fork) are greatly appreciated. +## Contributing +We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on how to contribute, including our DCO sign-off requirement. -[Read more](https://sqlmesh.readthedocs.io/en/stable/development/) on how to contribute to SQLMesh open source. +Please review our [Code of Conduct](CODE_OF_CONDUCT.md) and [Governance](GOVERNANCE.md) documents. -[Watch this video walkthrough](https://www.loom.com/share/2abd0d661c12459693fa155490633126?sid=b65c1c0f-8ef7-4036-ad19-3f85a3b87ff2) to see how our team contributes a feature to SQLMesh. +[Read more](https://sqlmesh.readthedocs.io/en/stable/development/) on how to set up your development environment. + +## License +This project is licensed under the [Apache License 2.0](LICENSE). Documentation is licensed under [CC-BY-4.0](https://creativecommons.org/licenses/by/4.0/). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..2ffffacea3 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,17 @@ +# Security Policy + +## Reporting a Vulnerability + +If you discover a security vulnerability in SQLMesh, please report it through [GitHub Security Advisories](https://github.com/sqlmesh/sqlmesh/security/advisories/new). Do not file a public issue for security vulnerabilities. + +## Response + +We will acknowledge receipt of your report within 72 hours and aim to provide an initial assessment within one week. + +## Disclosure + +We follow a coordinated disclosure process. We will work with you to understand and address the issue before any public disclosure. + +## Supported Versions + +Security fixes are generally applied to the latest release. Critical vulnerabilities may be backported to recent prior releases at the discretion of the maintainers. diff --git a/pyproject.toml b/pyproject.toml index 029d043704..a3e2b9addb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "sqlmesh" dynamic = ["version"] description = "Next-generation data transformation framework" readme = "README.md" -authors = [{ name = "TobikoData Inc.", email = "engineering@tobikodata.com" }] +authors = [{ name = "SQLMesh Contributors" }] license = { file = "LICENSE" } requires-python = ">= 3.9" dependencies = [ @@ -154,8 +154,8 @@ sqlmesh_lsp = "sqlmesh.lsp.main:main" [project.urls] Homepage = "https://sqlmesh.com/" Documentation = "https://sqlmesh.readthedocs.io/en/stable/" -Repository = "https://github.com/TobikoData/sqlmesh" -Issues = "https://github.com/TobikoData/sqlmesh/issues" +Repository = "https://github.com/sqlmesh/sqlmesh" +Issues = "https://github.com/sqlmesh/sqlmesh/issues" [build-system] requires = ["setuptools >= 61.0", "setuptools_scm"] diff --git a/sqlmesh-technical-charter.pdf b/sqlmesh-technical-charter.pdf new file mode 100644 index 0000000000000000000000000000000000000000..107f0150501c11c31385b6d8c7606f75308ac618 GIT binary patch literal 221170 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

A! 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 Date: Wed, 11 Mar 2026 16:43:02 -0700 Subject: [PATCH 32/39] update links (#5726) Signed-off-by: tobymao --- README.md | 14 ++++++++------ docs/HOWTO.md | 2 +- .../scheduler/hybrid_executors_docker_compose.md | 2 +- docs/concepts/macros/sqlmesh_macros.md | 2 +- docs/development.md | 2 +- docs/examples/incremental_time_full_walkthrough.md | 2 +- docs/examples/overview.md | 8 ++++---- docs/guides/custom_materializations.md | 10 +++++----- docs/guides/linter.md | 2 +- docs/guides/model_selection.md | 2 +- docs/guides/multi_repo.md | 6 +++--- docs/guides/notifications.md | 2 +- docs/index.md | 10 +++++----- docs/integrations/dbt.md | 2 +- docs/integrations/dlt.md | 2 +- docs/integrations/github.md | 2 +- docs/quickstart/cli.md | 2 +- docs/reference/python.md | 2 +- mkdocs.yml | 6 +++--- pdoc/cli.py | 2 +- posts/virtual_data_environments.md | 4 ++-- pyproject.toml | 4 ++-- sqlmesh/cli/main.py | 2 +- sqlmesh/core/model/common.py | 4 ++-- sqlmesh/core/plan/builder.py | 2 +- sqlmesh/core/renderer.py | 2 +- sqlmesh/core/snapshot/evaluator.py | 2 +- sqlmesh/core/state_sync/db/migrator.py | 2 +- sqlmesh/dbt/column.py | 2 +- .../v0092_warn_about_dbt_data_type_diff.py | 4 ++-- tests/core/engine_adapter/test_mssql.py | 4 ++-- tests/core/test_config.py | 2 +- tests/fixtures/dbt/empty_project/profiles.yml | 2 +- tests/pyproject.toml | 4 ++-- web/common/package.json | 2 +- 35 files changed, 63 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 0a1b2af718..41f78cc138 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ It is more than just a [dbt alternative](https://tobikodata.com/reduce_costs_wit ## Core Features -SQLMesh Plan Mode +SQLMesh Plan Mode > Get instant SQL impact and context of your changes, both in the CLI and in the [SQLMesh VSCode Extension](https://sqlmesh.readthedocs.io/en/latest/guides/vscode/?h=vs+cod) @@ -122,12 +122,12 @@ outputs: * Never build a table [more than once](https://tobikodata.com/simplicity-or-efficiency-how-dbt-makes-you-choose.html) * Track what data’s been modified and run only the necessary transformations for [incremental models](https://tobikodata.com/correctly-loading-incremental-data-at-scale.html) * Run [unit tests](https://tobikodata.com/we-need-even-greater-expectations.html) for free and configure automated audits -* Run [table diffs](https://sqlmesh.readthedocs.io/en/stable/examples/sqlmesh_cli_crash_course/?h=crash#run-data-diff-against-prod) between prod and dev based on tables/views impacted by a change +* Run [table diffs](https://sqlmesh.readthedocs.io/en/stable/examples/sqlmesh_cli_crash_course/?h=crash#run-data-diff-against-prod) between prod and dev based on tables/views impacted by a change

Level Up Your SQL Write SQL in any dialect and SQLMesh will transpile it to your target SQL dialect on the fly before sending it to the warehouse. -Transpile Example +Transpile Example
* Debug transformation errors *before* you run them in your warehouse in [10+ different SQL dialects](https://sqlmesh.readthedocs.io/en/stable/integrations/overview/#execution-engines) @@ -170,15 +170,17 @@ sqlmesh init # follow the prompts to get started (choose DuckDB) Follow the [quickstart guide](https://sqlmesh.readthedocs.io/en/stable/quickstart/cli/) to learn how to use SQLMesh. You already have a head start! -Follow the [crash course](https://sqlmesh.readthedocs.io/en/stable/examples/sqlmesh_cli_crash_course/) to learn the core movesets and use the easy to reference cheat sheet. +Follow the [crash course](https://sqlmesh.readthedocs.io/en/stable/examples/sqlmesh_cli_crash_course/) to learn the core movesets and use the easy to reference cheat sheet. Follow this [example](https://sqlmesh.readthedocs.io/en/stable/examples/incremental_time_full_walkthrough/) to learn how to use SQLMesh in a full walkthrough. ## Join Our Community Connect with us in the following ways: -* Join the [SQLMesh Slack Community](https://tobikodata.com/slack) to ask questions, or just to say hi! -* File an issue on our [GitHub](https://github.com/sqlmesh/sqlmesh/issues/new) +* Join the [Tobiko Slack Community](https://tobikodata.com/slack) to ask questions, or just to say hi! +* File an issue on our [GitHub](https://github.com/SQLMesh/sqlmesh/issues/new) +* Send us an email at [hello@tobikodata.com](mailto:hello@tobikodata.com) with your questions or feedback +* Read our [blog](https://tobikodata.com/blog) ## Contributing We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on how to contribute, including our DCO sign-off requirement. diff --git a/docs/HOWTO.md b/docs/HOWTO.md index 9ccefff077..edd7c9833f 100644 --- a/docs/HOWTO.md +++ b/docs/HOWTO.md @@ -92,7 +92,7 @@ You will work on the docs in a local copy of the sqlmesh git repository. If you don't have a copy of the repo on your machine, open a terminal and clone it into a `sqlmesh` directory by executing: ``` bash -git clone https://github.com/TobikoData/sqlmesh.git +git clone https://github.com/SQLMesh/sqlmesh.git ``` And navigate to the directory: diff --git a/docs/cloud/features/scheduler/hybrid_executors_docker_compose.md b/docs/cloud/features/scheduler/hybrid_executors_docker_compose.md index e3bd072752..8f8f323139 100644 --- a/docs/cloud/features/scheduler/hybrid_executors_docker_compose.md +++ b/docs/cloud/features/scheduler/hybrid_executors_docker_compose.md @@ -25,7 +25,7 @@ Both executors must be properly configured with environment variables to connect 1. **Get docker-compose file**: - Download the [docker-compose.yml](https://raw.githubusercontent.com/TobikoData/sqlmesh/refs/heads/main/docs/cloud/features/scheduler/scheduler/docker-compose.yml) and [.env.example](https://raw.githubusercontent.com/TobikoData/sqlmesh/refs/heads/main/docs/cloud/features/scheduler/scheduler/.env.example) files to a local directory. + Download the [docker-compose.yml](https://raw.githubusercontent.com/SQLMesh/sqlmesh/refs/heads/main/docs/cloud/features/scheduler/scheduler/docker-compose.yml) and [.env.example](https://raw.githubusercontent.com/SQLMesh/sqlmesh/refs/heads/main/docs/cloud/features/scheduler/scheduler/.env.example) files to a local directory. 2. **Create your environment file**: diff --git a/docs/concepts/macros/sqlmesh_macros.md b/docs/concepts/macros/sqlmesh_macros.md index f28e77e203..c7d967b12c 100644 --- a/docs/concepts/macros/sqlmesh_macros.md +++ b/docs/concepts/macros/sqlmesh_macros.md @@ -2111,7 +2111,7 @@ FROM some_table; Generics can be nested and are resolved recursively allowing for fairly robust type hinting. -See examples of the coercion function in action in the test suite [here](https://github.com/TobikoData/sqlmesh/blob/main/tests/core/test_macros.py). +See examples of the coercion function in action in the test suite [here](https://github.com/SQLMesh/sqlmesh/blob/main/tests/core/test_macros.py). #### Conclusion diff --git a/docs/development.md b/docs/development.md index 662ad17d6c..ff8b250d87 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,6 +1,6 @@ # Contribute to development -SQLMesh is licensed under [Apache 2.0](https://github.com/TobikoData/sqlmesh/blob/main/LICENSE). We encourage community contribution and would love for you to get involved. The following document outlines the process to contribute to SQLMesh. +SQLMesh is licensed under [Apache 2.0](https://github.com/SQLMesh/sqlmesh/blob/main/LICENSE). We encourage community contribution and would love for you to get involved. The following document outlines the process to contribute to SQLMesh. ## Prerequisites diff --git a/docs/examples/incremental_time_full_walkthrough.md b/docs/examples/incremental_time_full_walkthrough.md index 4e1d577d2c..ffa9def911 100644 --- a/docs/examples/incremental_time_full_walkthrough.md +++ b/docs/examples/incremental_time_full_walkthrough.md @@ -689,7 +689,7 @@ In the terminal output, I can see the change displayed like before, but I see so I leave the [effective date](../concepts/plans.md#effective-date) prompt blank because I do not want to reprocess historical data in `prod` - I only want to apply this new business logic going forward. -However, I do want to preview the new business logic in my `dev` environment before pushing to `prod`. Because I have [configured SQLMesh to create previews](https://github.com/TobikoData/sqlmesh-demos/blob/e0e3899e173cf7b8447ae707402a9df59911d1c0/config.yaml#L42) for forward-only models in my `config.yaml` file, SQLMesh has created a temporary copy of the `prod` table in my `dev` environment, so I can test the new logic on historical data. +However, I do want to preview the new business logic in my `dev` environment before pushing to `prod`. Because I have [configured SQLMesh to create previews](https://github.com/SQLMesh/sqlmesh-demos/blob/e0e3899e173cf7b8447ae707402a9df59911d1c0/config.yaml#L42) for forward-only models in my `config.yaml` file, SQLMesh has created a temporary copy of the `prod` table in my `dev` environment, so I can test the new logic on historical data. I specify the beginning of the preview's historical data window as `2024-10-27` in the preview start date prompt, and I specify the end of the window as now by leaving the preview end date prompt blank. diff --git a/docs/examples/overview.md b/docs/examples/overview.md index a252b3f9c2..e7dbc1916d 100644 --- a/docs/examples/overview.md +++ b/docs/examples/overview.md @@ -27,16 +27,16 @@ Walkthroughs are easy to follow and provide lots of information in a self-contai ## Projects -SQLMesh example projects are stored in the [sqlmesh-examples Github repository](https://github.com/TobikoData/sqlmesh-examples). The repository's front page includes additional information about how to download the files and set up the projects. +SQLMesh example projects are stored in the [sqlmesh-examples Github repository](https://github.com/SQLMesh/sqlmesh-examples). The repository's front page includes additional information about how to download the files and set up the projects. The two most comprehensive example projects use the SQLMesh `sushi` data, based on a fictional sushi restaurant. ("Tobiko" is the Japanese word for flying fish roe, commonly used in sushi.) -The `sushi` data is described in an [overview notebook](https://github.com/TobikoData/sqlmesh-examples/blob/main/001_sushi/sushi-overview.ipynb) in the repository. +The `sushi` data is described in an [overview notebook](https://github.com/SQLMesh/sqlmesh-examples/blob/main/001_sushi/sushi-overview.ipynb) in the repository. The example repository include two versions of the `sushi` project, at different levels of complexity: -- The [`simple` project](https://github.com/TobikoData/sqlmesh-examples/tree/main/001_sushi/1_simple) contains four `VIEW` and one `SEED` model +- The [`simple` project](https://github.com/SQLMesh/sqlmesh-examples/tree/main/001_sushi/1_simple) contains four `VIEW` and one `SEED` model - The `VIEW` model kind refreshes every run, making it easy to reason about SQLMesh's behavior -- The [`moderate` project](https://github.com/TobikoData/sqlmesh-examples/tree/main/001_sushi/2_moderate) contains five `INCREMENTAL_BY_TIME_RANGE`, one `FULL`, one `VIEW`, and one `SEED` model +- The [`moderate` project](https://github.com/SQLMesh/sqlmesh-examples/tree/main/001_sushi/2_moderate) contains five `INCREMENTAL_BY_TIME_RANGE`, one `FULL`, one `VIEW`, and one `SEED` model - The incremental models allow you to observe how and when new data is transformed by SQLMesh - Some models, like `customer_revenue_lifetime`, demonstrate more advanced incremental queries like customer lifetime value calculation diff --git a/docs/guides/custom_materializations.md b/docs/guides/custom_materializations.md index 58eb64026d..905a3d017e 100644 --- a/docs/guides/custom_materializations.md +++ b/docs/guides/custom_materializations.md @@ -24,13 +24,13 @@ A custom materialization must: - Be written in Python code - Be a Python class that inherits the SQLMesh `CustomMaterialization` base class -- Use or override the `insert` method from the SQLMesh [`MaterializableStrategy`](https://github.com/TobikoData/sqlmesh/blob/034476e7f64d261860fd630c3ac56d8a9c9f3e3a/sqlmesh/core/snapshot/evaluator.py#L1146) class/subclasses +- Use or override the `insert` method from the SQLMesh [`MaterializableStrategy`](https://github.com/SQLMesh/sqlmesh/blob/034476e7f64d261860fd630c3ac56d8a9c9f3e3a/sqlmesh/core/snapshot/evaluator.py#L1146) class/subclasses - Be loaded or imported by SQLMesh at runtime A custom materialization may: -- Use or override methods from the SQLMesh [`MaterializableStrategy`](https://github.com/TobikoData/sqlmesh/blob/034476e7f64d261860fd630c3ac56d8a9c9f3e3a/sqlmesh/core/snapshot/evaluator.py#L1146) class/subclasses -- Use or override methods from the SQLMesh [`EngineAdapter`](https://github.com/TobikoData/sqlmesh/blob/034476e7f64d261860fd630c3ac56d8a9c9f3e3a/sqlmesh/core/engine_adapter/base.py#L67) class/subclasses +- Use or override methods from the SQLMesh [`MaterializableStrategy`](https://github.com/SQLMesh/sqlmesh/blob/034476e7f64d261860fd630c3ac56d8a9c9f3e3a/sqlmesh/core/snapshot/evaluator.py#L1146) class/subclasses +- Use or override methods from the SQLMesh [`EngineAdapter`](https://github.com/SQLMesh/sqlmesh/blob/034476e7f64d261860fd630c3ac56d8a9c9f3e3a/sqlmesh/core/engine_adapter/base.py#L67) class/subclasses - Execute arbitrary SQL code and fetch results with the engine adapter `execute` and related methods A custom materialization may perform arbitrary Python processing with Pandas or other libraries, but in most cases that logic should reside in a [Python model](../concepts/models/python_models.md) instead of the materialization. @@ -157,7 +157,7 @@ class CustomFullMaterialization(CustomMaterialization): ) -> None: config_value = model.custom_materialization_properties["config_key"] # Proceed with implementing the insertion logic. - # Example existing materialization for look and feel: https://github.com/TobikoData/sqlmesh/blob/main/sqlmesh/core/snapshot/evaluator.py + # Example existing materialization for look and feel: https://github.com/SQLMesh/sqlmesh/blob/main/sqlmesh/core/snapshot/evaluator.py ``` ## Extending `CustomKind` @@ -292,4 +292,4 @@ setup( ) ``` -Refer to the SQLMesh Github [custom_materializations](https://github.com/TobikoData/sqlmesh/tree/main/examples/custom_materializations) example for more details on Python packaging. +Refer to the SQLMesh Github [custom_materializations](https://github.com/SQLMesh/sqlmesh/tree/main/examples/custom_materializations) example for more details on Python packaging. diff --git a/docs/guides/linter.md b/docs/guides/linter.md index 22cc5077b8..6cdac167ec 100644 --- a/docs/guides/linter.md +++ b/docs/guides/linter.md @@ -16,7 +16,7 @@ Some rules validate that a pattern is *not* present, such as not allowing `SELEC Rules are defined in Python. Each rule is an individual Python class that inherits from SQLMesh's `Rule` base class and defines the logic for validating a pattern. -We display a portion of the `Rule` base class's code below ([full source code](https://github.com/TobikoData/sqlmesh/blob/main/sqlmesh/core/linter/rule.py)). Its methods and properties illustrate the most important components of the subclassed rules you define. +We display a portion of the `Rule` base class's code below ([full source code](https://github.com/SQLMesh/sqlmesh/blob/main/sqlmesh/core/linter/rule.py)). Its methods and properties illustrate the most important components of the subclassed rules you define. Each rule class you create has four vital components: diff --git a/docs/guides/model_selection.md b/docs/guides/model_selection.md index e6178246d6..79fd17a18c 100644 --- a/docs/guides/model_selection.md +++ b/docs/guides/model_selection.md @@ -78,7 +78,7 @@ NOTE: the `--backfill-model` argument can only be used in development environmen ## Examples -We now demonstrate the use of `--select-model` and `--backfill-model` with the SQLMesh `sushi` example project, available in the `examples/sushi` directory of the [SQLMesh Github repository](https://github.com/TobikoData/sqlmesh). +We now demonstrate the use of `--select-model` and `--backfill-model` with the SQLMesh `sushi` example project, available in the `examples/sushi` directory of the [SQLMesh Github repository](https://github.com/SQLMesh/sqlmesh). ### sushi diff --git a/docs/guides/multi_repo.md b/docs/guides/multi_repo.md index bf34c7d21a..4dae4de57e 100644 --- a/docs/guides/multi_repo.md +++ b/docs/guides/multi_repo.md @@ -5,7 +5,7 @@ SQLMesh provides native support for multiple repos and makes it easy to maintain If you are wanting to separate your systems/data and provide isolation, checkout the [isolated systems guide](https://sqlmesh.readthedocs.io/en/stable/guides/isolated_systems/?h=isolated). ## Bootstrapping multiple projects -Setting up SQLMesh with multiple repos is quite simple. Copy the contents of this example [multi-repo project](https://github.com/TobikoData/sqlmesh/tree/main/examples/multi). +Setting up SQLMesh with multiple repos is quite simple. Copy the contents of this example [multi-repo project](https://github.com/SQLMesh/sqlmesh/tree/main/examples/multi). To bootstrap the project, you can point SQLMesh at both projects. @@ -196,7 +196,7 @@ $ sqlmesh -p examples/multi/repo_1 migrate SQLMesh also supports multiple repos for dbt projects, allowing it to correctly detect changes and orchestrate backfills even when changes span multiple dbt projects. -You can watch a [quick demo](https://www.loom.com/share/69c083428bb348da8911beb2cd4d30b2) of this setup or experiment with the [multi-repo dbt example](https://github.com/TobikoData/sqlmesh/tree/main/examples/multi_dbt) yourself. +You can watch a [quick demo](https://www.loom.com/share/69c083428bb348da8911beb2cd4d30b2) of this setup or experiment with the [multi-repo dbt example](https://github.com/SQLMesh/sqlmesh/tree/main/examples/multi_dbt) yourself. ## Multi-repo mixed projects @@ -212,4 +212,4 @@ $ sqlmesh -p examples/multi_hybrid/dbt_repo -p examples/multi_hybrid/sqlmesh_rep SQLMesh will automatically detect dependencies and lineage across both SQLMesh and dbt projects, even when models are sourcing from different project types. -For an example of this setup, refer to the [mixed SQLMesh and dbt example](https://github.com/TobikoData/sqlmesh/tree/main/examples/multi_hybrid). +For an example of this setup, refer to the [mixed SQLMesh and dbt example](https://github.com/SQLMesh/sqlmesh/tree/main/examples/multi_hybrid). diff --git a/docs/guides/notifications.md b/docs/guides/notifications.md index 03405b8252..749a71c842 100644 --- a/docs/guides/notifications.md +++ b/docs/guides/notifications.md @@ -256,7 +256,7 @@ This example shows an email notification target, where `sushi@example.com` email In Python configuration files, new notification targets can be configured to send custom messages. -To customize a notification, create a new notification target class as a subclass of one of the three target classes described above (`SlackWebhookNotificationTarget`, `SlackApiNotificationTarget`, or `BasicSMTPNotificationTarget`). See the definitions of these classes on Github [here](https://github.com/TobikoData/sqlmesh/blob/main/sqlmesh/core/notification_target.py). +To customize a notification, create a new notification target class as a subclass of one of the three target classes described above (`SlackWebhookNotificationTarget`, `SlackApiNotificationTarget`, or `BasicSMTPNotificationTarget`). See the definitions of these classes on Github [here](https://github.com/SQLMesh/sqlmesh/blob/main/sqlmesh/core/notification_target.py). Each of those notification target classes is a subclass of `BaseNotificationTarget`, which contains a `notify` function corresponding to each event type. This table lists the notification functions, along with the contextual information available to them at calling time (e.g., the environment name for start/end events): diff --git a/docs/index.md b/docs/index.md index 3e9330f83f..83c1b0a431 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,7 +1,7 @@ #

- SQLMesh logo + SQLMesh logo

SQLMesh is a next-generation data transformation framework designed to ship data quickly, efficiently, and without error. Data teams can efficiently run and deploy data transformations written in SQL or Python with visibility and control at any size. @@ -9,11 +9,11 @@ SQLMesh is a next-generation data transformation framework designed to ship data It is more than just a [dbt alternative](https://tobikodata.com/reduce_costs_with_cron_and_partitions.html).

- Architecture Diagram + Architecture Diagram

## Core Features -SQLMesh Plan Mode +SQLMesh Plan Mode > Get instant SQL impact analysis of your changes, whether in the CLI or in [SQLMesh Plan Mode](https://sqlmesh.readthedocs.io/en/stable/guides/ui/?h=modes#working-with-an-ide) @@ -121,7 +121,7 @@ It is more than just a [dbt alternative](https://tobikodata.com/reduce_costs_wit ??? tip "Level Up Your SQL" Write SQL in any dialect and SQLMesh will transpile it to your target SQL dialect on the fly before sending it to the warehouse. - Transpile Example + Transpile Example * Debug transformation errors *before* you run them in your warehouse in [10+ different SQL dialects](https://sqlmesh.readthedocs.io/en/stable/integrations/overview/#execution-engines) * Definitions using [simply SQL](https://sqlmesh.readthedocs.io/en/stable/concepts/models/sql_models/#sql-based-definition) (no need for redundant and confusing `Jinja` + `YAML`) @@ -153,7 +153,7 @@ Follow this [example](https://sqlmesh.readthedocs.io/en/stable/examples/incremen Together, we want to build data transformation without the waste. Connect with us in the following ways: * Join the [Tobiko Slack Community](https://tobikodata.com/slack) to ask questions, or just to say hi! -* File an issue on our [GitHub](https://github.com/TobikoData/sqlmesh/issues/new) +* File an issue on our [GitHub](https://github.com/SQLMesh/sqlmesh/issues/new) * Send us an email at [hello@tobikodata.com](mailto:hello@tobikodata.com) with your questions or feedback * Read our [blog](https://tobikodata.com/blog) diff --git a/docs/integrations/dbt.md b/docs/integrations/dbt.md index 7cbef5b8fa..5854236aa2 100644 --- a/docs/integrations/dbt.md +++ b/docs/integrations/dbt.md @@ -358,4 +358,4 @@ The dbt jinja methods that are not currently supported are: ## Missing something you need? -Submit an [issue](https://github.com/TobikoData/sqlmesh/issues), and we'll look into it! +Submit an [issue](https://github.com/SQLMesh/sqlmesh/issues), and we'll look into it! diff --git a/docs/integrations/dlt.md b/docs/integrations/dlt.md index a53dc184ea..7125510de9 100644 --- a/docs/integrations/dlt.md +++ b/docs/integrations/dlt.md @@ -70,7 +70,7 @@ SQLMesh will retrieve the data warehouse connection credentials from your dlt pr ### Example -Generating a SQLMesh project dlt is quite simple. In this example, we'll use the example `sushi_pipeline.py` from the [sushi-dlt project](https://github.com/TobikoData/sqlmesh/tree/main/examples/sushi_dlt). +Generating a SQLMesh project dlt is quite simple. In this example, we'll use the example `sushi_pipeline.py` from the [sushi-dlt project](https://github.com/SQLMesh/sqlmesh/tree/main/examples/sushi_dlt). First, run the pipeline within the project directory: diff --git a/docs/integrations/github.md b/docs/integrations/github.md index 923714888e..07903fce56 100644 --- a/docs/integrations/github.md +++ b/docs/integrations/github.md @@ -364,7 +364,7 @@ These are the possible outputs (based on how the bot is configured) that are cre * `prod_plan_preview` * `prod_environment_synced` -[There are many possible conclusions](https://github.com/TobikoData/sqlmesh/blob/main/sqlmesh/integrations/github/cicd/controller.py#L96-L102) so the best use case for this is likely to check for `success` conclusion in order to potentially run follow up steps. +[There are many possible conclusions](https://github.com/SQLMesh/sqlmesh/blob/main/sqlmesh/integrations/github/cicd/controller.py#L96-L102) so the best use case for this is likely to check for `success` conclusion in order to potentially run follow up steps. Note that in error cases conclusions may not be set and therefore you will get an empty string. Example of running a step after pr environment has been synced: diff --git a/docs/quickstart/cli.md b/docs/quickstart/cli.md index 7b77b2af1e..a592847470 100644 --- a/docs/quickstart/cli.md +++ b/docs/quickstart/cli.md @@ -160,7 +160,7 @@ https://sqlmesh.readthedocs.io/en/stable/quickstart/cli/ Need help? - Docs: https://sqlmesh.readthedocs.io - Slack: https://www.tobikodata.com/slack -- GitHub: https://github.com/TobikoData/sqlmesh/issues +- GitHub: https://github.com/SQLMesh/sqlmesh/issues ``` ??? info "Learn more about the project's configuration: `config.yaml`" diff --git a/docs/reference/python.md b/docs/reference/python.md index 14e0da84c8..1c4c9191ff 100644 --- a/docs/reference/python.md +++ b/docs/reference/python.md @@ -4,6 +4,6 @@ SQLMesh is built in Python, and its complete Python API reference is located [he The Python API reference is comprehensive and includes the internal components of SQLMesh. Those components are likely only of interest if you want to modify SQLMesh itself. -If you want to use SQLMesh via its Python API, the best approach is to study how the SQLMesh [CLI](./cli.md) calls it behind the scenes. The CLI implementation code shows exactly which Python methods are called for each CLI command and can be viewed [on Github](https://github.com/TobikoData/sqlmesh/blob/main/sqlmesh/cli/main.py). For example, the Python code executed by the `plan` command is located [here](https://github.com/TobikoData/sqlmesh/blob/15c8788100fa1cfb8b0cc1879ccd1ad21dc3e679/sqlmesh/cli/main.py#L302). +If you want to use SQLMesh via its Python API, the best approach is to study how the SQLMesh [CLI](./cli.md) calls it behind the scenes. The CLI implementation code shows exactly which Python methods are called for each CLI command and can be viewed [on Github](https://github.com/SQLMesh/sqlmesh/blob/main/sqlmesh/cli/main.py). For example, the Python code executed by the `plan` command is located [here](https://github.com/SQLMesh/sqlmesh/blob/15c8788100fa1cfb8b0cc1879ccd1ad21dc3e679/sqlmesh/cli/main.py#L302). Almost all the relevant Python methods are in the [SQLMesh `Context` class](https://sqlmesh.readthedocs.io/en/stable/_readthedocs/html/sqlmesh/core/context.html#Context). diff --git a/mkdocs.yml b/mkdocs.yml index 47ddca54e9..86761de9d7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,6 +1,6 @@ site_name: SQLMesh -repo_url: https://github.com/TobikoData/sqlmesh -repo_name: TobikoData/sqlmesh +repo_url: https://github.com/SQLMesh/sqlmesh +repo_name: SQLMesh/sqlmesh nav: - "Overview": index.md - Get started: @@ -202,7 +202,7 @@ extra: - icon: fontawesome/solid/paper-plane link: mailto:hello@tobikodata.com - icon: fontawesome/brands/github - link: https://github.com/TobikoData/sqlmesh/issues/new + link: https://github.com/SQLMesh/sqlmesh/issues/new analytics: provider: google property: G-JXQ1R227VS diff --git a/pdoc/cli.py b/pdoc/cli.py index 5833c59207..9301ae0444 100755 --- a/pdoc/cli.py +++ b/pdoc/cli.py @@ -29,7 +29,7 @@ def mocked_import(*args, **kwargs): opts.logo_link = "https://tobikodata.com" opts.footer_text = "Copyright Tobiko Data Inc. 2022" opts.template_directory = Path(__file__).parent.joinpath("templates").absolute() - opts.edit_url = ["sqlmesh=https://github.com/TobikoData/sqlmesh/tree/main/sqlmesh/"] + opts.edit_url = ["sqlmesh=https://github.com/SQLMesh/sqlmesh/tree/main/sqlmesh/"] with mock.patch("pdoc.__main__.parser", **{"parse_args.return_value": opts}): cli() diff --git a/posts/virtual_data_environments.md b/posts/virtual_data_environments.md index dc3b2cb46e..5cde9dba51 100644 --- a/posts/virtual_data_environments.md +++ b/posts/virtual_data_environments.md @@ -8,7 +8,7 @@ In this post, I'm going to explain why existing approaches to managing developme I'll introduce [Virtual Data Environments](#virtual-data-environments-1) - a novel approach that provides low-cost, efficient, scalable, and safe data environments that are easy to use and manage. They significantly boost the productivity of anyone who has to create or maintain data pipelines. -Finally, I’m going to explain how **Virtual Data Environments** are implemented in [SQLMesh](https://github.com/TobikoData/sqlmesh) and share details on each core component involved: +Finally, I’m going to explain how **Virtual Data Environments** are implemented in [SQLMesh](https://github.com/SQLMesh/sqlmesh) and share details on each core component involved: - Data [fingerprinting](#fingerprinting) - [Automatic change categorization](#automatic-change-categorization) - Decoupling of [physical](#physical-layer) and [virtual](#virtual-layer) layers @@ -156,6 +156,6 @@ With **Virtual Data Environments**, SQLMesh is able to provide fully **isolated* - Rolling back a change happens almost instantaneously since no data movement is involved and only views that are part of the **virtual layer** get updated. - Deploying changes to production is a **virtual layer** operation, which ensures that results observed during development are exactly the same in production and that data and code are always in sync. -To streamline deploying changes to production, our team is about to release the SQLMesh [CI/CD bot](https://github.com/TobikoData/sqlmesh/blob/main/docs/integrations/github.md), which will help automate this process. +To streamline deploying changes to production, our team is about to release the SQLMesh [CI/CD bot](https://github.com/SQLMesh/sqlmesh/blob/main/docs/integrations/github.md), which will help automate this process. Don't miss out - join our [Slack channel](https://tobikodata.com/slack) and stay tuned! diff --git a/pyproject.toml b/pyproject.toml index a3e2b9addb..ebfc112567 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -154,8 +154,8 @@ sqlmesh_lsp = "sqlmesh.lsp.main:main" [project.urls] Homepage = "https://sqlmesh.com/" Documentation = "https://sqlmesh.readthedocs.io/en/stable/" -Repository = "https://github.com/sqlmesh/sqlmesh" -Issues = "https://github.com/sqlmesh/sqlmesh/issues" +Repository = "https://github.com/SQLMesh/sqlmesh" +Issues = "https://github.com/SQLMesh/sqlmesh/issues" [build-system] requires = ["setuptools >= 61.0", "setuptools_scm"] diff --git a/sqlmesh/cli/main.py b/sqlmesh/cli/main.py index 45f95d2abb..ec5acbea59 100644 --- a/sqlmesh/cli/main.py +++ b/sqlmesh/cli/main.py @@ -246,7 +246,7 @@ def init( Need help? • Docs: https://sqlmesh.readthedocs.io • Slack: https://www.tobikodata.com/slack -• GitHub: https://github.com/TobikoData/sqlmesh/issues +• GitHub: https://github.com/SQLMesh/sqlmesh/issues """) diff --git a/sqlmesh/core/model/common.py b/sqlmesh/core/model/common.py index 9e117b56fb..dc51b3379c 100644 --- a/sqlmesh/core/model/common.py +++ b/sqlmesh/core/model/common.py @@ -255,7 +255,7 @@ def _add_variables_to_python_env( # - appear in metadata-only expressions, such as `audits (...)`, virtual statements, etc # - appear in the ASTs or definitions of metadata-only macros # - # See also: https://github.com/TobikoData/sqlmesh/pull/4936#issuecomment-3136339936, + # See also: https://github.com/SQLMesh/sqlmesh/pull/4936#issuecomment-3136339936, # specifically the "Terminology" and "Observations" section. metadata_used_variables = { var_name for var_name, is_metadata in used_variables.items() if is_metadata @@ -275,7 +275,7 @@ def _add_variables_to_python_env( if overlapping_variables := (non_metadata_used_variables & metadata_used_variables): raise ConfigError( f"Variables {', '.join(overlapping_variables)} are both metadata and non-metadata, " - "which is unexpected. Please file an issue at https://github.com/TobikoData/sqlmesh/issues/new." + "which is unexpected. Please file an issue at https://github.com/SQLMesh/sqlmesh/issues/new." ) metadata_variables = { diff --git a/sqlmesh/core/plan/builder.py b/sqlmesh/core/plan/builder.py index 7d753cc330..01834594cd 100644 --- a/sqlmesh/core/plan/builder.py +++ b/sqlmesh/core/plan/builder.py @@ -165,7 +165,7 @@ def __init__( # There may be an significant delay between the PlanBuilder producing a Plan and the Plan actually being run # so if execution_time=None is passed to the PlanBuilder, then the resulting Plan should also have execution_time=None # in order to prevent the Plan that was intended to run "as at now" from having "now" fixed to some time in the past - # ref: https://github.com/TobikoData/sqlmesh/pull/4702#discussion_r2140696156 + # ref: https://github.com/SQLMesh/sqlmesh/pull/4702#discussion_r2140696156 self._execution_time = execution_time self._backfill_models = backfill_models diff --git a/sqlmesh/core/renderer.py b/sqlmesh/core/renderer.py index 0cbf9b6e94..50c1faeb63 100644 --- a/sqlmesh/core/renderer.py +++ b/sqlmesh/core/renderer.py @@ -690,7 +690,7 @@ def _optimize_query(self, query: exp.Query, all_deps: t.Set[str]) -> exp.Query: except Exception as ex: raise_config_error( - f"Failed to optimize query, please file an issue at https://github.com/TobikoData/sqlmesh/issues/new. {ex}", + f"Failed to optimize query, please file an issue at https://github.com/SQLMesh/sqlmesh/issues/new. {ex}", self._path, ) diff --git a/sqlmesh/core/snapshot/evaluator.py b/sqlmesh/core/snapshot/evaluator.py index 1808011854..4f5102cbef 100644 --- a/sqlmesh/core/snapshot/evaluator.py +++ b/sqlmesh/core/snapshot/evaluator.py @@ -714,7 +714,7 @@ def _evaluate_snapshot( deployability_index = deployability_index or DeployabilityIndex.all_deployable() is_snapshot_deployable = deployability_index.is_deployable(snapshot) target_table_name = snapshot.table_name(is_deployable=is_snapshot_deployable) - # https://github.com/TobikoData/sqlmesh/issues/2609 + # https://github.com/SQLMesh/sqlmesh/issues/2609 # If there are no existing intervals yet; only consider this a first insert for the first snapshot in the batch if target_table_exists is None: target_table_exists = adapter.table_exists(target_table_name) diff --git a/sqlmesh/core/state_sync/db/migrator.py b/sqlmesh/core/state_sync/db/migrator.py index ad60c57570..8d73e1d395 100644 --- a/sqlmesh/core/state_sync/db/migrator.py +++ b/sqlmesh/core/state_sync/db/migrator.py @@ -195,7 +195,7 @@ def _apply_migrations( raise SQLMeshError( f"Number of snapshots before ({snapshot_count_before}) and after " f"({snapshot_count_after}) applying migration scripts {scripts} does not match. " - "Please file an issue issue at https://github.com/TobikoData/sqlmesh/issues/new." + "Please file an issue issue at https://github.com/SQLMesh/sqlmesh/issues/new." ) migrate_snapshots_and_environments = ( diff --git a/sqlmesh/dbt/column.py b/sqlmesh/dbt/column.py index 755f574388..80a6ad9325 100644 --- a/sqlmesh/dbt/column.py +++ b/sqlmesh/dbt/column.py @@ -42,7 +42,7 @@ def column_types_to_sqlmesh( ) if column_def.args.get("constraints"): logger.warning( - f"Ignoring unsupported constraints for column '{name}' with definition '{column.data_type}'. Please refer to github.com/TobikoData/sqlmesh/issues/4717 for more information." + f"Ignoring unsupported constraints for column '{name}' with definition '{column.data_type}'. Please refer to github.com/SQLMesh/sqlmesh/issues/4717 for more information." ) kind = column_def.kind if kind: diff --git a/sqlmesh/migrations/v0092_warn_about_dbt_data_type_diff.py b/sqlmesh/migrations/v0092_warn_about_dbt_data_type_diff.py index 02e2a5f4c1..5407e5a99a 100644 --- a/sqlmesh/migrations/v0092_warn_about_dbt_data_type_diff.py +++ b/sqlmesh/migrations/v0092_warn_about_dbt_data_type_diff.py @@ -5,7 +5,7 @@ doesn't match dbt's behavior. dbt only uses data_type for contracts/validation, not DDL. This fix may cause diffs if tables were created with incorrect types. -More context: https://github.com/TobikoData/sqlmesh/pull/5231 +More context: https://github.com/SQLMesh/sqlmesh/pull/5231 """ import json @@ -33,7 +33,7 @@ def migrate_rows(engine_adapter, schema, **kwargs): # type: ignore "tables may have been created with incorrect column types. After this migration, run " "'sqlmesh diff prod' to check for column type differences, and if any are found, " "apply a plan to correct the table schemas. For more details, see: " - "https://github.com/TobikoData/sqlmesh/pull/5231." + "https://github.com/SQLMesh/sqlmesh/pull/5231." ) for (snapshot,) in engine_adapter.fetchall( diff --git a/tests/core/engine_adapter/test_mssql.py b/tests/core/engine_adapter/test_mssql.py index bf28157d00..ec6a4ba3e8 100644 --- a/tests/core/engine_adapter/test_mssql.py +++ b/tests/core/engine_adapter/test_mssql.py @@ -833,7 +833,7 @@ def test_create_table_from_query(make_mocked_engine_adapter: t.Callable, mocker: columns_mock.assert_called_once_with(exp.table_("__temp_ctas_test_random_id", quoted=True)) # We don't want to drop anything other than LIMIT 0 - # See https://github.com/TobikoData/sqlmesh/issues/4048 + # See https://github.com/SQLMesh/sqlmesh/issues/4048 adapter.ctas( table_name="test_schema.test_table", query_or_df=parse_one( @@ -848,7 +848,7 @@ def test_create_table_from_query(make_mocked_engine_adapter: t.Callable, mocker: def test_replace_query_strategy(adapter: MSSQLEngineAdapter, mocker: MockerFixture): - # ref issue 4472: https://github.com/TobikoData/sqlmesh/issues/4472 + # ref issue 4472: https://github.com/SQLMesh/sqlmesh/issues/4472 # The FULL strategy calls EngineAdapter.replace_query() which calls _insert_overwrite_by_condition() should use DELETE+INSERT and not MERGE expressions = d.parse( f""" diff --git a/tests/core/test_config.py b/tests/core/test_config.py index f3a0de6672..9ae239f298 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -1050,7 +1050,7 @@ def test_environment_statements_config(tmp_path): ] -# https://github.com/TobikoData/sqlmesh/pull/4049 +# https://github.com/SQLMesh/sqlmesh/pull/4049 def test_pydantic_import_error() -> None: class TestConfig(DuckDBConnectionConfig): pass diff --git a/tests/fixtures/dbt/empty_project/profiles.yml b/tests/fixtures/dbt/empty_project/profiles.yml index adae09e9c6..712456bffe 100644 --- a/tests/fixtures/dbt/empty_project/profiles.yml +++ b/tests/fixtures/dbt/empty_project/profiles.yml @@ -7,7 +7,7 @@ empty_project: type: duckdb # database is required for dbt < 1.5 where our adapter deliberately doesnt infer the database from the path and # defaults it to "main", which raises a "project catalog doesnt match context catalog" error - # ref: https://github.com/TobikoData/sqlmesh/pull/1109 + # ref: https://github.com/SQLMesh/sqlmesh/pull/1109 database: empty_project path: 'empty_project.duckdb' threads: 4 diff --git a/tests/pyproject.toml b/tests/pyproject.toml index 6f9cd2f9d9..73f143bfde 100644 --- a/tests/pyproject.toml +++ b/tests/pyproject.toml @@ -8,8 +8,8 @@ license = { text = "Apache License 2.0" } [project.urls] Homepage = "https://sqlmesh.com/" Documentation = "https://sqlmesh.readthedocs.io/en/stable/" -Repository = "https://github.com/TobikoData/sqlmesh" -Issues = "https://github.com/TobikoData/sqlmesh/issues" +Repository = "https://github.com/SQLMesh/sqlmesh" +Issues = "https://github.com/SQLMesh/sqlmesh/issues" [build-system] requires = ["setuptools", "setuptools_scm", "toml"] diff --git a/web/common/package.json b/web/common/package.json index 6a0965f19e..924bbaa883 100644 --- a/web/common/package.json +++ b/web/common/package.json @@ -101,7 +101,7 @@ "tailwindcss": "3.4.17" }, "private": false, - "repository": "TobikoData/sqlmesh", + "repository": "SQLMesh/sqlmesh", "scripts": { "build": "tsc -p tsconfig.build.json && vite build --base './' && pnpm run build:css", "build-storybook": "storybook build", From 07e0f0f5670894696c06fae96f6c1f19652639d5 Mon Sep 17 00:00:00 2001 From: Ryan Eakman <6326532+eakmanrq@users.noreply.github.com> Date: Fri, 13 Mar 2026 09:54:08 -0700 Subject: [PATCH 33/39] Migrate CircleCI to GitHub Actions (#5729) --- .circleci/config.yml | 115 ----- .circleci/continue_config.yml | 331 --------------- .../scripts}/install-prerequisites.sh | 6 +- .../scripts}/manage-test-db.sh | 26 +- .../scripts}/test_migration.sh | 8 +- .../scripts}/update-pypirc.sh | 0 {.circleci => .github/scripts}/wait-for-db.sh | 2 +- .github/workflows/pr.yaml | 401 +++++++++++++++++- .github/workflows/private-repo-test.yaml | 97 ----- .github/workflows/release.yaml | 71 ++++ Makefile | 6 +- tests/cli/test_cli.py | 2 - .../engine_adapter/integration/config.yaml | 1 + .../integration/test_integration_snowflake.py | 1 + tests/core/test_test.py | 2 + tests/engines/spark/conftest.py | 1 + web/client/playwright.config.ts | 5 +- web/client/vite.config.ts | 1 + 18 files changed, 499 insertions(+), 577 deletions(-) delete mode 100644 .circleci/config.yml delete mode 100644 .circleci/continue_config.yml rename {.circleci => .github/scripts}/install-prerequisites.sh (89%) rename {.circleci => .github/scripts}/manage-test-db.sh (88%) rename {.circleci => .github/scripts}/test_migration.sh (91%) rename {.circleci => .github/scripts}/update-pypirc.sh (100%) rename {.circleci => .github/scripts}/wait-for-db.sh (98%) delete mode 100644 .github/workflows/private-repo-test.yaml create mode 100644 .github/workflows/release.yaml diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 7a12d3c07d..0000000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,115 +0,0 @@ -version: 2.1 - -setup: true - -on_main_or_tag_filter: &on_main_or_tag_filter - filters: - branches: - only: main - tags: - only: /^v\d+\.\d+\.\d+/ - -on_tag_filter: &on_tag_filter - filters: - branches: - ignore: /.*/ - tags: - only: /^v\d+\.\d+\.\d+/ - -orbs: - path-filtering: circleci/path-filtering@1.2.0 - -jobs: - publish: - docker: - - image: cimg/python:3.10 - resource_class: small - steps: - - checkout - - attach_workspace: - at: web/client - - run: - name: Publish Python package - command: make publish - - run: - name: Update pypirc - command: ./.circleci/update-pypirc.sh - - run: - name: Publish Python Tests package - command: unset TWINE_USERNAME TWINE_PASSWORD && make publish-tests - gh-release: - docker: - - image: cimg/node:20.19.0 - resource_class: small - steps: - - run: - name: Create release on GitHub - command: | - GITHUB_TOKEN="$GITHUB_TOKEN" \ - TARGET_TAG="$CIRCLE_TAG" \ - REPO_OWNER="$CIRCLE_PROJECT_USERNAME" \ - REPO_NAME="$CIRCLE_PROJECT_REPONAME" \ - CONTINUE_ON_ERROR="false" \ - npx https://github.com/TobikoData/circleci-gh-conventional-release - - ui-build: - docker: - - image: cimg/node:20.19.0 - resource_class: medium - steps: - - checkout - - run: - name: Install Dependencies - command: | - pnpm install - - run: - name: Build UI - command: pnpm --prefix web/client run build - - persist_to_workspace: - root: web/client - paths: - - dist - trigger_private_renovate: - docker: - - image: cimg/base:2021.11 - resource_class: small - steps: - - run: - name: Trigger private renovate - command: | - curl --request POST \ - --url $TOBIKO_PRIVATE_CIRCLECI_URL \ - --header "Circle-Token: $TOBIKO_PRIVATE_CIRCLECI_KEY" \ - --header "content-type: application/json" \ - --data '{ - "branch":"main", - "parameters":{ - "run_main_pr":false, - "run_sqlmesh_commit":false, - "run_renovate":true - } - }' - -workflows: - setup-workflow: - jobs: - - path-filtering/filter: - mapping: | - web/client/.* client true - (sqlmesh|tests|examples|web/server)/.* python true - pytest.ini|setup.cfg|setup.py|pyproject.toml python true - \.circleci/.*|Makefile|\.pre-commit-config\.yaml common true - vscode/extensions/.* vscode true - tag: "3.9" - - gh-release: - <<: *on_tag_filter - - ui-build: - <<: *on_main_or_tag_filter - - publish: - <<: *on_main_or_tag_filter - requires: - - ui-build - - trigger_private_renovate: - <<: *on_tag_filter - requires: - - publish diff --git a/.circleci/continue_config.yml b/.circleci/continue_config.yml deleted file mode 100644 index bf27e03f47..0000000000 --- a/.circleci/continue_config.yml +++ /dev/null @@ -1,331 +0,0 @@ -version: 2.1 - -parameters: - client: - type: boolean - default: false - common: - type: boolean - default: false - python: - type: boolean - default: false - -orbs: - windows: circleci/windows@5.0 - -commands: - halt_unless_core: - steps: - - unless: - condition: - or: - - << pipeline.parameters.common >> - - << pipeline.parameters.python >> - - equal: [main, << pipeline.git.branch >>] - steps: - - run: circleci-agent step halt - halt_unless_client: - steps: - - unless: - condition: - or: - - << pipeline.parameters.common >> - - << pipeline.parameters.client >> - - equal: [main, << pipeline.git.branch >>] - steps: - - run: circleci-agent step halt - -jobs: - vscode_test: - docker: - - image: cimg/node:20.19.1-browsers - resource_class: small - steps: - - checkout - - run: - name: Install Dependencies - command: | - pnpm install - - run: - name: Run VSCode extension CI - command: | - cd vscode/extension - pnpm run ci - doc_tests: - docker: - - image: cimg/python:3.10 - resource_class: small - steps: - - halt_unless_core - - checkout - - run: - name: Install dependencies - command: make install-dev install-doc - - run: - name: Run doc tests - command: make doc-test - - style_and_cicd_tests: - parameters: - python_version: - type: string - docker: - - image: cimg/python:<< parameters.python_version >> - resource_class: large - environment: - PYTEST_XDIST_AUTO_NUM_WORKERS: 8 - steps: - - halt_unless_core - - checkout - - run: - name: Install OpenJDK - command: sudo apt-get update && sudo apt-get install default-jdk - - run: - name: Install ODBC - command: sudo apt-get install unixodbc-dev - - run: - name: Install SQLMesh dev dependencies - command: make install-dev - - run: - name: Fix Git URL override - command: git config --global --unset url."ssh://git@github.com".insteadOf - - run: - name: Run linters and code style checks - command: make py-style - - unless: - condition: - equal: ["3.9", << parameters.python_version >>] - steps: - - run: - name: Exercise the benchmarks - command: make benchmark-ci - - run: - name: Run cicd tests - command: make cicd-test - - store_test_results: - path: test-results - - cicd_tests_windows: - executor: - name: windows/default - size: large - steps: - - halt_unless_core - - run: - name: Enable symlinks in git config - command: git config --global core.symlinks true - - checkout - - run: - name: Install System Dependencies - command: | - choco install make which -y - refreshenv - - run: - name: Install SQLMesh dev dependencies - command: | - python -m venv venv - . ./venv/Scripts/activate - python.exe -m pip install --upgrade pip - make install-dev - - run: - name: Run fast unit tests - command: | - . ./venv/Scripts/activate - which python - python --version - make fast-test - - store_test_results: - path: test-results - - migration_test: - docker: - - image: cimg/python:3.10 - resource_class: small - environment: - SQLMESH__DISABLE_ANONYMIZED_ANALYTICS: "1" - steps: - - halt_unless_core - - checkout - - run: - name: Run the migration test - sushi - command: ./.circleci/test_migration.sh sushi "--gateway duckdb_persistent" - - run: - name: Run the migration test - sushi_dbt - command: ./.circleci/test_migration.sh sushi_dbt "--config migration_test_config" - - ui_style: - docker: - - image: cimg/node:20.19.0 - resource_class: small - steps: - - checkout - - restore_cache: - name: Restore pnpm Package Cache - keys: - - pnpm-packages-{{ checksum "pnpm-lock.yaml" }} - - run: - name: Install Dependencies - command: | - pnpm install - - save_cache: - name: Save pnpm Package Cache - key: pnpm-packages-{{ checksum "pnpm-lock.yaml" }} - paths: - - .pnpm-store - - run: - name: Run linters and code style checks - command: pnpm run lint - - ui_test: - docker: - - image: mcr.microsoft.com/playwright:v1.54.1-jammy - resource_class: medium - steps: - - halt_unless_client - - checkout - - restore_cache: - name: Restore pnpm Package Cache - keys: - - pnpm-packages-{{ checksum "pnpm-lock.yaml" }} - - run: - name: Install pnpm package manager - command: | - npm install --global corepack@latest - corepack enable - corepack prepare pnpm@latest-10 --activate - pnpm config set store-dir .pnpm-store - - run: - name: Install Dependencies - command: | - pnpm install - - save_cache: - name: Save pnpm Package Cache - key: pnpm-packages-{{ checksum "pnpm-lock.yaml" }} - paths: - - .pnpm-store - - run: - name: Run tests - command: npm --prefix web/client run test - - engine_tests_docker: - parameters: - engine: - type: string - machine: - image: ubuntu-2404:2024.05.1 - docker_layer_caching: true - resource_class: large - environment: - SQLMESH__DISABLE_ANONYMIZED_ANALYTICS: "1" - steps: - - halt_unless_core - - checkout - - run: - name: Install OS-level dependencies - command: ./.circleci/install-prerequisites.sh "<< parameters.engine >>" - - run: - name: Run tests - command: make << parameters.engine >>-test - no_output_timeout: 20m - - store_test_results: - path: test-results - - engine_tests_cloud: - parameters: - engine: - type: string - docker: - - image: cimg/python:3.12 - resource_class: medium - environment: - PYTEST_XDIST_AUTO_NUM_WORKERS: 4 - SQLMESH__DISABLE_ANONYMIZED_ANALYTICS: "1" - steps: - - halt_unless_core - - checkout - - run: - name: Install OS-level dependencies - command: ./.circleci/install-prerequisites.sh "<< parameters.engine >>" - - run: - name: Generate database name - command: | - UUID=`cat /proc/sys/kernel/random/uuid` - TEST_DB_NAME="circleci_${UUID:0:8}" - echo "export TEST_DB_NAME='$TEST_DB_NAME'" >> "$BASH_ENV" - echo "export SNOWFLAKE_DATABASE='$TEST_DB_NAME'" >> "$BASH_ENV" - echo "export DATABRICKS_CATALOG='$TEST_DB_NAME'" >> "$BASH_ENV" - echo "export REDSHIFT_DATABASE='$TEST_DB_NAME'" >> "$BASH_ENV" - echo "export GCP_POSTGRES_DATABASE='$TEST_DB_NAME'" >> "$BASH_ENV" - echo "export FABRIC_DATABASE='$TEST_DB_NAME'" >> "$BASH_ENV" - - # Make snowflake private key available - echo $SNOWFLAKE_PRIVATE_KEY_RAW | base64 -d > /tmp/snowflake-keyfile.p8 - echo "export SNOWFLAKE_PRIVATE_KEY_FILE='/tmp/snowflake-keyfile.p8'" >> "$BASH_ENV" - - run: - name: Create test database - command: ./.circleci/manage-test-db.sh << parameters.engine >> "$TEST_DB_NAME" up - - run: - name: Run tests - command: | - make << parameters.engine >>-test - no_output_timeout: 20m - - run: - name: Tear down test database - command: ./.circleci/manage-test-db.sh << parameters.engine >> "$TEST_DB_NAME" down - when: always - - store_test_results: - path: test-results - -workflows: - main_pr: - jobs: - - doc_tests - - style_and_cicd_tests: - matrix: - parameters: - python_version: - - "3.9" - - "3.10" - - "3.11" - - "3.12" - - "3.13" - - cicd_tests_windows - - engine_tests_docker: - name: engine_<< matrix.engine >> - matrix: - parameters: - engine: - - duckdb - - postgres - - mysql - - mssql - - trino - - spark - - clickhouse - - risingwave - - engine_tests_cloud: - name: cloud_engine_<< matrix.engine >> - context: - - sqlmesh_cloud_database_integration - requires: - - engine_tests_docker - matrix: - parameters: - engine: - - snowflake - - databricks - - redshift - - bigquery - - clickhouse-cloud - - athena - - fabric - - gcp-postgres - filters: - branches: - only: - - main - - ui_style - - ui_test - - vscode_test - - migration_test diff --git a/.circleci/install-prerequisites.sh b/.github/scripts/install-prerequisites.sh similarity index 89% rename from .circleci/install-prerequisites.sh rename to .github/scripts/install-prerequisites.sh index 446221dba6..6ab602fc37 100755 --- a/.circleci/install-prerequisites.sh +++ b/.github/scripts/install-prerequisites.sh @@ -1,6 +1,6 @@ #!/bin/bash -# This script is intended to be run by an Ubuntu build agent on CircleCI +# This script is intended to be run by an Ubuntu CI build agent # The goal is to install OS-level dependencies that are required before trying to install Python dependencies set -e @@ -25,7 +25,7 @@ elif [ "$ENGINE" == "fabric" ]; then sudo dpkg -i packages-microsoft-prod.deb rm packages-microsoft-prod.deb - ENGINE_DEPENDENCIES="msodbcsql18" + ENGINE_DEPENDENCIES="msodbcsql18" fi ALL_DEPENDENCIES="$COMMON_DEPENDENCIES $ENGINE_DEPENDENCIES" @@ -39,4 +39,4 @@ if [ "$ENGINE" == "spark" ]; then java -version fi -echo "All done" \ No newline at end of file +echo "All done" diff --git a/.circleci/manage-test-db.sh b/.github/scripts/manage-test-db.sh similarity index 88% rename from .circleci/manage-test-db.sh rename to .github/scripts/manage-test-db.sh index b6e9c265c9..29d11afcc0 100755 --- a/.circleci/manage-test-db.sh +++ b/.github/scripts/manage-test-db.sh @@ -68,10 +68,10 @@ redshift_down() { EXIT_CODE=1 ATTEMPTS=0 while [ $EXIT_CODE -ne 0 ] && [ $ATTEMPTS -lt 5 ]; do - # note: sometimes this pg_terminate_backend() call can randomly fail with: ERROR: Insufficient privileges + # note: sometimes this pg_terminate_backend() call can randomly fail with: ERROR: Insufficient privileges # if it does, let's proceed with the drop anyway rather than aborting and never attempting the drop redshift_exec "select pg_terminate_backend(procpid) from pg_stat_activity where datname = '$1'" || true - + # perform drop redshift_exec "drop database $1;" && EXIT_CODE=$? || EXIT_CODE=$? if [ $EXIT_CODE -ne 0 ]; then @@ -103,14 +103,16 @@ clickhouse-cloud_init() { # GCP Postgres gcp-postgres_init() { - # Download and start Cloud SQL Proxy - curl -fsSL -o cloud-sql-proxy https://storage.googleapis.com/cloud-sql-connectors/cloud-sql-proxy/v2.18.0/cloud-sql-proxy.linux.amd64 - chmod +x cloud-sql-proxy + # Download Cloud SQL Proxy if not already present + if [ ! -f cloud-sql-proxy ]; then + curl -fsSL -o cloud-sql-proxy https://storage.googleapis.com/cloud-sql-connectors/cloud-sql-proxy/v2.18.0/cloud-sql-proxy.linux.amd64 + chmod +x cloud-sql-proxy + fi echo "$GCP_POSTGRES_KEYFILE_JSON" > /tmp/keyfile.json - ./cloud-sql-proxy --credentials-file /tmp/keyfile.json $GCP_POSTGRES_INSTANCE_CONNECTION_STRING & - - # Wait for proxy to start - sleep 5 + if ! pgrep -x cloud-sql-proxy > /dev/null; then + ./cloud-sql-proxy --credentials-file /tmp/keyfile.json $GCP_POSTGRES_INSTANCE_CONNECTION_STRING & + sleep 5 + fi } gcp-postgres_exec() { @@ -126,13 +128,13 @@ gcp-postgres_down() { } # Fabric -fabric_init() { +fabric_init() { python --version #note: as at 2025-08-20, ms-fabric-cli is pinned to Python >= 3.10, <3.13 pip install ms-fabric-cli - + # to prevent the '[EncryptionFailed] An error occurred with the encrypted cache.' error # ref: https://microsoft.github.io/fabric-cli/#switch-to-interactive-mode-optional - fab config set encryption_fallback_enabled true + fab config set encryption_fallback_enabled true echo "Logging in to Fabric" fab auth login -u $FABRIC_CLIENT_ID -p $FABRIC_CLIENT_SECRET --tenant $FABRIC_TENANT_ID diff --git a/.circleci/test_migration.sh b/.github/scripts/test_migration.sh similarity index 91% rename from .circleci/test_migration.sh rename to .github/scripts/test_migration.sh index bb1776550a..ec45772c73 100755 --- a/.circleci/test_migration.sh +++ b/.github/scripts/test_migration.sh @@ -30,12 +30,14 @@ cp -r "$EXAMPLE_DIR" "$TEST_DIR" git checkout $LAST_TAG # Install dependencies from the previous release. +uv venv .venv --clear +source .venv/bin/activate make install-dev # this is only needed temporarily until the released tag for $LAST_TAG includes this config if [ "$EXAMPLE_NAME" == "sushi_dbt" ]; then echo 'migration_test_config = sqlmesh_config(Path(__file__).parent, dbt_target_name="duckdb")' >> $TEST_DIR/config.py -fi +fi # Run initial plan pushd $TEST_DIR @@ -44,10 +46,12 @@ sqlmesh $SQLMESH_OPTS plan --no-prompts --auto-apply rm -rf .cache popd -# Switch back to the starting state of the repository +# Switch back to the starting state of the repository git checkout - # Install updated dependencies. +uv venv .venv --clear +source .venv/bin/activate make install-dev # Migrate and make sure the diff is empty diff --git a/.circleci/update-pypirc.sh b/.github/scripts/update-pypirc.sh similarity index 100% rename from .circleci/update-pypirc.sh rename to .github/scripts/update-pypirc.sh diff --git a/.circleci/wait-for-db.sh b/.github/scripts/wait-for-db.sh similarity index 98% rename from .circleci/wait-for-db.sh rename to .github/scripts/wait-for-db.sh index a313320279..07502e3898 100755 --- a/.circleci/wait-for-db.sh +++ b/.github/scripts/wait-for-db.sh @@ -80,4 +80,4 @@ while [ $EXIT_CODE -ne 0 ]; do fi done -echo "$ENGINE is ready!" \ No newline at end of file +echo "$ENGINE is ready!" diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 69e93635dc..4395c56313 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -6,11 +6,392 @@ on: branches: - main concurrency: - group: 'pr-${{ github.event.pull_request.number }}' + group: pr-${{ github.event.pull_request.number || github.sha }} cancel-in-progress: true permissions: contents: read jobs: + changes: + runs-on: ubuntu-latest + outputs: + python: ${{ steps.filter.outputs.python }} + client: ${{ steps.filter.outputs.client }} + ci: ${{ steps.filter.outputs.ci }} + steps: + - uses: actions/checkout@v5 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + python: + - 'sqlmesh/**' + - 'tests/**' + - 'examples/**' + - 'web/server/**' + - 'pytest.ini' + - 'setup.cfg' + - 'setup.py' + - 'pyproject.toml' + client: + - 'web/client/**' + ci: + - '.github/**' + - 'Makefile' + - '.pre-commit-config.yaml' + + doc-tests: + needs: changes + if: + needs.changes.outputs.python == 'true' || needs.changes.outputs.ci == + 'true' || github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + env: + UV: '1' + steps: + - uses: actions/checkout@v5 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.10' + - name: Install uv + uses: astral-sh/setup-uv@v7 + - name: Install dependencies + run: | + uv venv .venv + source .venv/bin/activate + make install-dev install-doc + - name: Run doc tests + run: | + source .venv/bin/activate + make doc-test + + style-and-cicd-tests: + needs: changes + if: + needs.changes.outputs.python == 'true' || needs.changes.outputs.ci == + 'true' || github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + env: + PYTEST_XDIST_AUTO_NUM_WORKERS: 2 + UV: '1' + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + - name: Install uv + uses: astral-sh/setup-uv@v7 + - name: Install OpenJDK and ODBC + run: + sudo apt-get update && sudo apt-get install -y default-jdk + unixodbc-dev + - name: Install SQLMesh dev dependencies + run: | + uv venv .venv + source .venv/bin/activate + make install-dev + - name: Fix Git URL override + run: + git config --global --unset url."ssh://git@github.com".insteadOf || + true + - name: Run linters and code style checks + run: | + source .venv/bin/activate + make py-style + - name: Exercise the benchmarks + if: matrix.python-version != '3.9' + run: | + source .venv/bin/activate + make benchmark-ci + - name: Run cicd tests + run: | + source .venv/bin/activate + make cicd-test + - name: Upload test results + uses: actions/upload-artifact@v5 + if: ${{ !cancelled() }} + with: + name: test-results-style-cicd-${{ matrix.python-version }} + path: test-results/ + retention-days: 7 + + cicd-tests-windows: + needs: changes + if: + needs.changes.outputs.python == 'true' || needs.changes.outputs.ci == + 'true' || github.ref == 'refs/heads/main' + runs-on: windows-latest + steps: + - name: Enable symlinks in git config + run: git config --global core.symlinks true + - uses: actions/checkout@v5 + - name: Install make + run: choco install make which -y + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + - name: Install SQLMesh dev dependencies + run: | + python -m venv venv + . ./venv/Scripts/activate + python.exe -m pip install --upgrade pip + make install-dev + - name: Run fast unit tests + run: | + . ./venv/Scripts/activate + which python + python --version + make fast-test + - name: Upload test results + uses: actions/upload-artifact@v5 + if: ${{ !cancelled() }} + with: + name: test-results-windows + path: test-results/ + retention-days: 7 + + migration-test: + needs: changes + if: + needs.changes.outputs.python == 'true' || needs.changes.outputs.ci == + 'true' || github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + env: + SQLMESH__DISABLE_ANONYMIZED_ANALYTICS: '1' + UV: '1' + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.10' + - name: Install uv + uses: astral-sh/setup-uv@v7 + - name: Run migration test - sushi + run: + ./.github/scripts/test_migration.sh sushi "--gateway + duckdb_persistent" + - name: Run migration test - sushi_dbt + run: + ./.github/scripts/test_migration.sh sushi_dbt "--config + migration_test_config" + + ui-style: + needs: [changes] + if: + needs.changes.outputs.client == 'true' || needs.changes.outputs.ci == + 'true' || github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v6 + with: + node-version: '20' + - uses: pnpm/action-setup@v4 + with: + version: latest + - name: Get pnpm store directory + id: pnpm-cache + run: echo "store=$(pnpm store path)" >> $GITHUB_OUTPUT + - uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-cache.outputs.store }} + key: pnpm-store-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: pnpm-store- + - name: Install dependencies + run: pnpm install + - name: Run linters and code style checks + run: pnpm run lint + + ui-test: + needs: changes + if: + needs.changes.outputs.client == 'true' || needs.changes.outputs.ci == + 'true' || github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.54.1-jammy + steps: + - uses: actions/checkout@v5 + - name: Install pnpm via corepack + run: | + npm install --global corepack@latest + corepack enable + corepack prepare pnpm@latest-10 --activate + pnpm config set store-dir .pnpm-store + - name: Install dependencies + run: pnpm install + - name: Build UI + run: npm --prefix web/client run build + - name: Run unit tests + run: npm --prefix web/client run test:unit + - name: Run e2e tests + run: npm --prefix web/client run test:e2e + env: + PLAYWRIGHT_SKIP_BUILD: '1' + HOME: /root + + engine-tests-docker: + needs: changes + if: + needs.changes.outputs.python == 'true' || needs.changes.outputs.ci == + 'true' || github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + engine: + [duckdb, postgres, mysql, mssql, trino, spark, clickhouse, risingwave] + env: + PYTEST_XDIST_AUTO_NUM_WORKERS: 2 + SQLMESH__DISABLE_ANONYMIZED_ANALYTICS: '1' + UV: '1' + steps: + - uses: actions/checkout@v5 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + - name: Install uv + uses: astral-sh/setup-uv@v7 + - name: Install SQLMesh dev dependencies + run: | + uv venv .venv + source .venv/bin/activate + make install-dev + - name: Install OS-level dependencies + run: ./.github/scripts/install-prerequisites.sh "${{ matrix.engine }}" + - name: Run tests + run: | + source .venv/bin/activate + make ${{ matrix.engine }}-test + - name: Upload test results + uses: actions/upload-artifact@v5 + if: ${{ !cancelled() }} + with: + name: test-results-docker-${{ matrix.engine }} + path: test-results/ + retention-days: 7 + + engine-tests-cloud: + needs: engine-tests-docker + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + engine: + [ + snowflake, + databricks, + redshift, + bigquery, + clickhouse-cloud, + athena, + fabric, + gcp-postgres, + ] + env: + PYTEST_XDIST_AUTO_NUM_WORKERS: 4 + SQLMESH__DISABLE_ANONYMIZED_ANALYTICS: '1' + UV: '1' + SNOWFLAKE_ACCOUNT: ${{ secrets.SNOWFLAKE_ACCOUNT }} + SNOWFLAKE_USER: ${{ secrets.SNOWFLAKE_USER }} + SNOWFLAKE_WAREHOUSE: ${{ secrets.SNOWFLAKE_WAREHOUSE }} + SNOWFLAKE_AUTHENTICATOR: SNOWFLAKE_JWT + DATABRICKS_SERVER_HOSTNAME: ${{ secrets.DATABRICKS_SERVER_HOSTNAME }} + DATABRICKS_HOST: ${{ secrets.DATABRICKS_SERVER_HOSTNAME }} + DATABRICKS_HTTP_PATH: ${{ secrets.DATABRICKS_HTTP_PATH }} + DATABRICKS_CLIENT_ID: ${{ secrets.DATABRICKS_CLIENT_ID }} + DATABRICKS_CLIENT_SECRET: ${{ secrets.DATABRICKS_CLIENT_SECRET }} + DATABRICKS_CONNECT_VERSION: ${{ secrets.DATABRICKS_CONNECT_VERSION }} + REDSHIFT_HOST: ${{ secrets.REDSHIFT_HOST }} + REDSHIFT_PORT: ${{ secrets.REDSHIFT_PORT }} + REDSHIFT_USER: ${{ secrets.REDSHIFT_USER }} + REDSHIFT_PASSWORD: ${{ secrets.REDSHIFT_PASSWORD }} + BIGQUERY_KEYFILE: ${{ secrets.BIGQUERY_KEYFILE }} + BIGQUERY_KEYFILE_CONTENTS: ${{ secrets.BIGQUERY_KEYFILE_CONTENTS }} + CLICKHOUSE_CLOUD_HOST: ${{ secrets.CLICKHOUSE_CLOUD_HOST }} + CLICKHOUSE_CLOUD_USERNAME: ${{ secrets.CLICKHOUSE_CLOUD_USERNAME }} + CLICKHOUSE_CLOUD_PASSWORD: ${{ secrets.CLICKHOUSE_CLOUD_PASSWORD }} + GCP_POSTGRES_KEYFILE_JSON: ${{ secrets.GCP_POSTGRES_KEYFILE_JSON }} + GCP_POSTGRES_INSTANCE_CONNECTION_STRING: + ${{ secrets.GCP_POSTGRES_INSTANCE_CONNECTION_STRING }} + GCP_POSTGRES_USER: ${{ secrets.GCP_POSTGRES_USER }} + GCP_POSTGRES_PASSWORD: ${{ secrets.GCP_POSTGRES_PASSWORD }} + ATHENA_S3_WAREHOUSE_LOCATION: ${{ secrets.ATHENA_S3_WAREHOUSE_LOCATION }} + ATHENA_WORK_GROUP: ${{ secrets.ATHENA_WORK_GROUP }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_REGION: ${{ secrets.AWS_REGION }} + FABRIC_HOST: ${{ secrets.FABRIC_HOST }} + FABRIC_CLIENT_ID: ${{ secrets.FABRIC_CLIENT_ID }} + FABRIC_CLIENT_SECRET: ${{ secrets.FABRIC_CLIENT_SECRET }} + FABRIC_TENANT_ID: ${{ secrets.FABRIC_TENANT_ID }} + FABRIC_WORKSPACE_ID: ${{ secrets.FABRIC_WORKSPACE_ID }} + steps: + - uses: actions/checkout@v5 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + - name: Install uv + uses: astral-sh/setup-uv@v7 + - name: Install OS-level dependencies + run: ./.github/scripts/install-prerequisites.sh "${{ matrix.engine }}" + - name: Install SQLMesh dev dependencies + run: | + uv venv .venv + source .venv/bin/activate + make install-dev + - name: Generate database name and setup credentials + run: | + UUID=$(cat /proc/sys/kernel/random/uuid) + TEST_DB_NAME="ci_${UUID:0:8}" + echo "TEST_DB_NAME=$TEST_DB_NAME" >> $GITHUB_ENV + echo "SNOWFLAKE_DATABASE=$TEST_DB_NAME" >> $GITHUB_ENV + echo "DATABRICKS_CATALOG=$TEST_DB_NAME" >> $GITHUB_ENV + echo "REDSHIFT_DATABASE=$TEST_DB_NAME" >> $GITHUB_ENV + echo "GCP_POSTGRES_DATABASE=$TEST_DB_NAME" >> $GITHUB_ENV + echo "FABRIC_DATABASE=$TEST_DB_NAME" >> $GITHUB_ENV + + echo "$SNOWFLAKE_PRIVATE_KEY_RAW" | base64 -d > /tmp/snowflake-keyfile.p8 + echo "SNOWFLAKE_PRIVATE_KEY_FILE=/tmp/snowflake-keyfile.p8" >> $GITHUB_ENV + env: + SNOWFLAKE_PRIVATE_KEY_RAW: ${{ secrets.SNOWFLAKE_PRIVATE_KEY_RAW }} + - name: Create test database + run: + ./.github/scripts/manage-test-db.sh "${{ matrix.engine }}" + "$TEST_DB_NAME" up + - name: Run tests + run: | + source .venv/bin/activate + make ${{ matrix.engine }}-test + - name: Tear down test database + if: always() + run: + ./.github/scripts/manage-test-db.sh "${{ matrix.engine }}" + "$TEST_DB_NAME" down + - name: Upload test results + uses: actions/upload-artifact@v5 + if: ${{ !cancelled() }} + with: + name: test-results-cloud-${{ matrix.engine }} + path: test-results/ + retention-days: 7 + test-vscode: env: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 @@ -100,30 +481,30 @@ jobs: if [[ "${{ matrix.dbt-version }}" == "1.3" ]] || \ [[ "${{ matrix.dbt-version }}" == "1.4" ]] || \ [[ "${{ matrix.dbt-version }}" == "1.5" ]]; then - + echo "DBT version is ${{ matrix.dbt-version }} (< 1.6.0), removing semantic_models and metrics sections..." - + schema_file="tests/fixtures/dbt/sushi_test/models/schema.yml" if [[ -f "$schema_file" ]]; then echo "Modifying $schema_file..." - + # Create a temporary file temp_file=$(mktemp) - + # Use awk to remove semantic_models and metrics sections awk ' /^semantic_models:/ { in_semantic=1; next } /^metrics:/ { in_metrics=1; next } - /^[^ ]/ && (in_semantic || in_metrics) { - in_semantic=0; - in_metrics=0 + /^[^ ]/ && (in_semantic || in_metrics) { + in_semantic=0; + in_metrics=0 } !in_semantic && !in_metrics { print } ' "$schema_file" > "$temp_file" - + # Move the temp file back mv "$temp_file" "$schema_file" - + echo "Successfully removed semantic_models and metrics sections" else echo "Schema file not found at $schema_file, skipping..." diff --git a/.github/workflows/private-repo-test.yaml b/.github/workflows/private-repo-test.yaml deleted file mode 100644 index 9b2365f48a..0000000000 --- a/.github/workflows/private-repo-test.yaml +++ /dev/null @@ -1,97 +0,0 @@ -name: Private Repo Testing - -on: - pull_request_target: - branches: - - main - -concurrency: - group: 'private-test-${{ github.event.pull_request.number }}' - cancel-in-progress: true - -permissions: - contents: read - -jobs: - trigger-private-test: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v5 - with: - fetch-depth: 0 - ref: ${{ github.event.pull_request.head.sha || github.ref }} - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: '3.12' - - name: Install uv - uses: astral-sh/setup-uv@v7 - - name: Set up Node.js for UI build - uses: actions/setup-node@v6 - with: - node-version: '20' - - name: Install pnpm - uses: pnpm/action-setup@v4 - with: - version: latest - - name: Install UI dependencies - run: pnpm install - - name: Build UI - run: pnpm --prefix web/client run build - - name: Install Python dependencies - run: | - python -m venv .venv - source .venv/bin/activate - pip install build twine setuptools_scm - - name: Generate development version - id: version - run: | - source .venv/bin/activate - # Generate a PEP 440 compliant unique version including run attempt - BASE_VERSION=$(python .github/scripts/get_scm_version.py) - COMMIT_SHA=$(git rev-parse --short HEAD) - # Use PEP 440 compliant format: base.devN+pr.sha.attempt - UNIQUE_VERSION="${BASE_VERSION}+pr${{ github.event.pull_request.number }}.${COMMIT_SHA}.run${{ github.run_attempt }}" - echo "version=$UNIQUE_VERSION" >> $GITHUB_OUTPUT - echo "Generated unique version with run attempt: $UNIQUE_VERSION" - - name: Build package - env: - SETUPTOOLS_SCM_PRETEND_VERSION: ${{ steps.version.outputs.version }} - run: | - source .venv/bin/activate - python -m build - - name: Configure PyPI for private repository - env: - TOBIKO_PRIVATE_PYPI_URL: ${{ secrets.TOBIKO_PRIVATE_PYPI_URL }} - TOBIKO_PRIVATE_PYPI_KEY: ${{ secrets.TOBIKO_PRIVATE_PYPI_KEY }} - run: ./.circleci/update-pypirc.sh - - name: Publish to private PyPI - run: | - source .venv/bin/activate - python -m twine upload -r tobiko-private dist/* - - name: Publish Python Tests package - env: - SETUPTOOLS_SCM_PRETEND_VERSION: ${{ steps.version.outputs.version }} - run: | - source .venv/bin/activate - unset TWINE_USERNAME TWINE_PASSWORD && make publish-tests - - name: Get GitHub App token - id: get_token - uses: actions/create-github-app-token@v2 - with: - private-key: ${{ secrets.TOBIKO_RENOVATE_BOT_PRIVATE_KEY }} - app-id: ${{ secrets.TOBIKO_RENOVATE_BOT_APP_ID }} - owner: ${{ secrets.PRIVATE_REPO_OWNER }} - - name: Trigger private repository workflow - uses: convictional/trigger-workflow-and-wait@v1.6.5 - with: - owner: ${{ secrets.PRIVATE_REPO_OWNER }} - repo: ${{ secrets.PRIVATE_REPO_NAME }} - github_token: ${{ steps.get_token.outputs.token }} - workflow_file_name: ${{ secrets.PRIVATE_WORKFLOW_FILE }} - client_payload: | - { - "package_version": "${{ steps.version.outputs.version }}", - "pr_number": "${{ github.event.pull_request.number }}" - } diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000000..75512ffd72 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,71 @@ +name: Release +on: + push: + tags: + - 'v*.*.*' +permissions: + contents: write +jobs: + ui-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v6 + with: + node-version: '20' + - uses: pnpm/action-setup@v4 + with: + version: latest + - name: Install dependencies + run: pnpm install + - name: Build UI + run: pnpm --prefix web/client run build + - name: Upload UI build artifact + uses: actions/upload-artifact@v5 + with: + name: ui-dist + path: web/client/dist/ + retention-days: 1 + + publish: + needs: ui-build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Download UI build artifact + uses: actions/download-artifact@v4 + with: + name: ui-dist + path: web/client/dist/ + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.10' + - name: Install uv + uses: astral-sh/setup-uv@v7 + - name: Install build dependencies + run: pip install build twine setuptools_scm + - name: Publish Python package + run: make publish + env: + TWINE_USERNAME: ${{ secrets.TWINE_USERNAME }} + TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }} + - name: Update pypirc for private repository + run: ./.github/scripts/update-pypirc.sh + env: + TOBIKO_PRIVATE_PYPI_URL: ${{ secrets.TOBIKO_PRIVATE_PYPI_URL }} + TOBIKO_PRIVATE_PYPI_KEY: ${{ secrets.TOBIKO_PRIVATE_PYPI_KEY }} + - name: Publish Python Tests package + run: unset TWINE_USERNAME TWINE_PASSWORD && make publish-tests + + gh-release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Create release on GitHub + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + tag_name: ${{ github.ref_name }} diff --git a/Makefile b/Makefile index e7a78de472..843beb0624 100644 --- a/Makefile +++ b/Makefile @@ -130,7 +130,7 @@ slow-test: pytest -n auto -m "(fast or slow) and not cicdonly" && pytest -m "isolated" && pytest -m "registry_isolation" && pytest -m "dialect_isolated" cicd-test: - pytest -n auto -m "fast or slow" --junitxml=test-results/junit-cicd.xml && pytest -m "isolated" && pytest -m "registry_isolation" && pytest -m "dialect_isolated" + pytest -n auto -m "(fast or slow) and not pyspark" --junitxml=test-results/junit-cicd.xml && pytest -m "pyspark" && pytest -m "isolated" && pytest -m "registry_isolation" && pytest -m "dialect_isolated" core-fast-test: pytest -n auto -m "fast and not web and not github and not dbt and not jupyter" @@ -166,7 +166,7 @@ web-test: pytest -n auto -m "web" guard-%: - @ if [ "${${*}}" = "" ]; then \ + @ if ! printenv ${*} > /dev/null 2>&1; then \ echo "Environment variable $* not set"; \ exit 1; \ fi @@ -176,7 +176,7 @@ engine-%-install: engine-docker-%-up: docker compose -f ./tests/core/engine_adapter/integration/docker/compose.${*}.yaml up -d - ./.circleci/wait-for-db.sh ${*} + ./.github/scripts/wait-for-db.sh ${*} engine-%-up: engine-%-install engine-docker-%-up @echo "Engine '${*}' is up and running" diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 480d186fa1..576ce95d91 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -878,7 +878,6 @@ def test_dlt_pipeline_errors(runner, tmp_path): assert "Error: Could not attach to pipeline" in result.output -@time_machine.travel(FREEZE_TIME) def test_dlt_filesystem_pipeline(tmp_path): import dlt @@ -982,7 +981,6 @@ def test_dlt_filesystem_pipeline(tmp_path): rmtree(storage_path) -@time_machine.travel(FREEZE_TIME) def test_dlt_pipeline(runner, tmp_path): from dlt.common.pipeline import get_dlt_pipelines_dir diff --git a/tests/core/engine_adapter/integration/config.yaml b/tests/core/engine_adapter/integration/config.yaml index 0b1ecd8193..5635f4e1ba 100644 --- a/tests/core/engine_adapter/integration/config.yaml +++ b/tests/core/engine_adapter/integration/config.yaml @@ -128,6 +128,7 @@ gateways: warehouse: {{ env_var('SNOWFLAKE_WAREHOUSE') }} database: {{ env_var('SNOWFLAKE_DATABASE') }} user: {{ env_var('SNOWFLAKE_USER') }} + authenticator: SNOWFLAKE_JWT private_key_path: {{ env_var('SNOWFLAKE_PRIVATE_KEY_FILE', 'tests/fixtures/snowflake/rsa_key_no_pass.p8') }} check_import: false state_connection: diff --git a/tests/core/engine_adapter/integration/test_integration_snowflake.py b/tests/core/engine_adapter/integration/test_integration_snowflake.py index f9862c51cb..7f3c38be46 100644 --- a/tests/core/engine_adapter/integration/test_integration_snowflake.py +++ b/tests/core/engine_adapter/integration/test_integration_snowflake.py @@ -186,6 +186,7 @@ def _get_data_object(table: exp.Table) -> DataObject: assert not metadata.is_clustered +@pytest.mark.skip(reason="External volume LIST privileges not configured for CI test databases") def test_create_iceberg_table(ctx: TestContext) -> None: # Note: this test relies on a default Catalog and External Volume being configured in Snowflake # ref: https://docs.snowflake.com/en/user-guide/tables-iceberg-configure-catalog-integration#set-a-default-catalog-at-the-account-database-or-schema-level diff --git a/tests/core/test_test.py b/tests/core/test_test.py index 43d0f333c3..d679f09393 100644 --- a/tests/core/test_test.py +++ b/tests/core/test_test.py @@ -1718,10 +1718,12 @@ def test_generate_input_data_using_sql(mocker: MockerFixture, tmp_path: Path) -> ) +@pytest.mark.pyspark def test_pyspark_python_model(tmp_path: Path) -> None: spark_connection_config = SparkConnectionConfig( config={ "spark.master": "local", + "spark.driver.memory": "512m", "spark.sql.warehouse.dir": f"{tmp_path}/data_dir", "spark.driver.extraJavaOptions": f"-Dderby.system.home={tmp_path}/derby_dir", }, diff --git a/tests/engines/spark/conftest.py b/tests/engines/spark/conftest.py index 933bc7870f..ce6a99ea35 100644 --- a/tests/engines/spark/conftest.py +++ b/tests/engines/spark/conftest.py @@ -9,6 +9,7 @@ def spark_session() -> t.Generator[SparkSession, None, None]: session = ( SparkSession.builder.master("local") .appName("SQLMesh Test") + .config("spark.driver.memory", "512m") .enableHiveSupport() .getOrCreate() ) diff --git a/web/client/playwright.config.ts b/web/client/playwright.config.ts index afaa00c716..c574869b87 100644 --- a/web/client/playwright.config.ts +++ b/web/client/playwright.config.ts @@ -50,7 +50,10 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { - command: 'npm run build && npm run preview', + command: + process.env.PLAYWRIGHT_SKIP_BUILD != null + ? 'npm run preview' + : 'npm run build && npm run preview', url: URL, reuseExistingServer: process.env.CI == null, timeout: 120000, // Two minutes diff --git a/web/client/vite.config.ts b/web/client/vite.config.ts index 206504cf4b..4b98b21c68 100644 --- a/web/client/vite.config.ts +++ b/web/client/vite.config.ts @@ -68,5 +68,6 @@ export default defineConfig({ }, preview: { port: 8005, + host: '127.0.0.1', }, }) From 7d5c0c85a01a9367118bfffe55063d9a4fc82b64 Mon Sep 17 00:00:00 2001 From: Vaggelis Danias Date: Thu, 19 Mar 2026 10:31:29 +0200 Subject: [PATCH 34/39] Chore!!: Migrate sqlglot to v30 (#5736) Signed-off-by: vaggelisd --- pyproject.toml | 2 +- sqlmesh/core/_typing.py | 4 +- sqlmesh/core/audit/definition.py | 30 ++-- sqlmesh/core/config/linter.py | 2 +- sqlmesh/core/config/model.py | 6 +- sqlmesh/core/context.py | 22 +-- sqlmesh/core/context_diff.py | 2 +- sqlmesh/core/dialect.py | 151 +++++++++--------- sqlmesh/core/engine_adapter/athena.py | 30 ++-- sqlmesh/core/engine_adapter/base.py | 100 ++++++------ sqlmesh/core/engine_adapter/base_postgres.py | 2 +- sqlmesh/core/engine_adapter/bigquery.py | 36 ++--- sqlmesh/core/engine_adapter/clickhouse.py | 42 +++-- sqlmesh/core/engine_adapter/databricks.py | 12 +- sqlmesh/core/engine_adapter/duckdb.py | 4 +- sqlmesh/core/engine_adapter/mixins.py | 75 +++++---- sqlmesh/core/engine_adapter/mssql.py | 10 +- sqlmesh/core/engine_adapter/mysql.py | 2 +- sqlmesh/core/engine_adapter/postgres.py | 8 +- sqlmesh/core/engine_adapter/redshift.py | 16 +- sqlmesh/core/engine_adapter/snowflake.py | 32 ++-- sqlmesh/core/engine_adapter/spark.py | 6 +- sqlmesh/core/engine_adapter/trino.py | 10 +- sqlmesh/core/lineage.py | 6 +- sqlmesh/core/macros.py | 114 +++++++------ sqlmesh/core/metric/definition.py | 18 +-- sqlmesh/core/metric/rewriter.py | 16 +- sqlmesh/core/model/cache.py | 2 +- sqlmesh/core/model/common.py | 34 ++-- sqlmesh/core/model/decorator.py | 2 +- sqlmesh/core/model/definition.py | 110 ++++++------- sqlmesh/core/model/kind.py | 61 +++---- sqlmesh/core/model/meta.py | 64 +++++--- sqlmesh/core/model/seed.py | 4 +- sqlmesh/core/node.py | 8 +- sqlmesh/core/reference.py | 2 +- sqlmesh/core/renderer.py | 22 +-- sqlmesh/core/schema_diff.py | 18 +-- sqlmesh/core/selector.py | 12 +- sqlmesh/core/snapshot/evaluator.py | 14 +- sqlmesh/core/state_sync/common.py | 16 +- sqlmesh/core/state_sync/db/environment.py | 6 +- sqlmesh/core/state_sync/db/snapshot.py | 2 +- sqlmesh/core/state_sync/db/utils.py | 6 +- sqlmesh/core/state_sync/export_import.py | 2 +- sqlmesh/core/table_diff.py | 18 +-- sqlmesh/core/test/definition.py | 4 +- sqlmesh/dbt/model.py | 2 +- sqlmesh/lsp/hints.py | 5 +- sqlmesh/lsp/reference.py | 2 +- sqlmesh/utils/date.py | 4 +- sqlmesh/utils/jinja.py | 3 +- sqlmesh/utils/lineage.py | 6 +- sqlmesh/utils/metaprogramming.py | 37 ++++- sqlmesh/utils/pydantic.py | 38 +++-- tests/conftest.py | 2 +- tests/core/engine_adapter/__init__.py | 2 +- .../engine_adapter/integration/__init__.py | 6 +- .../integration/test_integration_athena.py | 4 +- .../test_integration_clickhouse.py | 4 +- tests/core/engine_adapter/test_athena.py | 2 +- tests/core/engine_adapter/test_bigquery.py | 2 +- tests/core/engine_adapter/test_clickhouse.py | 2 +- tests/core/engine_adapter/test_snowflake.py | 2 +- .../core/integration/test_auto_restatement.py | 4 +- tests/core/integration/utils.py | 5 +- tests/core/test_audit.py | 4 +- tests/core/test_config.py | 3 +- tests/core/test_macros.py | 16 +- tests/core/test_model.py | 20 +-- tests/core/test_plan.py | 2 +- tests/core/test_snapshot_evaluator.py | 4 +- tests/utils/test_metaprogramming.py | 18 ++- web/server/api/endpoints/table_diff.py | 2 +- 74 files changed, 712 insertions(+), 654 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ebfc112567..56d66ecff5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ dependencies = [ "requests", "rich[jupyter]", "ruamel.yaml", - "sqlglot[rs]~=28.10.1", + "sqlglot~=30.0.1", "tenacity", "time-machine", "json-stream" diff --git a/sqlmesh/core/_typing.py b/sqlmesh/core/_typing.py index 8e28312c1a..2bc69e901b 100644 --- a/sqlmesh/core/_typing.py +++ b/sqlmesh/core/_typing.py @@ -8,8 +8,8 @@ if t.TYPE_CHECKING: TableName = t.Union[str, exp.Table] SchemaName = t.Union[str, exp.Table] - SessionProperties = t.Dict[str, t.Union[exp.Expression, str, int, float, bool]] - CustomMaterializationProperties = t.Dict[str, t.Union[exp.Expression, str, int, float, bool]] + SessionProperties = t.Dict[str, t.Union[exp.Expr, str, int, float, bool]] + CustomMaterializationProperties = t.Dict[str, t.Union[exp.Expr, str, int, float, bool]] if sys.version_info >= (3, 11): diff --git a/sqlmesh/core/audit/definition.py b/sqlmesh/core/audit/definition.py index 9f470872fe..4c90151ee4 100644 --- a/sqlmesh/core/audit/definition.py +++ b/sqlmesh/core/audit/definition.py @@ -67,7 +67,7 @@ class AuditMixin(AuditCommonMetaMixin): """ query_: ParsableSql - defaults: t.Dict[str, exp.Expression] + defaults: t.Dict[str, exp.Expr] expressions_: t.Optional[t.List[ParsableSql]] jinja_macros: JinjaMacroRegistry formatting: t.Optional[bool] @@ -77,10 +77,10 @@ def query(self) -> t.Union[exp.Query, d.JinjaQuery]: return t.cast(t.Union[exp.Query, d.JinjaQuery], self.query_.parse(self.dialect)) @property - def expressions(self) -> t.List[exp.Expression]: + def expressions(self) -> t.List[exp.Expr]: if not self.expressions_: return [] - result = [] + result: t.List[exp.Expr] = [] for e in self.expressions_: parsed = e.parse(self.dialect) if not isinstance(parsed, exp.Semicolon): @@ -95,7 +95,7 @@ def macro_definitions(self) -> t.List[d.MacroDef]: @field_validator("name", "dialect", mode="before", check_fields=False) def audit_string_validator(cls: t.Type, v: t.Any) -> t.Optional[str]: - if isinstance(v, exp.Expression): + if isinstance(v, exp.Expr): return v.name.lower() return str(v).lower() if v is not None else None @@ -111,9 +111,7 @@ def audit_map_validator(cls: t.Type, v: t.Any, values: t.Any) -> t.Dict[str, t.A if isinstance(v, dict): dialect = get_dialect(values) return { - key: value - if isinstance(value, exp.Expression) - else d.parse_one(str(value), dialect=dialect) + key: value if isinstance(value, exp.Expr) else d.parse_one(str(value), dialect=dialect) for key, value in v.items() } raise_config_error("Defaults must be a tuple of exp.EQ or a dict", error_type=AuditConfigError) @@ -133,7 +131,7 @@ class ModelAudit(PydanticModel, AuditMixin, DbtInfoMixin, frozen=True): blocking: bool = True standalone: t.Literal[False] = False query_: ParsableSql = Field(alias="query") - defaults: t.Dict[str, exp.Expression] = {} + defaults: t.Dict[str, exp.Expr] = {} expressions_: t.Optional[t.List[ParsableSql]] = Field(default=None, alias="expressions") jinja_macros: JinjaMacroRegistry = JinjaMacroRegistry() formatting: t.Optional[bool] = Field(default=None, exclude=True) @@ -169,7 +167,7 @@ class StandaloneAudit(_Node, AuditMixin): blocking: bool = False standalone: t.Literal[True] = True query_: ParsableSql = Field(alias="query") - defaults: t.Dict[str, exp.Expression] = {} + defaults: t.Dict[str, exp.Expr] = {} expressions_: t.Optional[t.List[ParsableSql]] = Field(default=None, alias="expressions") jinja_macros: JinjaMacroRegistry = JinjaMacroRegistry() default_catalog: t.Optional[str] = None @@ -323,13 +321,13 @@ def render_definition( include_python: bool = True, include_defaults: bool = False, render_query: bool = False, - ) -> t.List[exp.Expression]: + ) -> t.List[exp.Expr]: """Returns the original list of sql expressions comprising the model definition. Args: include_python: Whether or not to include Python code in the rendered definition. """ - expressions: t.List[exp.Expression] = [] + expressions: t.List[exp.Expr] = [] comment = None for field_name in sorted(self.meta_fields): field_value = getattr(self, field_name) @@ -381,7 +379,7 @@ def meta_fields(self) -> t.Iterable[str]: return set(AuditCommonMetaMixin.__annotations__) | set(_Node.all_field_infos()) @property - def audits_with_args(self) -> t.List[t.Tuple[Audit, t.Dict[str, exp.Expression]]]: + def audits_with_args(self) -> t.List[t.Tuple[Audit, t.Dict[str, exp.Expr]]]: return [(self, {})] @@ -389,7 +387,7 @@ def audits_with_args(self) -> t.List[t.Tuple[Audit, t.Dict[str, exp.Expression]] def load_audit( - expressions: t.List[exp.Expression], + expressions: t.List[exp.Expr], *, path: Path = Path(), module_path: Path = Path(), @@ -499,7 +497,7 @@ def load_audit( def load_multiple_audits( - expressions: t.List[exp.Expression], + expressions: t.List[exp.Expr], *, path: Path = Path(), module_path: Path = Path(), @@ -510,7 +508,7 @@ def load_multiple_audits( variables: t.Optional[t.Dict[str, t.Any]] = None, project: t.Optional[str] = None, ) -> t.Generator[Audit, None, None]: - audit_block: t.List[exp.Expression] = [] + audit_block: t.List[exp.Expr] = [] for expression in expressions: if isinstance(expression, d.Audit): if audit_block: @@ -543,7 +541,7 @@ def _raise_config_error(msg: str, path: pathlib.Path) -> None: # mypy doesn't realize raise_config_error raises an exception @t.no_type_check -def _maybe_parse_arg_pair(e: exp.Expression) -> t.Tuple[str, exp.Expression]: +def _maybe_parse_arg_pair(e: exp.Expr) -> t.Tuple[str, exp.Expr]: if isinstance(e, exp.EQ): return e.left.name, e.right diff --git a/sqlmesh/core/config/linter.py b/sqlmesh/core/config/linter.py index c2a40e09aa..11d700c540 100644 --- a/sqlmesh/core/config/linter.py +++ b/sqlmesh/core/config/linter.py @@ -34,7 +34,7 @@ def _validate_rules(cls, v: t.Any) -> t.Set[str]: v = v.unnest().name elif isinstance(v, (exp.Tuple, exp.Array)): v = [e.name for e in v.expressions] - elif isinstance(v, exp.Expression): + elif isinstance(v, exp.Expr): v = v.name return {name.lower() for name in ensure_collection(v)} diff --git a/sqlmesh/core/config/model.py b/sqlmesh/core/config/model.py index aeefdf2557..ac41d75fe3 100644 --- a/sqlmesh/core/config/model.py +++ b/sqlmesh/core/config/model.py @@ -71,9 +71,9 @@ class ModelDefaultsConfig(BaseConfig): enabled: t.Optional[t.Union[str, bool]] = None formatting: t.Optional[t.Union[str, bool]] = None batch_concurrency: t.Optional[int] = None - pre_statements: t.Optional[t.List[t.Union[str, exp.Expression]]] = None - post_statements: t.Optional[t.List[t.Union[str, exp.Expression]]] = None - on_virtual_update: t.Optional[t.List[t.Union[str, exp.Expression]]] = None + pre_statements: t.Optional[t.List[t.Union[str, exp.Expr]]] = None + post_statements: t.Optional[t.List[t.Union[str, exp.Expr]]] = None + on_virtual_update: t.Optional[t.List[t.Union[str, exp.Expr]]] = None _model_kind_validator = model_kind_validator _on_destructive_change_validator = on_destructive_change_validator diff --git a/sqlmesh/core/context.py b/sqlmesh/core/context.py index 860194278b..dc51aad2a7 100644 --- a/sqlmesh/core/context.py +++ b/sqlmesh/core/context.py @@ -234,7 +234,7 @@ def resolve_table(self, model_name: str) -> str: ) def fetchdf( - self, query: t.Union[exp.Expression, str], quote_identifiers: bool = False + self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False ) -> pd.DataFrame: """Fetches a dataframe given a sql string or sqlglot expression. @@ -248,7 +248,7 @@ def fetchdf( return self.engine_adapter.fetchdf(query, quote_identifiers=quote_identifiers) def fetch_pyspark_df( - self, query: t.Union[exp.Expression, str], quote_identifiers: bool = False + self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False ) -> PySparkDataFrame: """Fetches a PySpark dataframe given a sql string or sqlglot expression. @@ -1105,7 +1105,7 @@ def render( execution_time: t.Optional[TimeLike] = None, expand: t.Union[bool, t.Iterable[str]] = False, **kwargs: t.Any, - ) -> exp.Expression: + ) -> exp.Expr: """Renders a model's query, expanding macros with provided kwargs, and optionally expanding referenced models. Args: @@ -1860,10 +1860,10 @@ def table_diff( self, source: str, target: str, - on: t.Optional[t.List[str] | exp.Condition] = None, + on: t.Optional[t.List[str] | exp.Expr] = None, skip_columns: t.Optional[t.List[str]] = None, select_models: t.Optional[t.Collection[str]] = None, - where: t.Optional[str | exp.Condition] = None, + where: t.Optional[str | exp.Expr] = None, limit: int = 20, show: bool = True, show_sample: bool = True, @@ -1922,7 +1922,7 @@ def table_diff( raise SQLMeshError(e) models_to_diff: t.List[ - t.Tuple[Model, EngineAdapter, str, str, t.Optional[t.List[str] | exp.Condition]] + t.Tuple[Model, EngineAdapter, str, str, t.Optional[t.List[str] | exp.Expr]] ] = [] models_without_grain: t.List[Model] = [] source_snapshots_to_name = { @@ -2041,9 +2041,9 @@ def _model_diff( target_alias: str, limit: int, decimals: int, - on: t.Optional[t.List[str] | exp.Condition] = None, + on: t.Optional[t.List[str] | exp.Expr] = None, skip_columns: t.Optional[t.List[str]] = None, - where: t.Optional[str | exp.Condition] = None, + where: t.Optional[str | exp.Expr] = None, show: bool = True, temp_schema: t.Optional[str] = None, skip_grain_check: bool = False, @@ -2083,10 +2083,10 @@ def _table_diff( limit: int, decimals: int, adapter: EngineAdapter, - on: t.Optional[t.List[str] | exp.Condition] = None, + on: t.Optional[t.List[str] | exp.Expr] = None, model: t.Optional[Model] = None, skip_columns: t.Optional[t.List[str]] = None, - where: t.Optional[str | exp.Condition] = None, + where: t.Optional[str | exp.Expr] = None, schema_diff_ignore_case: bool = False, ) -> TableDiff: if not on: @@ -2344,7 +2344,7 @@ def audit( return not errors @python_api_analytics - def rewrite(self, sql: str, dialect: str = "") -> exp.Expression: + def rewrite(self, sql: str, dialect: str = "") -> exp.Expr: """Rewrite a sql expression with semantic references into an executable query. https://sqlmesh.readthedocs.io/en/latest/concepts/metrics/overview/ diff --git a/sqlmesh/core/context_diff.py b/sqlmesh/core/context_diff.py index 07d13b1c2f..047e58609a 100644 --- a/sqlmesh/core/context_diff.py +++ b/sqlmesh/core/context_diff.py @@ -36,7 +36,7 @@ from sqlmesh.utils.metaprogramming import Executable # noqa from sqlmesh.core.environment import EnvironmentStatements -IGNORED_PACKAGES = {"sqlmesh", "sqlglot"} +IGNORED_PACKAGES = {"sqlmesh", "sqlglot", "sqlglotc"} class ContextDiff(PydanticModel): diff --git a/sqlmesh/core/dialect.py b/sqlmesh/core/dialect.py index c0a48326f2..122b287ac0 100644 --- a/sqlmesh/core/dialect.py +++ b/sqlmesh/core/dialect.py @@ -14,6 +14,7 @@ from sqlglot.dialects.dialect import DialectType from sqlglot.dialects import DuckDB, Snowflake, TSQL import sqlglot.dialects.athena as athena +from sqlglot.parsers.athena import AthenaTrinoParser from sqlglot.helper import seq_get from sqlglot.optimizer.normalize_identifiers import normalize_identifiers from sqlglot.optimizer.qualify_columns import quote_identifiers @@ -52,7 +53,7 @@ class Metric(exp.Expression): arg_types = {"expressions": True} -class Jinja(exp.Func): +class Jinja(exp.Expression, exp.Func): arg_types = {"this": True} @@ -76,7 +77,7 @@ class MacroVar(exp.Var): pass -class MacroFunc(exp.Func): +class MacroFunc(exp.Expression, exp.Func): @property def name(self) -> str: return self.this.name @@ -102,7 +103,7 @@ class DColonCast(exp.Cast): pass -class MetricAgg(exp.AggFunc): +class MetricAgg(exp.Expression, exp.AggFunc): """Used for computing metrics.""" arg_types = {"this": True} @@ -118,7 +119,7 @@ class StagedFilePath(exp.Expression): arg_types = exp.Table.arg_types.copy() -def _parse_statement(self: Parser) -> t.Optional[exp.Expression]: +def _parse_statement(self: Parser) -> t.Optional[exp.Expr]: if self._curr is None: return None @@ -152,7 +153,7 @@ def _parse_statement(self: Parser) -> t.Optional[exp.Expression]: raise -def _parse_lambda(self: Parser, alias: bool = False) -> t.Optional[exp.Expression]: +def _parse_lambda(self: Parser, alias: bool = False) -> t.Optional[exp.Expr]: node = self.__parse_lambda(alias=alias) # type: ignore if isinstance(node, exp.Lambda): node.set("this", self._parse_alias(node.this)) @@ -163,7 +164,7 @@ def _parse_id_var( self: Parser, any_token: bool = True, tokens: t.Optional[t.Collection[TokenType]] = None, -) -> t.Optional[exp.Expression]: +) -> t.Optional[exp.Expr]: if self._prev and self._prev.text == SQLMESH_MACRO_PREFIX and self._match(TokenType.L_BRACE): identifier = self.__parse_id_var(any_token=any_token, tokens=tokens) # type: ignore if not self._match(TokenType.R_BRACE): @@ -207,12 +208,12 @@ def _parse_id_var( else: self.raise_error("Expecting }") - identifier = self.expression(exp.Identifier, this=this, quoted=identifier.quoted) + identifier = self.expression(exp.Identifier(this=this, quoted=identifier.quoted)) return identifier -def _parse_macro(self: Parser, keyword_macro: str = "") -> t.Optional[exp.Expression]: +def _parse_macro(self: Parser, keyword_macro: str = "") -> t.Optional[exp.Expr]: if self._prev.text != SQLMESH_MACRO_PREFIX: return self._parse_parameter() @@ -220,7 +221,7 @@ def _parse_macro(self: Parser, keyword_macro: str = "") -> t.Optional[exp.Expres index = self._index field = self._parse_primary() or self._parse_function(functions={}) or self._parse_id_var() - def _build_macro(field: t.Optional[exp.Expression]) -> t.Optional[exp.Expression]: + def _build_macro(field: t.Optional[exp.Expr]) -> t.Optional[exp.Expr]: if isinstance(field, exp.Func): macro_name = field.name.upper() if macro_name != keyword_macro and macro_name in KEYWORD_MACROS: @@ -230,37 +231,39 @@ def _build_macro(field: t.Optional[exp.Expression]) -> t.Optional[exp.Expression if isinstance(field, exp.Anonymous): if macro_name == "DEF": return self.expression( - MacroDef, - this=field.expressions[0], - expression=field.expressions[1], + MacroDef( + this=field.expressions[0], + expression=field.expressions[1], + ), comments=comments, ) if macro_name == "SQL": into = field.expressions[1].this.lower() if len(field.expressions) > 1 else None return self.expression( - MacroSQL, this=field.expressions[0], into=into, comments=comments + MacroSQL(this=field.expressions[0], into=into), comments=comments ) else: field = self.expression( - exp.Anonymous, - this=field.sql_name(), - expressions=list(field.args.values()), + exp.Anonymous( + this=field.sql_name(), + expressions=list(field.args.values()), + ), comments=comments, ) - return self.expression(MacroFunc, this=field, comments=comments) + return self.expression(MacroFunc(this=field), comments=comments) if field is None: return None if field.is_string or (isinstance(field, exp.Identifier) and field.quoted): return self.expression( - MacroStrReplace, this=exp.Literal.string(field.this), comments=comments + MacroStrReplace(this=exp.Literal.string(field.this)), comments=comments ) if "@" in field.this: - return field - return self.expression(MacroVar, this=field.this, comments=comments) + return field # type: ignore[return-value] + return self.expression(MacroVar(this=field.this), comments=comments) if isinstance(field, (exp.Window, exp.IgnoreNulls, exp.RespectNulls)): field.set("this", _build_macro(field.this)) @@ -273,7 +276,7 @@ def _build_macro(field: t.Optional[exp.Expression]) -> t.Optional[exp.Expression KEYWORD_MACROS = {"WITH", "JOIN", "WHERE", "GROUP_BY", "HAVING", "ORDER_BY", "LIMIT"} -def _parse_matching_macro(self: Parser, name: str) -> t.Optional[exp.Expression]: +def _parse_matching_macro(self: Parser, name: str) -> t.Optional[exp.Expr]: if not self._match_pair(TokenType.PARAMETER, TokenType.VAR, advance=False) or ( self._next and self._next.text.upper() != name.upper() ): @@ -283,7 +286,7 @@ def _parse_matching_macro(self: Parser, name: str) -> t.Optional[exp.Expression] return _parse_macro(self, keyword_macro=name) -def _parse_body_macro(self: Parser) -> t.Tuple[str, t.Optional[exp.Expression]]: +def _parse_body_macro(self: Parser) -> t.Tuple[str, t.Optional[exp.Expr]]: name = self._next and self._next.text.upper() if name == "JOIN": @@ -301,7 +304,7 @@ def _parse_body_macro(self: Parser) -> t.Tuple[str, t.Optional[exp.Expression]]: return ("", None) -def _parse_with(self: Parser, skip_with_token: bool = False) -> t.Optional[exp.Expression]: +def _parse_with(self: Parser, skip_with_token: bool = False) -> t.Optional[exp.Expr]: macro = _parse_matching_macro(self, "WITH") if not macro: return self.__parse_with(skip_with_token=skip_with_token) # type: ignore @@ -312,7 +315,7 @@ def _parse_with(self: Parser, skip_with_token: bool = False) -> t.Optional[exp.E def _parse_join( self: Parser, skip_join_token: bool = False, parse_bracket: bool = False -) -> t.Optional[exp.Expression]: +) -> t.Optional[exp.Expr]: index = self._index method, side, kind = self._parse_join_parts() macro = _parse_matching_macro(self, "JOIN") @@ -351,7 +354,7 @@ def _parse_select( parse_set_operation: bool = True, consume_pipe: bool = True, from_: t.Optional[exp.From] = None, -) -> t.Optional[exp.Expression]: +) -> t.Optional[exp.Expr]: select = self.__parse_select( # type: ignore nested=nested, table=table, @@ -372,7 +375,7 @@ def _parse_select( return select -def _parse_where(self: Parser, skip_where_token: bool = False) -> t.Optional[exp.Expression]: +def _parse_where(self: Parser, skip_where_token: bool = False) -> t.Optional[exp.Expr]: macro = _parse_matching_macro(self, "WHERE") if not macro: return self.__parse_where(skip_where_token=skip_where_token) # type: ignore @@ -381,7 +384,7 @@ def _parse_where(self: Parser, skip_where_token: bool = False) -> t.Optional[exp return macro -def _parse_group(self: Parser, skip_group_by_token: bool = False) -> t.Optional[exp.Expression]: +def _parse_group(self: Parser, skip_group_by_token: bool = False) -> t.Optional[exp.Expr]: macro = _parse_matching_macro(self, "GROUP_BY") if not macro: return self.__parse_group(skip_group_by_token=skip_group_by_token) # type: ignore @@ -390,7 +393,7 @@ def _parse_group(self: Parser, skip_group_by_token: bool = False) -> t.Optional[ return macro -def _parse_having(self: Parser, skip_having_token: bool = False) -> t.Optional[exp.Expression]: +def _parse_having(self: Parser, skip_having_token: bool = False) -> t.Optional[exp.Expr]: macro = _parse_matching_macro(self, "HAVING") if not macro: return self.__parse_having(skip_having_token=skip_having_token) # type: ignore @@ -400,8 +403,8 @@ def _parse_having(self: Parser, skip_having_token: bool = False) -> t.Optional[e def _parse_order( - self: Parser, this: t.Optional[exp.Expression] = None, skip_order_token: bool = False -) -> t.Optional[exp.Expression]: + self: Parser, this: t.Optional[exp.Expr] = None, skip_order_token: bool = False +) -> t.Optional[exp.Expr]: macro = _parse_matching_macro(self, "ORDER_BY") if not macro: return self.__parse_order(this, skip_order_token=skip_order_token) # type: ignore @@ -412,10 +415,10 @@ def _parse_order( def _parse_limit( self: Parser, - this: t.Optional[exp.Expression] = None, + this: t.Optional[exp.Expr] = None, top: bool = False, skip_limit_token: bool = False, -) -> t.Optional[exp.Expression]: +) -> t.Optional[exp.Expr]: macro = _parse_matching_macro(self, "TOP" if top else "LIMIT") if not macro: return self.__parse_limit(this, top=top, skip_limit_token=skip_limit_token) # type: ignore @@ -424,7 +427,7 @@ def _parse_limit( return macro -def _parse_value(self: Parser, values: bool = True) -> t.Optional[exp.Expression]: +def _parse_value(self: Parser, values: bool = True) -> t.Optional[exp.Expr]: wrapped = self._match(TokenType.L_PAREN, advance=False) # The base _parse_value method always constructs a Tuple instance. This is problematic when @@ -438,11 +441,11 @@ def _parse_value(self: Parser, values: bool = True) -> t.Optional[exp.Expression return expr -def _parse_macro_or_clause(self: Parser, parser: t.Callable) -> t.Optional[exp.Expression]: +def _parse_macro_or_clause(self: Parser, parser: t.Callable) -> t.Optional[exp.Expr]: return _parse_macro(self) if self._match(TokenType.PARAMETER) else parser() -def _parse_props(self: Parser) -> t.Optional[exp.Expression]: +def _parse_props(self: Parser) -> t.Optional[exp.Expr]: key = self._parse_id_var(any_token=True) if not key: return None @@ -460,7 +463,7 @@ def _parse_props(self: Parser) -> t.Optional[exp.Expression]: elif name == "merge_filter": value = self._parse_conjunction() elif self._match(TokenType.L_PAREN): - value = self.expression(exp.Tuple, expressions=self._parse_csv(self._parse_equality)) + value = self.expression(exp.Tuple(expressions=self._parse_csv(self._parse_equality))) self._match_r_paren() else: value = self._parse_bracket(self._parse_field(any_token=True)) @@ -469,7 +472,7 @@ def _parse_props(self: Parser) -> t.Optional[exp.Expression]: # Make sure if we get a windows path that it is converted to posix value = exp.Literal.string(value.this.replace("\\", "/")) # type: ignore - return self.expression(exp.Property, this=name, value=value) + return self.expression(exp.Property(this=name, value=value)) def _parse_types( @@ -477,7 +480,7 @@ def _parse_types( check_func: bool = False, schema: bool = False, allow_identifiers: bool = True, -) -> t.Optional[exp.Expression]: +) -> t.Optional[exp.Expr]: start = self._curr parsed_type = self.__parse_types( # type: ignore check_func=check_func, schema=schema, allow_identifiers=allow_identifiers @@ -534,7 +537,7 @@ def _parse_table_parts( return table -def _parse_if(self: Parser) -> t.Optional[exp.Expression]: +def _parse_if(self: Parser) -> t.Optional[exp.Expr]: # If we fail to parse an IF function with expressions as arguments, we then try # to parse a statement / command to support the macro @IF(condition, statement) index = self._index @@ -566,11 +569,11 @@ def _parse_if(self: Parser) -> t.Optional[exp.Expression]: return exp.Anonymous(this="IF", expressions=[cond, stmt]) -def _create_parser(expression_type: t.Type[exp.Expression], table_keys: t.List[str]) -> t.Callable: - def parse(self: Parser) -> t.Optional[exp.Expression]: +def _create_parser(expression_type: t.Type[exp.Expr], table_keys: t.List[str]) -> t.Callable: + def parse(self: Parser) -> t.Optional[exp.Expr]: from sqlmesh.core.model.kind import ModelKindName - expressions: t.List[exp.Expression] = [] + expressions: t.List[exp.Expr] = [] while True: prev_property = seq_get(expressions, -1) @@ -589,7 +592,7 @@ def parse(self: Parser) -> t.Optional[exp.Expression]: key = key_expression.name.lower() start = self._curr - value: t.Optional[exp.Expression | str] + value: t.Optional[exp.Expr | str] if key in table_keys: value = self._parse_table_parts() @@ -629,7 +632,7 @@ def parse(self: Parser) -> t.Optional[exp.Expression]: else: props = None - value = self.expression(ModelKind, this=kind.value, expressions=props) + value = self.expression(ModelKind(this=kind.value, expressions=props)) elif key == "expression": value = self._parse_conjunction() elif key == "partitioned_by": @@ -641,12 +644,12 @@ def parse(self: Parser) -> t.Optional[exp.Expression]: else: value = self._parse_bracket(self._parse_field(any_token=True)) - if isinstance(value, exp.Expression): + if isinstance(value, exp.Expr): value.meta["sql"] = self._find_sql(start, self._prev) - expressions.append(self.expression(exp.Property, this=key, value=value)) + expressions.append(self.expression(exp.Property(this=key, value=value))) - return self.expression(expression_type, expressions=expressions) + return self.expression(expression_type(expressions=expressions)) return parse @@ -658,7 +661,7 @@ def parse(self: Parser) -> t.Optional[exp.Expression]: } -def _props_sql(self: Generator, expressions: t.List[exp.Expression]) -> str: +def _props_sql(self: Generator, expressions: t.List[exp.Expr]) -> str: props = [] size = len(expressions) @@ -676,7 +679,7 @@ def _props_sql(self: Generator, expressions: t.List[exp.Expression]) -> str: return "\n".join(props) -def _on_virtual_update_sql(self: Generator, expressions: t.List[exp.Expression]) -> str: +def _on_virtual_update_sql(self: Generator, expressions: t.List[exp.Expr]) -> str: statements = "\n".join( self.sql(expression) if isinstance(expression, JinjaStatement) @@ -697,7 +700,7 @@ def _model_kind_sql(self: Generator, expression: ModelKind) -> str: return expression.name.upper() -def _macro_keyword_func_sql(self: Generator, expression: exp.Expression) -> str: +def _macro_keyword_func_sql(self: Generator, expression: exp.Expr) -> str: name = expression.name keyword = name.replace("_", " ") *args, clause = expression.expressions @@ -731,7 +734,7 @@ def _override(klass: t.Type[Tokenizer | Parser], func: t.Callable) -> None: def format_model_expressions( - expressions: t.List[exp.Expression], + expressions: t.List[exp.Expr], dialect: t.Optional[str] = None, rewrite_casts: bool = True, **kwargs: t.Any, @@ -752,7 +755,7 @@ def format_model_expressions( if rewrite_casts: - def cast_to_colon(node: exp.Expression) -> exp.Expression: + def cast_to_colon(node: exp.Expr) -> exp.Expr: if isinstance(node, exp.Cast) and not any( # Only convert CAST into :: if it doesn't have additional args set, otherwise this # conversion could alter the semantics (eg. changing SAFE_CAST in BigQuery to CAST) @@ -784,8 +787,8 @@ def cast_to_colon(node: exp.Expression) -> exp.Expression: def text_diff( - a: t.List[exp.Expression], - b: t.List[exp.Expression], + a: t.List[exp.Expr], + b: t.List[exp.Expr], a_dialect: t.Optional[str] = None, b_dialect: t.Optional[str] = None, ) -> str: @@ -860,7 +863,7 @@ def _is_virtual_statement_end(tokens: t.List[Token], pos: int) -> bool: return _is_command_statement(ON_VIRTUAL_UPDATE_END, tokens, pos) -def virtual_statement(statements: t.List[exp.Expression]) -> VirtualUpdateStatement: +def virtual_statement(statements: t.List[exp.Expr]) -> VirtualUpdateStatement: return VirtualUpdateStatement(expressions=statements) @@ -874,7 +877,7 @@ class ChunkType(Enum): def parse_one( sql: str, dialect: t.Optional[str] = None, into: t.Optional[exp.IntoType] = None -) -> exp.Expression: +) -> exp.Expr: expressions = parse(sql, default_dialect=dialect, match_dialect=False, into=into) if not expressions: raise SQLMeshError(f"No expressions found in '{sql}'") @@ -888,7 +891,7 @@ def parse( default_dialect: t.Optional[str] = None, match_dialect: bool = True, into: t.Optional[exp.IntoType] = None, -) -> t.List[exp.Expression]: +) -> t.List[exp.Expr]: """Parse a sql string. Supports parsing model definition. @@ -952,10 +955,10 @@ def parse( pos += 1 parser = dialect.parser() - expressions: t.List[exp.Expression] = [] + expressions: t.List[exp.Expr] = [] - def parse_sql_chunk(chunk: t.List[Token], meta_sql: bool = True) -> t.List[exp.Expression]: - parsed_expressions: t.List[t.Optional[exp.Expression]] = ( + def parse_sql_chunk(chunk: t.List[Token], meta_sql: bool = True) -> t.List[exp.Expr]: + parsed_expressions: t.List[t.Optional[exp.Expr]] = ( parser.parse(chunk, sql) if into is None else parser.parse_into(into, chunk, sql) ) expressions = [] @@ -966,7 +969,7 @@ def parse_sql_chunk(chunk: t.List[Token], meta_sql: bool = True) -> t.List[exp.E expressions.append(expression) return expressions - def parse_jinja_chunk(chunk: t.List[Token], meta_sql: bool = True) -> exp.Expression: + def parse_jinja_chunk(chunk: t.List[Token], meta_sql: bool = True) -> exp.Expr: start, *_, end = chunk segment = sql[start.end + 2 : end.start - 1] factory = jinja_query if chunk_type == ChunkType.JINJA_QUERY else jinja_statement @@ -977,9 +980,9 @@ def parse_jinja_chunk(chunk: t.List[Token], meta_sql: bool = True) -> exp.Expres def parse_virtual_statement( chunks: t.List[t.Tuple[t.List[Token], ChunkType]], pos: int - ) -> t.Tuple[t.List[exp.Expression], int]: + ) -> t.Tuple[t.List[exp.Expr], int]: # For virtual statements we need to handle both SQL and Jinja nested blocks within the chunk - virtual_update_statements = [] + virtual_update_statements: t.List[exp.Expr] = [] start = chunks[pos][0][0].start while ( @@ -1031,7 +1034,7 @@ def extend_sqlglot() -> None: # so this ensures that the extra ones it defines are also extended if dialect == athena.Athena: tokenizers.add(athena._TrinoTokenizer) - parsers.add(athena._TrinoParser) + parsers.add(AthenaTrinoParser) generators.add(athena._TrinoGenerator) generators.add(athena._HiveGenerator) @@ -1251,7 +1254,7 @@ def normalize_model_name( def find_tables( - expression: exp.Expression, default_catalog: t.Optional[str], dialect: DialectType = None + expression: exp.Expr, default_catalog: t.Optional[str], dialect: DialectType = None ) -> t.Set[str]: """Find all tables referenced in a query. @@ -1274,10 +1277,10 @@ def find_tables( return expression.meta[TABLES_META] -def add_table(node: exp.Expression, table: str) -> exp.Expression: +def add_table(node: exp.Expr, table: str) -> exp.Expr: """Add a table to all columns in an expression.""" - def _transform(node: exp.Expression) -> exp.Expression: + def _transform(node: exp.Expr) -> exp.Expr: if isinstance(node, exp.Column) and not node.table: return exp.column(node.this, table=table) if isinstance(node, exp.Identifier): @@ -1387,7 +1390,7 @@ def normalize_and_quote( quote_identifiers(query, dialect=dialect) -def interpret_expression(e: exp.Expression) -> exp.Expression | str | int | float | bool: +def interpret_expression(e: exp.Expr) -> exp.Expr | str | int | float | bool: if e.is_int: return int(e.this) if e.is_number: @@ -1399,13 +1402,13 @@ def interpret_expression(e: exp.Expression) -> exp.Expression | str | int | floa def interpret_key_value_pairs( e: exp.Tuple, -) -> t.Dict[str, exp.Expression | str | int | float | bool]: +) -> t.Dict[str, exp.Expr | str | int | float | bool]: return {i.this.name: interpret_expression(i.expression) for i in e.expressions} def extract_func_call( - v: exp.Expression, allow_tuples: bool = False -) -> t.Tuple[str, t.Dict[str, exp.Expression]]: + v: exp.Expr, allow_tuples: bool = False +) -> t.Tuple[str, t.Dict[str, exp.Expr]]: kwargs = {} if isinstance(v, exp.Anonymous): @@ -1442,7 +1445,7 @@ def extract_function_calls(func_calls: t.Any, allow_tuples: bool = False) -> t.A return [extract_func_call(i, allow_tuples=allow_tuples) for i in func_calls.expressions] if isinstance(func_calls, exp.Paren): return [extract_func_call(func_calls.this, allow_tuples=allow_tuples)] - if isinstance(func_calls, exp.Expression): + if isinstance(func_calls, exp.Expr): return [extract_func_call(func_calls, allow_tuples=allow_tuples)] if isinstance(func_calls, list): function_calls = [] @@ -1474,9 +1477,7 @@ def is_meta_expression(v: t.Any) -> bool: return isinstance(v, (Audit, Metric, Model)) -def replace_merge_table_aliases( - expression: exp.Expression, dialect: t.Optional[str] = None -) -> exp.Expression: +def replace_merge_table_aliases(expression: exp.Expr, dialect: t.Optional[str] = None) -> exp.Expr: """ Resolves references from the "source" and "target" tables (or their DBT equivalents) with the corresponding SQLMesh merge aliases (MERGE_SOURCE_ALIAS and MERGE_TARGET_ALIAS) diff --git a/sqlmesh/core/engine_adapter/athena.py b/sqlmesh/core/engine_adapter/athena.py index bd84ba5276..338381549b 100644 --- a/sqlmesh/core/engine_adapter/athena.py +++ b/sqlmesh/core/engine_adapter/athena.py @@ -158,7 +158,7 @@ def _create_schema( schema_name: SchemaName, ignore_if_exists: bool, warn_on_error: bool, - properties: t.List[exp.Expression], + properties: t.List[exp.Expr], kind: str, ) -> None: if location := self._table_location(table_properties=None, table=exp.to_table(schema_name)): @@ -177,14 +177,14 @@ def _create_schema( def _build_create_table_exp( self, table_name_or_schema: t.Union[exp.Schema, TableName], - expression: t.Optional[exp.Expression], + expression: t.Optional[exp.Expr], exists: bool = True, replace: bool = False, target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, table_description: t.Optional[str] = None, table_kind: t.Optional[str] = None, - partitioned_by: t.Optional[t.List[exp.Expression]] = None, - table_properties: t.Optional[t.Dict[str, exp.Expression]] = None, + partitioned_by: t.Optional[t.List[exp.Expr]] = None, + table_properties: t.Optional[t.Dict[str, exp.Expr]] = None, **kwargs: t.Any, ) -> exp.Create: exists = False if replace else exists @@ -235,18 +235,18 @@ def _build_table_properties_exp( catalog_name: t.Optional[str] = None, table_format: t.Optional[str] = None, storage_format: t.Optional[str] = None, - partitioned_by: t.Optional[t.List[exp.Expression]] = None, + partitioned_by: t.Optional[t.List[exp.Expr]] = None, partition_interval_unit: t.Optional[IntervalUnit] = None, - clustered_by: t.Optional[t.List[exp.Expression]] = None, - table_properties: t.Optional[t.Dict[str, exp.Expression]] = None, + clustered_by: t.Optional[t.List[exp.Expr]] = None, + table_properties: t.Optional[t.Dict[str, exp.Expr]] = None, target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, table_description: t.Optional[str] = None, table_kind: t.Optional[str] = None, table: t.Optional[exp.Table] = None, - expression: t.Optional[exp.Expression] = None, + expression: t.Optional[exp.Expr] = None, **kwargs: t.Any, ) -> t.Optional[exp.Properties]: - properties: t.List[exp.Expression] = [] + properties: t.List[exp.Expr] = [] table_properties = table_properties or {} is_hive = self._table_type(table_format) == "hive" @@ -266,7 +266,7 @@ def _build_table_properties_exp( properties.append(exp.SchemaCommentProperty(this=exp.Literal.string(table_description))) if partitioned_by: - schema_expressions: t.List[exp.Expression] = [] + schema_expressions: t.List[exp.Expr] = [] if is_hive and target_columns_to_types: # For Hive-style tables, you cannot include the partitioned by columns in the main set of columns # In the PARTITIONED BY expression, you also cant just include the column names, you need to include the data type as well @@ -381,7 +381,7 @@ def _is_hive_partitioned_table(self, table: exp.Table) -> bool: raise e def _table_location_or_raise( - self, table_properties: t.Optional[t.Dict[str, exp.Expression]], table: exp.Table + self, table_properties: t.Optional[t.Dict[str, exp.Expr]], table: exp.Table ) -> exp.LocationProperty: location = self._table_location(table_properties, table) if not location: @@ -392,7 +392,7 @@ def _table_location_or_raise( def _table_location( self, - table_properties: t.Optional[t.Dict[str, exp.Expression]], + table_properties: t.Optional[t.Dict[str, exp.Expr]], table: exp.Table, ) -> t.Optional[exp.LocationProperty]: base_uri: str @@ -402,7 +402,7 @@ def _table_location( s3_base_location_property = table_properties.pop( "s3_base_location" ) # pop because it's handled differently and we dont want it to end up in the TBLPROPERTIES clause - if isinstance(s3_base_location_property, exp.Expression): + if isinstance(s3_base_location_property, exp.Expr): base_uri = s3_base_location_property.name else: base_uri = s3_base_location_property @@ -419,7 +419,7 @@ def _table_location( return exp.LocationProperty(this=exp.Literal.string(full_uri)) def _find_matching_columns( - self, partitioned_by: t.List[exp.Expression], columns_to_types: t.Dict[str, exp.DataType] + self, partitioned_by: t.List[exp.Expr], columns_to_types: t.Dict[str, exp.DataType] ) -> t.List[t.Tuple[str, exp.DataType]]: matches = [] for col in partitioned_by: @@ -557,7 +557,7 @@ def _chunks() -> t.Iterable[t.List[t.List[str]]]: PartitionsToDelete=[{"Values": v} for v in batch], ) - def delete_from(self, table_name: TableName, where: t.Union[str, exp.Expression]) -> None: + def delete_from(self, table_name: TableName, where: t.Union[str, exp.Expr]) -> None: table = exp.to_table(table_name) table_type = self._query_table_type(table) diff --git a/sqlmesh/core/engine_adapter/base.py b/sqlmesh/core/engine_adapter/base.py index e2dbb51459..8de7b79398 100644 --- a/sqlmesh/core/engine_adapter/base.py +++ b/sqlmesh/core/engine_adapter/base.py @@ -236,7 +236,7 @@ def _casted_columns( cls, target_columns_to_types: t.Dict[str, exp.DataType], source_columns: t.Optional[t.List[str]] = None, - ) -> t.List[exp.Alias]: + ) -> t.List[exp.Expr]: source_columns_lookup = set(source_columns or target_columns_to_types) return [ exp.alias_( @@ -591,7 +591,7 @@ def create_index( def _pop_creatable_type_from_properties( self, - properties: t.Dict[str, exp.Expression], + properties: t.Dict[str, exp.Expr], ) -> t.Optional[exp.Property]: """Pop out the creatable_type from the properties dictionary (if exists (return it/remove it) else return none). It also checks that none of the expressions are MATERIALIZE as that conflicts with the `materialize` parameter. @@ -652,9 +652,9 @@ def create_managed_table( table_name: TableName, query: Query, target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, - partitioned_by: t.Optional[t.List[exp.Expression]] = None, - clustered_by: t.Optional[t.List[exp.Expression]] = None, - table_properties: t.Optional[t.Dict[str, exp.Expression]] = None, + partitioned_by: t.Optional[t.List[exp.Expr]] = None, + clustered_by: t.Optional[t.List[exp.Expr]] = None, + table_properties: t.Optional[t.Dict[str, exp.Expr]] = None, table_description: t.Optional[str] = None, column_descriptions: t.Optional[t.Dict[str, str]] = None, source_columns: t.Optional[t.List[str]] = None, @@ -964,7 +964,7 @@ def _create_table_from_source_queries( def _create_table( self, table_name_or_schema: t.Union[exp.Schema, TableName], - expression: t.Optional[exp.Expression], + expression: t.Optional[exp.Expr], exists: bool = True, replace: bool = False, target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, @@ -1002,7 +1002,7 @@ def _create_table( def _build_create_table_exp( self, table_name_or_schema: t.Union[exp.Schema, TableName], - expression: t.Optional[exp.Expression], + expression: t.Optional[exp.Expr], exists: bool = True, replace: bool = False, target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, @@ -1203,7 +1203,7 @@ def create_view( materialized_properties: t.Optional[t.Dict[str, t.Any]] = None, table_description: t.Optional[str] = None, column_descriptions: t.Optional[t.Dict[str, str]] = None, - view_properties: t.Optional[t.Dict[str, exp.Expression]] = None, + view_properties: t.Optional[t.Dict[str, exp.Expr]] = None, source_columns: t.Optional[t.List[str]] = None, **create_kwargs: t.Any, ) -> None: @@ -1382,7 +1382,7 @@ def create_schema( schema_name: SchemaName, ignore_if_exists: bool = True, warn_on_error: bool = True, - properties: t.Optional[t.List[exp.Expression]] = None, + properties: t.Optional[t.List[exp.Expr]] = None, ) -> None: properties = properties or [] return self._create_schema( @@ -1398,7 +1398,7 @@ def _create_schema( schema_name: SchemaName, ignore_if_exists: bool, warn_on_error: bool, - properties: t.List[exp.Expression], + properties: t.List[exp.Expr], kind: str, ) -> None: """Create a schema from a name or qualified table name.""" @@ -1423,7 +1423,7 @@ def drop_schema( schema_name: SchemaName, ignore_if_not_exists: bool = True, cascade: bool = False, - **drop_args: t.Dict[str, exp.Expression], + **drop_args: t.Dict[str, exp.Expr], ) -> None: return self._drop_object( name=schema_name, @@ -1494,7 +1494,7 @@ def table_exists(self, table_name: TableName) -> bool: except Exception: return False - def delete_from(self, table_name: TableName, where: t.Union[str, exp.Expression]) -> None: + def delete_from(self, table_name: TableName, where: t.Union[str, exp.Expr]) -> None: self.execute(exp.delete(table_name, where)) def insert_append( @@ -1552,7 +1552,7 @@ def insert_overwrite_by_partition( self, table_name: TableName, query_or_df: QueryOrDF, - partitioned_by: t.List[exp.Expression], + partitioned_by: t.List[exp.Expr], target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, source_columns: t.Optional[t.List[str]] = None, ) -> None: @@ -1583,10 +1583,8 @@ def insert_overwrite_by_time_partition( query_or_df: QueryOrDF, start: TimeLike, end: TimeLike, - time_formatter: t.Callable[ - [TimeLike, t.Optional[t.Dict[str, exp.DataType]]], exp.Expression - ], - time_column: TimeColumn | exp.Expression | str, + time_formatter: t.Callable[[TimeLike, t.Optional[t.Dict[str, exp.DataType]]], exp.Expr], + time_column: TimeColumn | exp.Expr | str, target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, source_columns: t.Optional[t.List[str]] = None, **kwargs: t.Any, @@ -1726,7 +1724,7 @@ def _merge( self, target_table: TableName, query: Query, - on: exp.Expression, + on: exp.Expr, whens: exp.Whens, ) -> None: this = exp.alias_(exp.to_table(target_table), alias=MERGE_TARGET_ALIAS, table=True) @@ -1741,7 +1739,7 @@ def scd_type_2_by_time( self, target_table: TableName, source_table: QueryOrDF, - unique_key: t.Sequence[exp.Expression], + unique_key: t.Sequence[exp.Expr], valid_from_col: exp.Column, valid_to_col: exp.Column, execution_time: t.Union[TimeLike, exp.Column], @@ -1777,11 +1775,11 @@ def scd_type_2_by_column( self, target_table: TableName, source_table: QueryOrDF, - unique_key: t.Sequence[exp.Expression], + unique_key: t.Sequence[exp.Expr], valid_from_col: exp.Column, valid_to_col: exp.Column, execution_time: t.Union[TimeLike, exp.Column], - check_columns: t.Union[exp.Star, t.Sequence[exp.Expression]], + check_columns: t.Union[exp.Star, t.Sequence[exp.Expr]], invalidate_hard_deletes: bool = True, execution_time_as_valid_from: bool = False, target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, @@ -1813,13 +1811,13 @@ def _scd_type_2( self, target_table: TableName, source_table: QueryOrDF, - unique_key: t.Sequence[exp.Expression], + unique_key: t.Sequence[exp.Expr], valid_from_col: exp.Column, valid_to_col: exp.Column, execution_time: t.Union[TimeLike, exp.Column], invalidate_hard_deletes: bool = True, updated_at_col: t.Optional[exp.Column] = None, - check_columns: t.Optional[t.Union[exp.Star, t.Sequence[exp.Expression]]] = None, + check_columns: t.Optional[t.Union[exp.Star, t.Sequence[exp.Expr]]] = None, updated_at_as_valid_from: bool = False, execution_time_as_valid_from: bool = False, target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, @@ -1908,7 +1906,7 @@ def remove_managed_columns( raise SQLMeshError( "Cannot use `updated_at_as_valid_from` without `updated_at_name` for SCD Type 2" ) - update_valid_from_start: t.Union[str, exp.Expression] = updated_at_col + update_valid_from_start: t.Union[str, exp.Expr] = updated_at_col # If using check_columns and the user doesn't always want execution_time for valid from # then we only use epoch 0 if we are truncating the table and loading rows for the first time. # All future new rows should have execution time. @@ -2207,9 +2205,9 @@ def merge( target_table: TableName, source_table: QueryOrDF, target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]], - unique_key: t.Sequence[exp.Expression], + unique_key: t.Sequence[exp.Expr], when_matched: t.Optional[exp.Whens] = None, - merge_filter: t.Optional[exp.Expression] = None, + merge_filter: t.Optional[exp.Expr] = None, source_columns: t.Optional[t.List[str]] = None, **kwargs: t.Any, ) -> None: @@ -2382,7 +2380,7 @@ def get_data_objects( def fetchone( self, - query: t.Union[exp.Expression, str], + query: t.Union[exp.Expr, str], ignore_unsupported_errors: bool = False, quote_identifiers: bool = False, ) -> t.Optional[t.Tuple]: @@ -2396,7 +2394,7 @@ def fetchone( def fetchall( self, - query: t.Union[exp.Expression, str], + query: t.Union[exp.Expr, str], ignore_unsupported_errors: bool = False, quote_identifiers: bool = False, ) -> t.List[t.Tuple]: @@ -2409,7 +2407,7 @@ def fetchall( return self.cursor.fetchall() def _fetch_native_df( - self, query: t.Union[exp.Expression, str], quote_identifiers: bool = False + self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False ) -> DF: """Fetches a DataFrame that can be either Pandas or PySpark from the cursor""" with self.transaction(): @@ -2432,7 +2430,7 @@ def _native_df_to_pandas_df( raise NotImplementedError(f"Unable to convert {type(query_or_df)} to Pandas") def fetchdf( - self, query: t.Union[exp.Expression, str], quote_identifiers: bool = False + self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False ) -> pd.DataFrame: """Fetches a Pandas DataFrame from the cursor""" import pandas as pd @@ -2445,7 +2443,7 @@ def fetchdf( return df def fetch_pyspark_df( - self, query: t.Union[exp.Expression, str], quote_identifiers: bool = False + self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False ) -> PySparkDataFrame: """Fetches a PySpark DataFrame from the cursor""" raise NotImplementedError(f"Engine does not support PySpark DataFrames: {type(self)}") @@ -2575,7 +2573,7 @@ def _is_session_active(self) -> bool: def execute( self, - expressions: t.Union[str, exp.Expression, t.Sequence[exp.Expression]], + expressions: t.Union[str, exp.Expr, t.Sequence[exp.Expr]], ignore_unsupported_errors: bool = False, quote_identifiers: bool = True, track_rows_processed: bool = False, @@ -2587,7 +2585,7 @@ def execute( ) with self.transaction(): for e in ensure_list(expressions): - if isinstance(e, exp.Expression): + if isinstance(e, exp.Expr): self._check_identifier_length(e) sql = self._to_sql(e, quote=quote_identifiers, **to_sql_kwargs) else: @@ -2597,7 +2595,7 @@ def execute( self._log_sql( sql, - expression=e if isinstance(e, exp.Expression) else None, + expression=e if isinstance(e, exp.Expr) else None, quote_identifiers=quote_identifiers, ) self._execute(sql, track_rows_processed, **kwargs) @@ -2610,7 +2608,7 @@ def _attach_correlation_id(self, sql: str) -> str: def _log_sql( self, sql: str, - expression: t.Optional[exp.Expression] = None, + expression: t.Optional[exp.Expr] = None, quote_identifiers: bool = True, ) -> None: if not logger.isEnabledFor(self._execute_log_level): @@ -2702,7 +2700,7 @@ def temp_table( self.drop_table(table) def _table_or_view_properties_to_expressions( - self, table_or_view_properties: t.Optional[t.Dict[str, exp.Expression]] = None + self, table_or_view_properties: t.Optional[t.Dict[str, exp.Expr]] = None ) -> t.List[exp.Property]: """Converts model properties (either physical or virtual) to a list of property expressions.""" if not table_or_view_properties: @@ -2714,7 +2712,7 @@ def _table_or_view_properties_to_expressions( def _build_partitioned_by_exp( self, - partitioned_by: t.List[exp.Expression], + partitioned_by: t.List[exp.Expr], *, partition_interval_unit: t.Optional[IntervalUnit] = None, target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, @@ -2725,7 +2723,7 @@ def _build_partitioned_by_exp( def _build_clustered_by_exp( self, - clustered_by: t.List[exp.Expression], + clustered_by: t.List[exp.Expr], **kwargs: t.Any, ) -> t.Optional[exp.Cluster]: return None @@ -2735,17 +2733,17 @@ def _build_table_properties_exp( catalog_name: t.Optional[str] = None, table_format: t.Optional[str] = None, storage_format: t.Optional[str] = None, - partitioned_by: t.Optional[t.List[exp.Expression]] = None, + partitioned_by: t.Optional[t.List[exp.Expr]] = None, partition_interval_unit: t.Optional[IntervalUnit] = None, - clustered_by: t.Optional[t.List[exp.Expression]] = None, - table_properties: t.Optional[t.Dict[str, exp.Expression]] = None, + clustered_by: t.Optional[t.List[exp.Expr]] = None, + table_properties: t.Optional[t.Dict[str, exp.Expr]] = None, target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, table_description: t.Optional[str] = None, table_kind: t.Optional[str] = None, **kwargs: t.Any, ) -> t.Optional[exp.Properties]: """Creates a SQLGlot table properties expression for ddl.""" - properties: t.List[exp.Expression] = [] + properties: t.List[exp.Expr] = [] if table_description: properties.append( @@ -2764,12 +2762,12 @@ def _build_table_properties_exp( def _build_view_properties_exp( self, - view_properties: t.Optional[t.Dict[str, exp.Expression]] = None, + view_properties: t.Optional[t.Dict[str, exp.Expr]] = None, table_description: t.Optional[str] = None, **kwargs: t.Any, ) -> t.Optional[exp.Properties]: """Creates a SQLGlot table properties expression for view""" - properties: t.List[exp.Expression] = [] + properties: t.List[exp.Expr] = [] if table_description: properties.append( @@ -2791,7 +2789,7 @@ def _truncate_table_comment(self, comment: str) -> str: def _truncate_column_comment(self, comment: str) -> str: return self._truncate_comment(comment, self.MAX_COLUMN_COMMENT_LENGTH) - def _to_sql(self, expression: exp.Expression, quote: bool = True, **kwargs: t.Any) -> str: + def _to_sql(self, expression: exp.Expr, quote: bool = True, **kwargs: t.Any) -> str: """ Converts an expression to a SQL string. Has a set of default kwargs to apply, and then default kwargs defined for the given dialect, and then kwargs provided by the user when defining the engine @@ -2852,7 +2850,7 @@ def _order_projections_and_filter( self, query: Query, target_columns_to_types: t.Dict[str, exp.DataType], - where: t.Optional[exp.Expression] = None, + where: t.Optional[exp.Expr] = None, coerce_types: bool = False, ) -> Query: if not isinstance(query, exp.Query) or ( @@ -2863,7 +2861,7 @@ def _order_projections_and_filter( query = t.cast(exp.Query, query.copy()) with_ = query.args.pop("with_", None) - select_exprs: t.List[exp.Expression] = [ + select_exprs: t.List[exp.Expr] = [ exp.column(c, quoted=True) for c in target_columns_to_types ] if coerce_types and columns_to_types_all_known(target_columns_to_types): @@ -2914,7 +2912,7 @@ def _replace_by_key( target_table: TableName, source_table: QueryOrDF, target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]], - key: t.Sequence[exp.Expression], + key: t.Sequence[exp.Expr], is_unique_key: bool, source_columns: t.Optional[t.List[str]] = None, ) -> None: @@ -3055,7 +3053,7 @@ def _select_columns( ) ) - def _check_identifier_length(self, expression: exp.Expression) -> None: + def _check_identifier_length(self, expression: exp.Expr) -> None: if self.MAX_IDENTIFIER_LENGTH is None or not isinstance(expression, exp.DDL): return @@ -3147,7 +3145,7 @@ def _apply_grants_config_expr( table: exp.Table, grants_config: GrantsConfig, table_type: DataObjectType = DataObjectType.TABLE, - ) -> t.List[exp.Expression]: + ) -> t.List[exp.Expr]: """Returns SQLGlot Grant expressions to apply grants to a table. Args: @@ -3170,7 +3168,7 @@ def _revoke_grants_config_expr( table: exp.Table, grants_config: GrantsConfig, table_type: DataObjectType = DataObjectType.TABLE, - ) -> t.List[exp.Expression]: + ) -> t.List[exp.Expr]: """Returns SQLGlot expressions to revoke grants from a table. Args: diff --git a/sqlmesh/core/engine_adapter/base_postgres.py b/sqlmesh/core/engine_adapter/base_postgres.py index 11f56da133..e2347b1263 100644 --- a/sqlmesh/core/engine_adapter/base_postgres.py +++ b/sqlmesh/core/engine_adapter/base_postgres.py @@ -110,7 +110,7 @@ def create_view( materialized_properties: t.Optional[t.Dict[str, t.Any]] = None, table_description: t.Optional[str] = None, column_descriptions: t.Optional[t.Dict[str, str]] = None, - view_properties: t.Optional[t.Dict[str, exp.Expression]] = None, + view_properties: t.Optional[t.Dict[str, exp.Expr]] = None, source_columns: t.Optional[t.List[str]] = None, **create_kwargs: t.Any, ) -> None: diff --git a/sqlmesh/core/engine_adapter/bigquery.py b/sqlmesh/core/engine_adapter/bigquery.py index 59a56b6ace..4741f90d27 100644 --- a/sqlmesh/core/engine_adapter/bigquery.py +++ b/sqlmesh/core/engine_adapter/bigquery.py @@ -67,7 +67,7 @@ class BigQueryEngineAdapter(ClusteredByMixin, RowDiffMixin, GrantsFromInfoSchema SUPPORTS_MATERIALIZED_VIEWS = True SUPPORTS_CLONING = True SUPPORTS_GRANTS = True - CURRENT_USER_OR_ROLE_EXPRESSION: exp.Expression = exp.func("session_user") + CURRENT_USER_OR_ROLE_EXPRESSION: exp.Expr = exp.func("session_user") SUPPORTS_MULTIPLE_GRANT_PRINCIPALS = True USE_CATALOG_IN_GRANTS = True GRANT_INFORMATION_SCHEMA_TABLE_NAME = "OBJECT_PRIVILEGES" @@ -288,7 +288,7 @@ def create_schema( schema_name: SchemaName, ignore_if_exists: bool = True, warn_on_error: bool = True, - properties: t.List[exp.Expression] = [], + properties: t.List[exp.Expr] = [], ) -> None: """Create a schema from a name or qualified table name.""" from google.api_core.exceptions import Conflict @@ -433,7 +433,7 @@ def alter_table( def fetchone( self, - query: t.Union[exp.Expression, str], + query: t.Union[exp.Expr, str], ignore_unsupported_errors: bool = False, quote_identifiers: bool = False, ) -> t.Optional[t.Tuple]: @@ -453,7 +453,7 @@ def fetchone( def fetchall( self, - query: t.Union[exp.Expression, str], + query: t.Union[exp.Expr, str], ignore_unsupported_errors: bool = False, quote_identifiers: bool = False, ) -> t.List[t.Tuple]: @@ -689,7 +689,7 @@ def insert_overwrite_by_partition( self, table_name: TableName, query_or_df: QueryOrDF, - partitioned_by: t.List[exp.Expression], + partitioned_by: t.List[exp.Expr], target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, source_columns: t.Optional[t.List[str]] = None, ) -> None: @@ -803,7 +803,7 @@ def _table_name(self, table_name: TableName) -> str: return ".".join(part.name for part in exp.to_table(table_name).parts) def _fetch_native_df( - self, query: t.Union[exp.Expression, str], quote_identifiers: bool = False + self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False ) -> DF: self.execute(query, quote_identifiers=quote_identifiers) query_job = self._query_job @@ -863,7 +863,7 @@ def _build_description_property_exp( def _build_partitioned_by_exp( self, - partitioned_by: t.List[exp.Expression], + partitioned_by: t.List[exp.Expr], *, partition_interval_unit: t.Optional[IntervalUnit] = None, target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, @@ -909,16 +909,16 @@ def _build_table_properties_exp( catalog_name: t.Optional[str] = None, table_format: t.Optional[str] = None, storage_format: t.Optional[str] = None, - partitioned_by: t.Optional[t.List[exp.Expression]] = None, + partitioned_by: t.Optional[t.List[exp.Expr]] = None, partition_interval_unit: t.Optional[IntervalUnit] = None, - clustered_by: t.Optional[t.List[exp.Expression]] = None, - table_properties: t.Optional[t.Dict[str, exp.Expression]] = None, + clustered_by: t.Optional[t.List[exp.Expr]] = None, + table_properties: t.Optional[t.Dict[str, exp.Expr]] = None, target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, table_description: t.Optional[str] = None, table_kind: t.Optional[str] = None, **kwargs: t.Any, ) -> t.Optional[exp.Properties]: - properties: t.List[exp.Expression] = [] + properties: t.List[exp.Expr] = [] if partitioned_by and ( partitioned_by_prop := self._build_partitioned_by_exp( @@ -1025,12 +1025,12 @@ def _build_col_comment_exp( def _build_view_properties_exp( self, - view_properties: t.Optional[t.Dict[str, exp.Expression]] = None, + view_properties: t.Optional[t.Dict[str, exp.Expr]] = None, table_description: t.Optional[str] = None, **kwargs: t.Any, ) -> t.Optional[exp.Properties]: """Creates a SQLGlot table properties expression for view""" - properties: t.List[exp.Expression] = [] + properties: t.List[exp.Expr] = [] if table_description: properties.append( @@ -1257,10 +1257,10 @@ def _update_clustering_key(self, operation: TableAlterClusterByOperation) -> Non ) ) - def _normalize_decimal_value(self, col: exp.Expression, precision: int) -> exp.Expression: + def _normalize_decimal_value(self, col: exp.Expr, precision: int) -> exp.Expr: return exp.func("FORMAT", exp.Literal.string(f"%.{precision}f"), col) - def _normalize_nested_value(self, col: exp.Expression) -> exp.Expression: + def _normalize_nested_value(self, col: exp.Expr) -> exp.Expr: return exp.func("TO_JSON_STRING", col, dialect=self.dialect) @t.overload @@ -1338,7 +1338,7 @@ def _get_current_schema(self) -> str: def _get_bq_dataset_location(self, project: str, dataset: str) -> str: return self._db_call(self.client.get_dataset, dataset_ref=f"{project}.{dataset}").location - def _get_grant_expression(self, table: exp.Table) -> exp.Expression: + def _get_grant_expression(self, table: exp.Table) -> exp.Expr: if not table.db: raise ValueError( f"Table {table.sql(dialect=self.dialect)} does not have a schema (dataset)" @@ -1392,8 +1392,8 @@ def _dcl_grants_config_expr( table: exp.Table, grants_config: GrantsConfig, table_type: DataObjectType = DataObjectType.TABLE, - ) -> t.List[exp.Expression]: - expressions: t.List[exp.Expression] = [] + ) -> t.List[exp.Expr]: + expressions: t.List[exp.Expr] = [] if not grants_config: return expressions diff --git a/sqlmesh/core/engine_adapter/clickhouse.py b/sqlmesh/core/engine_adapter/clickhouse.py index 45c22a6e55..71a834ecfc 100644 --- a/sqlmesh/core/engine_adapter/clickhouse.py +++ b/sqlmesh/core/engine_adapter/clickhouse.py @@ -64,7 +64,7 @@ def cluster(self) -> t.Optional[str]: # doesn't use the row index at all def fetchone( self, - query: t.Union[exp.Expression, str], + query: t.Union[exp.Expr, str], ignore_unsupported_errors: bool = False, quote_identifiers: bool = False, ) -> t.Tuple: @@ -77,13 +77,11 @@ def fetchone( return self.cursor.fetchall()[0] def _fetch_native_df( - self, query: t.Union[exp.Expression, str], quote_identifiers: bool = False + self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False ) -> pd.DataFrame: """Fetches a Pandas DataFrame from the cursor""" return self.cursor.client.query_df( - self._to_sql(query, quote=quote_identifiers) - if isinstance(query, exp.Expression) - else query, + self._to_sql(query, quote=quote_identifiers) if isinstance(query, exp.Expr) else query, use_extended_dtypes=True, ) @@ -168,7 +166,7 @@ def create_schema( schema_name: SchemaName, ignore_if_exists: bool = True, warn_on_error: bool = True, - properties: t.List[exp.Expression] = [], + properties: t.List[exp.Expr] = [], ) -> None: """Create a Clickhouse database from a name or qualified table name. @@ -229,7 +227,7 @@ def _insert_overwrite_by_condition( # REPLACE BY KEY: extract kwargs if present dynamic_key = kwargs.get("dynamic_key") if dynamic_key: - dynamic_key_exp = t.cast(exp.Expression, kwargs.get("dynamic_key_exp")) + dynamic_key_exp = t.cast(exp.Expr, kwargs.get("dynamic_key_exp")) dynamic_key_unique = t.cast(bool, kwargs.get("dynamic_key_unique")) try: @@ -414,7 +412,7 @@ def _replace_by_key( target_table: TableName, source_table: QueryOrDF, target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]], - key: t.Sequence[exp.Expression], + key: t.Sequence[exp.Expr], is_unique_key: bool, source_columns: t.Optional[t.List[str]] = None, ) -> None: @@ -440,7 +438,7 @@ def insert_overwrite_by_partition( self, table_name: TableName, query_or_df: QueryOrDF, - partitioned_by: t.List[exp.Expression], + partitioned_by: t.List[exp.Expr], target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, source_columns: t.Optional[t.List[str]] = None, ) -> None: @@ -487,7 +485,7 @@ def _get_partition_ids( def _create_table( self, table_name_or_schema: t.Union[exp.Schema, TableName], - expression: t.Optional[exp.Expression], + expression: t.Optional[exp.Expr], exists: bool = True, replace: bool = False, target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, @@ -595,7 +593,7 @@ def _rename_table( self.execute(f"RENAME TABLE {old_table_sql} TO {new_table_sql}{self._on_cluster_sql()}") - def delete_from(self, table_name: TableName, where: t.Union[str, exp.Expression]) -> None: + def delete_from(self, table_name: TableName, where: t.Union[str, exp.Expr]) -> None: delete_expr = exp.delete(table_name, where) if self.engine_run_mode.is_cluster: delete_expr.set("cluster", exp.OnCluster(this=exp.to_identifier(self.cluster))) @@ -649,7 +647,7 @@ def _drop_object( def _build_partitioned_by_exp( self, - partitioned_by: t.List[exp.Expression], + partitioned_by: t.List[exp.Expr], **kwargs: t.Any, ) -> t.Optional[t.Union[exp.PartitionedByProperty, exp.Property]]: return exp.PartitionedByProperty( @@ -714,14 +712,14 @@ def use_server_nulls_for_unmatched_after_join( return query def _build_settings_property( - self, key: str, value: exp.Expression | str | int | float + self, key: str, value: exp.Expr | str | int | float ) -> exp.SettingsProperty: return exp.SettingsProperty( expressions=[ exp.EQ( this=exp.var(key.lower()), expression=value - if isinstance(value, exp.Expression) + if isinstance(value, exp.Expr) else exp.Literal(this=value, is_string=isinstance(value, str)), ) ] @@ -732,17 +730,17 @@ def _build_table_properties_exp( catalog_name: t.Optional[str] = None, table_format: t.Optional[str] = None, storage_format: t.Optional[str] = None, - partitioned_by: t.Optional[t.List[exp.Expression]] = None, + partitioned_by: t.Optional[t.List[exp.Expr]] = None, partition_interval_unit: t.Optional[IntervalUnit] = None, - clustered_by: t.Optional[t.List[exp.Expression]] = None, - table_properties: t.Optional[t.Dict[str, exp.Expression]] = None, + clustered_by: t.Optional[t.List[exp.Expr]] = None, + table_properties: t.Optional[t.Dict[str, exp.Expr]] = None, target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, table_description: t.Optional[str] = None, table_kind: t.Optional[str] = None, empty_ctas: bool = False, **kwargs: t.Any, ) -> t.Optional[exp.Properties]: - properties: t.List[exp.Expression] = [] + properties: t.List[exp.Expr] = [] table_engine = self.DEFAULT_TABLE_ENGINE if storage_format: @@ -809,9 +807,7 @@ def _build_table_properties_exp( ttl = table_properties_copy.pop("TTL", None) if ttl: properties.append( - exp.MergeTreeTTL( - expressions=[ttl if isinstance(ttl, exp.Expression) else exp.var(ttl)] - ) + exp.MergeTreeTTL(expressions=[ttl if isinstance(ttl, exp.Expr) else exp.var(ttl)]) ) if ( @@ -845,12 +841,12 @@ def _build_table_properties_exp( def _build_view_properties_exp( self, - view_properties: t.Optional[t.Dict[str, exp.Expression]] = None, + view_properties: t.Optional[t.Dict[str, exp.Expr]] = None, table_description: t.Optional[str] = None, **kwargs: t.Any, ) -> t.Optional[exp.Properties]: """Creates a SQLGlot table properties expression for view""" - properties: t.List[exp.Expression] = [] + properties: t.List[exp.Expr] = [] view_properties_copy = view_properties.copy() if view_properties else {} diff --git a/sqlmesh/core/engine_adapter/databricks.py b/sqlmesh/core/engine_adapter/databricks.py index 870b946e7d..e3d029a17d 100644 --- a/sqlmesh/core/engine_adapter/databricks.py +++ b/sqlmesh/core/engine_adapter/databricks.py @@ -163,7 +163,7 @@ def _grant_object_kind(table_type: DataObjectType) -> str: return "MATERIALIZED VIEW" return "TABLE" - def _get_grant_expression(self, table: exp.Table) -> exp.Expression: + def _get_grant_expression(self, table: exp.Table) -> exp.Expr: # We only care about explicitly granted privileges and not inherited ones # if this is removed you would see grants inherited from the catalog get returned expression = super()._get_grant_expression(table) @@ -210,7 +210,7 @@ def query_factory() -> Query: return [SourceQuery(query_factory=query_factory)] def _fetch_native_df( - self, query: t.Union[exp.Expression, str], quote_identifiers: bool = False + self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False ) -> DF: """Fetches a DataFrame that can be either Pandas or PySpark from the cursor""" if self.is_spark_session_connection: @@ -223,7 +223,7 @@ def _fetch_native_df( return self.cursor.fetchall_arrow().to_pandas() def fetchdf( - self, query: t.Union[exp.Expression, str], quote_identifiers: bool = False + self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False ) -> pd.DataFrame: """ Returns a Pandas DataFrame from a query or expression. @@ -364,10 +364,10 @@ def _build_table_properties_exp( catalog_name: t.Optional[str] = None, table_format: t.Optional[str] = None, storage_format: t.Optional[str] = None, - partitioned_by: t.Optional[t.List[exp.Expression]] = None, + partitioned_by: t.Optional[t.List[exp.Expr]] = None, partition_interval_unit: t.Optional[IntervalUnit] = None, - clustered_by: t.Optional[t.List[exp.Expression]] = None, - table_properties: t.Optional[t.Dict[str, exp.Expression]] = None, + clustered_by: t.Optional[t.List[exp.Expr]] = None, + table_properties: t.Optional[t.Dict[str, exp.Expr]] = None, target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, table_description: t.Optional[str] = None, table_kind: t.Optional[str] = None, diff --git a/sqlmesh/core/engine_adapter/duckdb.py b/sqlmesh/core/engine_adapter/duckdb.py index 3b057219e0..ebfcaa7901 100644 --- a/sqlmesh/core/engine_adapter/duckdb.py +++ b/sqlmesh/core/engine_adapter/duckdb.py @@ -145,7 +145,7 @@ def _get_data_objects( for row in df.itertuples() ] - def _normalize_decimal_value(self, col: exp.Expression, precision: int) -> exp.Expression: + def _normalize_decimal_value(self, col: exp.Expr, precision: int) -> exp.Expr: """ duckdb truncates instead of rounding when casting to decimal. @@ -163,7 +163,7 @@ def _normalize_decimal_value(self, col: exp.Expression, precision: int) -> exp.E def _create_table( self, table_name_or_schema: t.Union[exp.Schema, TableName], - expression: t.Optional[exp.Expression], + expression: t.Optional[exp.Expr], exists: bool = True, replace: bool = False, target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, diff --git a/sqlmesh/core/engine_adapter/mixins.py b/sqlmesh/core/engine_adapter/mixins.py index c8ef32b9da..bf4bb970a2 100644 --- a/sqlmesh/core/engine_adapter/mixins.py +++ b/sqlmesh/core/engine_adapter/mixins.py @@ -38,9 +38,9 @@ def merge( target_table: TableName, source_table: QueryOrDF, target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]], - unique_key: t.Sequence[exp.Expression], + unique_key: t.Sequence[exp.Expr], when_matched: t.Optional[exp.Whens] = None, - merge_filter: t.Optional[exp.Expression] = None, + merge_filter: t.Optional[exp.Expr] = None, source_columns: t.Optional[t.List[str]] = None, **kwargs: t.Any, ) -> None: @@ -58,18 +58,14 @@ def merge( class PandasNativeFetchDFSupportMixin(EngineAdapter): def _fetch_native_df( - self, query: t.Union[exp.Expression, str], quote_identifiers: bool = False + self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False ) -> DF: """Fetches a Pandas DataFrame from a SQL query.""" from warnings import catch_warnings, filterwarnings from pandas.io.sql import read_sql_query - sql = ( - self._to_sql(query, quote=quote_identifiers) - if isinstance(query, exp.Expression) - else query - ) + sql = self._to_sql(query, quote=quote_identifiers) if isinstance(query, exp.Expr) else query logger.debug(f"Executing SQL:\n{sql}") with catch_warnings(), self.transaction(): filterwarnings( @@ -87,7 +83,7 @@ class HiveMetastoreTablePropertiesMixin(EngineAdapter): def _build_partitioned_by_exp( self, - partitioned_by: t.List[exp.Expression], + partitioned_by: t.List[exp.Expr], *, catalog_name: t.Optional[str] = None, **kwargs: t.Any, @@ -120,16 +116,16 @@ def _build_table_properties_exp( catalog_name: t.Optional[str] = None, table_format: t.Optional[str] = None, storage_format: t.Optional[str] = None, - partitioned_by: t.Optional[t.List[exp.Expression]] = None, + partitioned_by: t.Optional[t.List[exp.Expr]] = None, partition_interval_unit: t.Optional[IntervalUnit] = None, - clustered_by: t.Optional[t.List[exp.Expression]] = None, - table_properties: t.Optional[t.Dict[str, exp.Expression]] = None, + clustered_by: t.Optional[t.List[exp.Expr]] = None, + table_properties: t.Optional[t.Dict[str, exp.Expr]] = None, target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, table_description: t.Optional[str] = None, table_kind: t.Optional[str] = None, **kwargs: t.Any, ) -> t.Optional[exp.Properties]: - properties: t.List[exp.Expression] = [] + properties: t.List[exp.Expr] = [] if table_format and self.dialect == "spark": properties.append(exp.FileFormatProperty(this=exp.Var(this=table_format))) @@ -166,12 +162,12 @@ def _build_table_properties_exp( def _build_view_properties_exp( self, - view_properties: t.Optional[t.Dict[str, exp.Expression]] = None, + view_properties: t.Optional[t.Dict[str, exp.Expr]] = None, table_description: t.Optional[str] = None, **kwargs: t.Any, ) -> t.Optional[exp.Properties]: """Creates a SQLGlot table properties expression for view""" - properties: t.List[exp.Expression] = [] + properties: t.List[exp.Expr] = [] if table_description: properties.append( @@ -194,7 +190,7 @@ def _truncate_comment(self, comment: str, length: t.Optional[int]) -> str: class GetCurrentCatalogFromFunctionMixin(EngineAdapter): - CURRENT_CATALOG_EXPRESSION: exp.Expression = exp.func("current_catalog") + CURRENT_CATALOG_EXPRESSION: exp.Expr = exp.func("current_catalog") def get_current_catalog(self) -> t.Optional[str]: """Returns the catalog name of the current connection.""" @@ -240,7 +236,7 @@ def _default_precision_to_max( def _build_create_table_exp( self, table_name_or_schema: t.Union[exp.Schema, TableName], - expression: t.Optional[exp.Expression], + expression: t.Optional[exp.Expr], exists: bool = True, replace: bool = False, target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, @@ -322,11 +318,11 @@ def is_destructive(self) -> bool: return False @property - def _alter_actions(self) -> t.List[exp.Expression]: + def _alter_actions(self) -> t.List[exp.Expr]: return [exp.Cluster(expressions=self.cluster_key_expressions)] @property - def cluster_key_expressions(self) -> t.List[exp.Expression]: + def cluster_key_expressions(self) -> t.List[exp.Expr]: # Note: Assumes `clustering_key` as a string like: # - "(col_a)" # - "(col_a, col_b)" @@ -346,14 +342,14 @@ def is_destructive(self) -> bool: return False @property - def _alter_actions(self) -> t.List[exp.Expression]: + def _alter_actions(self) -> t.List[exp.Expr]: return [exp.Command(this="DROP", expression="CLUSTERING KEY")] class ClusteredByMixin(EngineAdapter): def _build_clustered_by_exp( self, - clustered_by: t.List[exp.Expression], + clustered_by: t.List[exp.Expr], **kwargs: t.Any, ) -> t.Optional[exp.Cluster]: return exp.Cluster(expressions=[c.copy() for c in clustered_by]) @@ -410,9 +406,9 @@ def logical_merge( target_table: TableName, source_table: QueryOrDF, target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]], - unique_key: t.Sequence[exp.Expression], + unique_key: t.Sequence[exp.Expr], when_matched: t.Optional[exp.Whens] = None, - merge_filter: t.Optional[exp.Expression] = None, + merge_filter: t.Optional[exp.Expr] = None, source_columns: t.Optional[t.List[str]] = None, ) -> None: """ @@ -452,12 +448,12 @@ def concat_columns( decimal_precision: int = 3, timestamp_precision: int = MAX_TIMESTAMP_PRECISION, delimiter: str = ",", - ) -> exp.Expression: + ) -> exp.Expr: """ Produce an expression that generates a string version of a record, that is: - Every column converted to a string representation, joined together into a single string using the specified :delimiter """ - expressions_to_concat: t.List[exp.Expression] = [] + expressions_to_concat: t.List[exp.Expr] = [] for idx, (column, type) in enumerate(columns_to_types.items()): expressions_to_concat.append( exp.func( @@ -475,11 +471,11 @@ def concat_columns( def normalize_value( self, - expr: exp.Expression, + expr: exp.Expr, type: exp.DataType, decimal_precision: int = 3, timestamp_precision: int = MAX_TIMESTAMP_PRECISION, - ) -> exp.Expression: + ) -> exp.Expr: """ Return an expression that converts the values inside the column `col` to a normalized string @@ -490,6 +486,7 @@ def normalize_value( - `boolean` columns -> '1' or '0' - NULLS -> "" (empty string) """ + value: exp.Expr if type.is_type(exp.DataType.Type.BOOLEAN): value = self._normalize_boolean_value(expr) elif type.is_type(*exp.DataType.INTEGER_TYPES): @@ -512,12 +509,12 @@ def normalize_value( return exp.cast(value, to=exp.DataType.build("VARCHAR")) - def _normalize_nested_value(self, expr: exp.Expression) -> exp.Expression: + def _normalize_nested_value(self, expr: exp.Expr) -> exp.Expr: return expr def _normalize_timestamp_value( - self, expr: exp.Expression, type: exp.DataType, precision: int - ) -> exp.Expression: + self, expr: exp.Expr, type: exp.DataType, precision: int + ) -> exp.Expr: if precision > self.MAX_TIMESTAMP_PRECISION: raise ValueError( f"Requested timestamp precision '{precision}' exceeds maximum supported precision: {self.MAX_TIMESTAMP_PRECISION}" @@ -547,18 +544,18 @@ def _normalize_timestamp_value( return expr - def _normalize_integer_value(self, expr: exp.Expression) -> exp.Expression: + def _normalize_integer_value(self, expr: exp.Expr) -> exp.Expr: return exp.cast(expr, "BIGINT") - def _normalize_decimal_value(self, expr: exp.Expression, precision: int) -> exp.Expression: + def _normalize_decimal_value(self, expr: exp.Expr, precision: int) -> exp.Expr: return exp.cast(expr, f"DECIMAL(38,{precision})") - def _normalize_boolean_value(self, expr: exp.Expression) -> exp.Expression: + def _normalize_boolean_value(self, expr: exp.Expr) -> exp.Expr: return exp.cast(expr, "INT") class GrantsFromInfoSchemaMixin(EngineAdapter): - CURRENT_USER_OR_ROLE_EXPRESSION: exp.Expression = exp.func("current_user") + CURRENT_USER_OR_ROLE_EXPRESSION: exp.Expr = exp.func("current_user") SUPPORTS_MULTIPLE_GRANT_PRINCIPALS = False USE_CATALOG_IN_GRANTS = False GRANT_INFORMATION_SCHEMA_TABLE_NAME = "table_privileges" @@ -578,8 +575,8 @@ def _dcl_grants_config_expr( table: exp.Table, grants_config: GrantsConfig, table_type: DataObjectType = DataObjectType.TABLE, - ) -> t.List[exp.Expression]: - expressions: t.List[exp.Expression] = [] + ) -> t.List[exp.Expr]: + expressions: t.List[exp.Expr] = [] if not grants_config: return expressions @@ -617,7 +614,7 @@ def _apply_grants_config_expr( table: exp.Table, grants_config: GrantsConfig, table_type: DataObjectType = DataObjectType.TABLE, - ) -> t.List[exp.Expression]: + ) -> t.List[exp.Expr]: return self._dcl_grants_config_expr(exp.Grant, table, grants_config, table_type) def _revoke_grants_config_expr( @@ -625,10 +622,10 @@ def _revoke_grants_config_expr( table: exp.Table, grants_config: GrantsConfig, table_type: DataObjectType = DataObjectType.TABLE, - ) -> t.List[exp.Expression]: + ) -> t.List[exp.Expr]: return self._dcl_grants_config_expr(exp.Revoke, table, grants_config, table_type) - def _get_grant_expression(self, table: exp.Table) -> exp.Expression: + def _get_grant_expression(self, table: exp.Table) -> exp.Expr: schema_identifier = table.args.get("db") or normalize_identifiers( exp.to_identifier(self._get_current_schema(), quoted=True), dialect=self.dialect ) diff --git a/sqlmesh/core/engine_adapter/mssql.py b/sqlmesh/core/engine_adapter/mssql.py index 359d1f0818..e381c0a198 100644 --- a/sqlmesh/core/engine_adapter/mssql.py +++ b/sqlmesh/core/engine_adapter/mssql.py @@ -176,7 +176,7 @@ def drop_schema( schema_name: SchemaName, ignore_if_not_exists: bool = True, cascade: bool = False, - **drop_args: t.Dict[str, exp.Expression], + **drop_args: t.Dict[str, exp.Expr], ) -> None: """ MsSql doesn't support CASCADE clause and drops schemas unconditionally. @@ -205,9 +205,9 @@ def merge( target_table: TableName, source_table: QueryOrDF, target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]], - unique_key: t.Sequence[exp.Expression], + unique_key: t.Sequence[exp.Expr], when_matched: t.Optional[exp.Whens] = None, - merge_filter: t.Optional[exp.Expression] = None, + merge_filter: t.Optional[exp.Expr] = None, source_columns: t.Optional[t.List[str]] = None, **kwargs: t.Any, ) -> None: @@ -401,7 +401,7 @@ def _get_data_objects( for row in dataframe.itertuples() ] - def _to_sql(self, expression: exp.Expression, quote: bool = True, **kwargs: t.Any) -> str: + def _to_sql(self, expression: exp.Expr, quote: bool = True, **kwargs: t.Any) -> str: sql = super()._to_sql(expression, quote=quote, **kwargs) return f"{sql};" @@ -448,7 +448,7 @@ def _insert_overwrite_by_condition( **kwargs, ) - def delete_from(self, table_name: TableName, where: t.Union[str, exp.Expression]) -> None: + def delete_from(self, table_name: TableName, where: t.Union[str, exp.Expr]) -> None: if where == exp.true(): # "A TRUNCATE TABLE operation can be rolled back within a transaction." # ref: https://learn.microsoft.com/en-us/sql/t-sql/statements/truncate-table-transact-sql?view=sql-server-ver15#remarks diff --git a/sqlmesh/core/engine_adapter/mysql.py b/sqlmesh/core/engine_adapter/mysql.py index 31773d6c63..66759dc440 100644 --- a/sqlmesh/core/engine_adapter/mysql.py +++ b/sqlmesh/core/engine_adapter/mysql.py @@ -73,7 +73,7 @@ def drop_schema( schema_name: SchemaName, ignore_if_not_exists: bool = True, cascade: bool = False, - **drop_args: t.Dict[str, exp.Expression], + **drop_args: t.Dict[str, exp.Expr], ) -> None: # MySQL doesn't support CASCADE clause and drops schemas unconditionally. super().drop_schema(schema_name, ignore_if_not_exists=ignore_if_not_exists, cascade=False) diff --git a/sqlmesh/core/engine_adapter/postgres.py b/sqlmesh/core/engine_adapter/postgres.py index 3dd108cf91..6794169322 100644 --- a/sqlmesh/core/engine_adapter/postgres.py +++ b/sqlmesh/core/engine_adapter/postgres.py @@ -40,7 +40,7 @@ class PostgresEngineAdapter( MAX_IDENTIFIER_LENGTH: t.Optional[int] = 63 SUPPORTS_QUERY_EXECUTION_TRACKING = True GRANT_INFORMATION_SCHEMA_TABLE_NAME = "role_table_grants" - CURRENT_USER_OR_ROLE_EXPRESSION: exp.Expression = exp.column("current_role") + CURRENT_USER_OR_ROLE_EXPRESSION: exp.Expr = exp.column("current_role") SUPPORTS_MULTIPLE_GRANT_PRINCIPALS = True SCHEMA_DIFFER_KWARGS = { "parameterized_type_defaults": { @@ -73,7 +73,7 @@ class PostgresEngineAdapter( } def _fetch_native_df( - self, query: t.Union[exp.Expression, str], quote_identifiers: bool = False + self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False ) -> DF: """ `read_sql_query` when using psycopg will result on a hanging transaction that must be committed @@ -113,9 +113,9 @@ def merge( target_table: TableName, source_table: QueryOrDF, target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]], - unique_key: t.Sequence[exp.Expression], + unique_key: t.Sequence[exp.Expr], when_matched: t.Optional[exp.Whens] = None, - merge_filter: t.Optional[exp.Expression] = None, + merge_filter: t.Optional[exp.Expr] = None, source_columns: t.Optional[t.List[str]] = None, **kwargs: t.Any, ) -> None: diff --git a/sqlmesh/core/engine_adapter/redshift.py b/sqlmesh/core/engine_adapter/redshift.py index 03dc89053e..c2a27954cd 100644 --- a/sqlmesh/core/engine_adapter/redshift.py +++ b/sqlmesh/core/engine_adapter/redshift.py @@ -143,7 +143,7 @@ def cursor(self) -> t.Any: return cursor def _fetch_native_df( - self, query: t.Union[exp.Expression, str], quote_identifiers: bool = False + self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False ) -> pd.DataFrame: """Fetches a Pandas DataFrame from the cursor""" import pandas as pd @@ -217,7 +217,7 @@ def create_view( materialized_properties: t.Optional[t.Dict[str, t.Any]] = None, table_description: t.Optional[str] = None, column_descriptions: t.Optional[t.Dict[str, str]] = None, - view_properties: t.Optional[t.Dict[str, exp.Expression]] = None, + view_properties: t.Optional[t.Dict[str, exp.Expr]] = None, source_columns: t.Optional[t.List[str]] = None, **create_kwargs: t.Any, ) -> None: @@ -227,7 +227,7 @@ def create_view( swap tables out from under views. Therefore, we create the view as non-binding. """ no_schema_binding = True - if isinstance(query_or_df, exp.Expression): + if isinstance(query_or_df, exp.Expr): # We can't include NO SCHEMA BINDING if the query has a recursive CTE has_recursive_cte = any( w.args.get("recursive", False) for w in query_or_df.find_all(exp.With) @@ -367,9 +367,9 @@ def merge( target_table: TableName, source_table: QueryOrDF, target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]], - unique_key: t.Sequence[exp.Expression], + unique_key: t.Sequence[exp.Expr], when_matched: t.Optional[exp.Whens] = None, - merge_filter: t.Optional[exp.Expression] = None, + merge_filter: t.Optional[exp.Expr] = None, source_columns: t.Optional[t.List[str]] = None, **kwargs: t.Any, ) -> None: @@ -400,12 +400,12 @@ def _merge( self, target_table: TableName, query: Query, - on: exp.Expression, + on: exp.Expr, whens: exp.Whens, ) -> None: # Redshift does not support table aliases in the target table of a MERGE statement. # So we must use the actual table name instead of an alias, as we do with the source table. - def resolve_target_table(expression: exp.Expression) -> exp.Expression: + def resolve_target_table(expression: exp.Expr) -> exp.Expr: if ( isinstance(expression, exp.Column) and expression.table.upper() == MERGE_TARGET_ALIAS @@ -436,7 +436,7 @@ def resolve_target_table(expression: exp.Expression) -> exp.Expression: track_rows_processed=True, ) - def _normalize_decimal_value(self, expr: exp.Expression, precision: int) -> exp.Expression: + def _normalize_decimal_value(self, expr: exp.Expr, precision: int) -> exp.Expr: # Redshift is finicky. It truncates when the data is already in a table, but rounds when the data is generated as part of a SELECT. # # The following works: diff --git a/sqlmesh/core/engine_adapter/snowflake.py b/sqlmesh/core/engine_adapter/snowflake.py index a8eabe070d..09c530b8f3 100644 --- a/sqlmesh/core/engine_adapter/snowflake.py +++ b/sqlmesh/core/engine_adapter/snowflake.py @@ -83,7 +83,7 @@ class SnowflakeEngineAdapter( SNOWPARK = "snowpark" SUPPORTS_QUERY_EXECUTION_TRACKING = True SUPPORTS_GRANTS = True - CURRENT_USER_OR_ROLE_EXPRESSION: exp.Expression = exp.func("CURRENT_ROLE") + CURRENT_USER_OR_ROLE_EXPRESSION: exp.Expr = exp.func("CURRENT_ROLE") USE_CATALOG_IN_GRANTS = True @contextlib.contextmanager @@ -95,7 +95,7 @@ def session(self, properties: SessionProperties) -> t.Iterator[None]: if isinstance(warehouse, str): warehouse = exp.to_identifier(warehouse) - if not isinstance(warehouse, exp.Expression): + if not isinstance(warehouse, exp.Expr): raise SQLMeshError(f"Invalid warehouse: '{warehouse}'") warehouse_exp = quote_identifiers( @@ -189,7 +189,7 @@ def _drop_catalog(self, catalog_name: exp.Identifier) -> None: def _create_table( self, table_name_or_schema: t.Union[exp.Schema, TableName], - expression: t.Optional[exp.Expression], + expression: t.Optional[exp.Expr], exists: bool = True, replace: bool = False, target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, @@ -225,9 +225,9 @@ def create_managed_table( table_name: TableName, query: Query, target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, - partitioned_by: t.Optional[t.List[exp.Expression]] = None, - clustered_by: t.Optional[t.List[exp.Expression]] = None, - table_properties: t.Optional[t.Dict[str, exp.Expression]] = None, + partitioned_by: t.Optional[t.List[exp.Expr]] = None, + clustered_by: t.Optional[t.List[exp.Expr]] = None, + table_properties: t.Optional[t.Dict[str, exp.Expr]] = None, table_description: t.Optional[str] = None, column_descriptions: t.Optional[t.Dict[str, str]] = None, source_columns: t.Optional[t.List[str]] = None, @@ -278,7 +278,7 @@ def create_view( materialized_properties: t.Optional[t.Dict[str, t.Any]] = None, table_description: t.Optional[str] = None, column_descriptions: t.Optional[t.Dict[str, str]] = None, - view_properties: t.Optional[t.Dict[str, exp.Expression]] = None, + view_properties: t.Optional[t.Dict[str, exp.Expr]] = None, source_columns: t.Optional[t.List[str]] = None, **create_kwargs: t.Any, ) -> None: @@ -311,16 +311,16 @@ def _build_table_properties_exp( catalog_name: t.Optional[str] = None, table_format: t.Optional[str] = None, storage_format: t.Optional[str] = None, - partitioned_by: t.Optional[t.List[exp.Expression]] = None, + partitioned_by: t.Optional[t.List[exp.Expr]] = None, partition_interval_unit: t.Optional[IntervalUnit] = None, - clustered_by: t.Optional[t.List[exp.Expression]] = None, - table_properties: t.Optional[t.Dict[str, exp.Expression]] = None, + clustered_by: t.Optional[t.List[exp.Expr]] = None, + table_properties: t.Optional[t.Dict[str, exp.Expr]] = None, target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, table_description: t.Optional[str] = None, table_kind: t.Optional[str] = None, **kwargs: t.Any, ) -> t.Optional[exp.Properties]: - properties: t.List[exp.Expression] = [] + properties: t.List[exp.Expr] = [] # TODO: there is some overlap with the base class and other engine adapters # we need a better way of filtering table properties relevent to the current engine @@ -471,7 +471,7 @@ def cleanup() -> None: return [SourceQuery(query_factory=query_factory, cleanup_func=cleanup)] def _fetch_native_df( - self, query: t.Union[exp.Expression, str], quote_identifiers: bool = False + self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False ) -> DF: import pandas as pd from snowflake.connector.errors import NotSupportedError @@ -561,7 +561,7 @@ def _get_data_objects( for row in df.rename(columns={col: col.lower() for col in df.columns}).itertuples() ] - def _get_grant_expression(self, table: exp.Table) -> exp.Expression: + def _get_grant_expression(self, table: exp.Table) -> exp.Expr: # Upon execute the catalog in table expressions are properly normalized to handle the case where a user provides # the default catalog in their connection config. This doesn't though update catalogs in strings like when querying # the information schema. So we need to manually replace those here. @@ -586,7 +586,7 @@ def set_current_catalog(self, catalog: str) -> None: def set_current_schema(self, schema: str) -> None: self.execute(exp.Use(kind="SCHEMA", this=to_schema(schema))) - def _normalize_catalog(self, expression: exp.Expression) -> exp.Expression: + def _normalize_catalog(self, expression: exp.Expr) -> exp.Expr: # note: important to use self._default_catalog instead of the self.default_catalog property # otherwise we get RecursionError: maximum recursion depth exceeded # because it calls get_current_catalog(), which executes a query, which needs the default catalog, which calls get_current_catalog()... etc @@ -604,7 +604,7 @@ def unquote_and_lower(identifier: str) -> str: self._default_catalog, dialect=self.dialect ) - def catalog_rewriter(node: exp.Expression) -> exp.Expression: + def catalog_rewriter(node: exp.Expr) -> exp.Expr: if isinstance(node, exp.Table): if node.catalog: # only replace the catalog on the model with the target catalog if the two are functionally equivalent @@ -621,7 +621,7 @@ def catalog_rewriter(node: exp.Expression) -> exp.Expression: expression = expression.transform(catalog_rewriter) return expression - def _to_sql(self, expression: exp.Expression, quote: bool = True, **kwargs: t.Any) -> str: + def _to_sql(self, expression: exp.Expr, quote: bool = True, **kwargs: t.Any) -> str: return super()._to_sql( expression=self._normalize_catalog(expression), quote=quote, **kwargs ) diff --git a/sqlmesh/core/engine_adapter/spark.py b/sqlmesh/core/engine_adapter/spark.py index 5216b0a329..9199aa3bcd 100644 --- a/sqlmesh/core/engine_adapter/spark.py +++ b/sqlmesh/core/engine_adapter/spark.py @@ -340,12 +340,12 @@ def _get_temp_table( return table def fetchdf( - self, query: t.Union[exp.Expression, str], quote_identifiers: bool = False + self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False ) -> pd.DataFrame: return self.fetch_pyspark_df(query, quote_identifiers=quote_identifiers).toPandas() def fetch_pyspark_df( - self, query: t.Union[exp.Expression, str], quote_identifiers: bool = False + self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False ) -> PySparkDataFrame: return self._ensure_pyspark_df( self._fetch_native_df(query, quote_identifiers=quote_identifiers) @@ -437,7 +437,7 @@ def _native_df_to_pandas_df( def _create_table( self, table_name_or_schema: t.Union[exp.Schema, TableName], - expression: t.Optional[exp.Expression], + expression: t.Optional[exp.Expr], exists: bool = True, replace: bool = False, target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, diff --git a/sqlmesh/core/engine_adapter/trino.py b/sqlmesh/core/engine_adapter/trino.py index 89470728f2..00acddb26c 100644 --- a/sqlmesh/core/engine_adapter/trino.py +++ b/sqlmesh/core/engine_adapter/trino.py @@ -129,7 +129,7 @@ def session(self, properties: SessionProperties) -> t.Iterator[None]: yield return - if not isinstance(authorization, exp.Expression): + if not isinstance(authorization, exp.Expr): authorization = exp.Literal.string(authorization) if not authorization.is_string: @@ -326,13 +326,13 @@ def _scd_type_2( self, target_table: TableName, source_table: QueryOrDF, - unique_key: t.Sequence[exp.Expression], + unique_key: t.Sequence[exp.Expr], valid_from_col: exp.Column, valid_to_col: exp.Column, execution_time: t.Union[TimeLike, exp.Column], invalidate_hard_deletes: bool = True, updated_at_col: t.Optional[exp.Column] = None, - check_columns: t.Optional[t.Union[exp.Star, t.Sequence[exp.Expression]]] = None, + check_columns: t.Optional[t.Union[exp.Star, t.Sequence[exp.Expr]]] = None, updated_at_as_valid_from: bool = False, execution_time_as_valid_from: bool = False, target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, @@ -409,7 +409,7 @@ def _create_schema( schema_name: SchemaName, ignore_if_exists: bool, warn_on_error: bool, - properties: t.List[exp.Expression], + properties: t.List[exp.Expr], kind: str, ) -> None: if mapped_location := self._schema_location(schema_name): @@ -426,7 +426,7 @@ def _create_schema( def _create_table( self, table_name_or_schema: t.Union[exp.Schema, TableName], - expression: t.Optional[exp.Expression], + expression: t.Optional[exp.Expr], exists: bool = True, replace: bool = False, target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, diff --git a/sqlmesh/core/lineage.py b/sqlmesh/core/lineage.py index 777a2a7d9a..8363979034 100644 --- a/sqlmesh/core/lineage.py +++ b/sqlmesh/core/lineage.py @@ -16,7 +16,7 @@ from sqlmesh.core.model import Model -CACHE: t.Dict[str, t.Tuple[int, exp.Expression, Scope]] = {} +CACHE: t.Dict[str, t.Tuple[int, exp.Expr, Scope]] = {} def lineage( @@ -25,8 +25,8 @@ def lineage( trim_selects: bool = True, **kwargs: t.Any, ) -> Node: - query = None - scope = None + query: t.Optional[exp.Expr] = None + scope: t.Optional[Scope] = None if model.name in CACHE: obj_id, query, scope = CACHE[model.name] diff --git a/sqlmesh/core/macros.py b/sqlmesh/core/macros.py index af7c344081..888acbb8eb 100644 --- a/sqlmesh/core/macros.py +++ b/sqlmesh/core/macros.py @@ -110,7 +110,7 @@ def _macro_sql(sql: str, into: t.Optional[str] = None) -> str: return f"self.parse_one({', '.join(args)})" -def _macro_func_sql(self: Generator, e: exp.Expression) -> str: +def _macro_func_sql(self: Generator, e: exp.Expr) -> str: func = e.this if isinstance(func, exp.Anonymous): @@ -178,7 +178,7 @@ def __init__( schema: t.Optional[MappingSchema] = None, runtime_stage: RuntimeStage = RuntimeStage.LOADING, resolve_table: t.Optional[t.Callable[[str | exp.Table], str]] = None, - resolve_tables: t.Optional[t.Callable[[exp.Expression], exp.Expression]] = None, + resolve_tables: t.Optional[t.Callable[[exp.Expr], exp.Expr]] = None, snapshots: t.Optional[t.Dict[str, Snapshot]] = None, default_catalog: t.Optional[str] = None, path: t.Optional[Path] = None, @@ -237,7 +237,7 @@ def __init__( def send( self, name: str, *args: t.Any, **kwargs: t.Any - ) -> t.Union[None, exp.Expression, t.List[exp.Expression]]: + ) -> t.Union[None, exp.Expr, t.List[exp.Expr]]: func = self.macros.get(normalize_macro_name(name)) if not callable(func): @@ -253,14 +253,12 @@ def send( + format_evaluated_code_exception(e, self.python_env) ) - def transform( - self, expression: exp.Expression - ) -> exp.Expression | t.List[exp.Expression] | None: + def transform(self, expression: exp.Expr) -> exp.Expr | t.List[exp.Expr] | None: changed = False def evaluate_macros( - node: exp.Expression, - ) -> exp.Expression | t.List[exp.Expression] | None: + node: exp.Expr, + ) -> exp.Expr | t.List[exp.Expr] | None: nonlocal changed if isinstance(node, MacroVar): @@ -281,14 +279,10 @@ def evaluate_macros( value = self.locals.get(var_name, variables.get(var_name)) if isinstance(value, list): return exp.convert( - tuple( - self.transform(v) if isinstance(v, exp.Expression) else v for v in value - ) + tuple(self.transform(v) if isinstance(v, exp.Expr) else v for v in value) ) - return exp.convert( - self.transform(value) if isinstance(value, exp.Expression) else value - ) + return exp.convert(self.transform(value) if isinstance(value, exp.Expr) else value) if isinstance(node, exp.Identifier) and "@" in node.this: text = self.template(node.this, {}) if node.this != text: @@ -311,7 +305,7 @@ def evaluate_macros( self.parse_one(node.sql(dialect=self.dialect, copy=False)) for node in transformed ] - if isinstance(transformed, exp.Expression): + if isinstance(transformed, exp.Expr): return self.parse_one(transformed.sql(dialect=self.dialect, copy=False)) return transformed @@ -339,7 +333,7 @@ def template(self, text: t.Any, local_variables: t.Dict[str, t.Any]) -> str: } return MacroStrTemplate(str(text)).safe_substitute(CaseInsensitiveMapping(base_mapping)) - def evaluate(self, node: MacroFunc) -> exp.Expression | t.List[exp.Expression] | None: + def evaluate(self, node: MacroFunc) -> exp.Expr | t.List[exp.Expr] | None: if isinstance(node, MacroDef): if isinstance(node.expression, exp.Lambda): _, fn = _norm_var_arg_lambda(self, node.expression) @@ -353,7 +347,7 @@ def evaluate(self, node: MacroFunc) -> exp.Expression | t.List[exp.Expression] | return node if isinstance(node, (MacroSQL, MacroStrReplace)): - result: t.Optional[exp.Expression | t.List[exp.Expression]] = exp.convert( + result: t.Optional[exp.Expr | t.List[exp.Expr]] = exp.convert( self.eval_expression(node) ) else: @@ -421,7 +415,7 @@ def eval_expression(self, node: t.Any) -> t.Any: Returns: The return value of the evaled Python Code. """ - if not isinstance(node, exp.Expression): + if not isinstance(node, exp.Expr): return node code = node.sql() try: @@ -434,8 +428,8 @@ def eval_expression(self, node: t.Any) -> t.Any: ) def parse_one( - self, sql: str | exp.Expression, into: t.Optional[exp.IntoType] = None, **opts: t.Any - ) -> exp.Expression: + self, sql: str | exp.Expr, into: t.Optional[exp.IntoType] = None, **opts: t.Any + ) -> exp.Expr: """Parses the given SQL string and returns a syntax tree for the first parsed SQL statement. @@ -497,7 +491,7 @@ def resolve_table(self, table: str | exp.Table) -> str: ) return self._resolve_table(table) - def resolve_tables(self, query: exp.Expression) -> exp.Expression: + def resolve_tables(self, query: exp.Expr) -> exp.Expr: """Resolves queries with references to SQLMesh model names to their physical tables.""" if not self._resolve_tables: raise SQLMeshError( @@ -588,7 +582,7 @@ def variables(self) -> t.Dict[str, t.Any]: **self.locals.get(c.SQLMESH_BLUEPRINT_VARS_METADATA, {}), } - def _coerce(self, expr: exp.Expression, typ: t.Any, strict: bool = False) -> t.Any: + def _coerce(self, expr: exp.Expr, typ: t.Any, strict: bool = False) -> t.Any: """Coerces the given expression to the specified type on a best-effort basis.""" return _coerce(expr, typ, self.dialect, self._path, strict) @@ -648,8 +642,8 @@ def _norm_var_arg_lambda( """ def substitute( - node: exp.Expression, args: t.Dict[str, exp.Expression] - ) -> exp.Expression | t.List[exp.Expression] | None: + node: exp.Expr, args: t.Dict[str, exp.Expr] + ) -> exp.Expr | t.List[exp.Expr] | None: if isinstance(node, (exp.Identifier, exp.Var)): if not isinstance(node.parent, exp.Column): name = node.name.lower() @@ -798,8 +792,8 @@ def filter_(evaluator: MacroEvaluator, *args: t.Any) -> t.List[t.Any]: def _optional_expression( evaluator: MacroEvaluator, condition: exp.Condition, - expression: exp.Expression, -) -> t.Optional[exp.Expression]: + expression: exp.Expr, +) -> t.Optional[exp.Expr]: """Inserts expression when the condition is True The following examples express the usage of this function in the context of the macros which wrap it. @@ -864,7 +858,7 @@ def star( suffix: exp.Literal = exp.Literal.string(""), quote_identifiers: exp.Boolean = exp.true(), except_: t.Union[exp.Array, exp.Tuple] = exp.Tuple(expressions=[]), -) -> t.List[exp.Alias]: +) -> t.List[exp.Expr]: """Returns a list of projections for the given relation. Args: @@ -939,7 +933,7 @@ def star( @macro() def generate_surrogate_key( evaluator: MacroEvaluator, - *fields: exp.Expression, + *fields: exp.Expr, hash_function: exp.Literal = exp.Literal.string("MD5"), ) -> exp.Func: """Generates a surrogate key (string) for the given fields. @@ -956,7 +950,7 @@ def generate_surrogate_key( >>> MacroEvaluator(dialect="bigquery").transform(parse_one(sql, dialect="bigquery")).sql("bigquery") "SELECT SHA256(CONCAT(COALESCE(CAST(a AS STRING), '_sqlmesh_surrogate_key_null_'), '|', COALESCE(CAST(b AS STRING), '_sqlmesh_surrogate_key_null_'), '|', COALESCE(CAST(c AS STRING), '_sqlmesh_surrogate_key_null_'))) FROM foo" """ - string_fields: t.List[exp.Expression] = [] + string_fields: t.List[exp.Expr] = [] for i, field in enumerate(fields): if i > 0: string_fields.append(exp.Literal.string("|")) @@ -980,7 +974,7 @@ def generate_surrogate_key( @macro() -def safe_add(_: MacroEvaluator, *fields: exp.Expression) -> exp.Case: +def safe_add(_: MacroEvaluator, *fields: exp.Expr) -> exp.Case: """Adds numbers together, substitutes nulls for 0s and only returns null if all fields are null. Example: @@ -998,7 +992,7 @@ def safe_add(_: MacroEvaluator, *fields: exp.Expression) -> exp.Case: @macro() -def safe_sub(_: MacroEvaluator, *fields: exp.Expression) -> exp.Case: +def safe_sub(_: MacroEvaluator, *fields: exp.Expr) -> exp.Case: """Subtract numbers, substitutes nulls for 0s and only returns null if all fields are null. Example: @@ -1016,7 +1010,7 @@ def safe_sub(_: MacroEvaluator, *fields: exp.Expression) -> exp.Case: @macro() -def safe_div(_: MacroEvaluator, numerator: exp.Expression, denominator: exp.Expression) -> exp.Div: +def safe_div(_: MacroEvaluator, numerator: exp.Expr, denominator: exp.Expr) -> exp.Div: """Divides numbers, returns null if the denominator is 0. Example: @@ -1032,7 +1026,7 @@ def safe_div(_: MacroEvaluator, numerator: exp.Expression, denominator: exp.Expr @macro() def union( evaluator: MacroEvaluator, - *args: exp.Expression, + *args: exp.Expr, ) -> exp.Query: """Returns a UNION of the given tables. Only choosing columns that have the same name and type. @@ -1107,10 +1101,10 @@ def union( @macro() def haversine_distance( _: MacroEvaluator, - lat1: exp.Expression, - lon1: exp.Expression, - lat2: exp.Expression, - lon2: exp.Expression, + lat1: exp.Expr, + lon1: exp.Expr, + lat2: exp.Expr, + lon2: exp.Expr, unit: exp.Literal = exp.Literal.string("mi"), ) -> exp.Mul: """Returns the haversine distance between two points. @@ -1150,17 +1144,17 @@ def haversine_distance( def pivot( evaluator: MacroEvaluator, column: SQL, - values: t.List[exp.Expression], + values: t.List[exp.Expr], alias: bool = True, - agg: exp.Expression = exp.Literal.string("SUM"), - cmp: exp.Expression = exp.Literal.string("="), - prefix: exp.Expression = exp.Literal.string(""), - suffix: exp.Expression = exp.Literal.string(""), + agg: exp.Expr = exp.Literal.string("SUM"), + cmp: exp.Expr = exp.Literal.string("="), + prefix: exp.Expr = exp.Literal.string(""), + suffix: exp.Expr = exp.Literal.string(""), then_value: SQL = SQL("1"), else_value: SQL = SQL("0"), quote: bool = True, distinct: bool = False, -) -> t.List[exp.Expression]: +) -> t.List[exp.Expr]: """Returns a list of projections as a result of pivoting the given column on the given values. Example: @@ -1173,14 +1167,14 @@ def pivot( >>> MacroEvaluator(dialect="bigquery").transform(parse_one(sql)).sql("bigquery") "SELECT SUM(CASE WHEN a = 'v' THEN tv ELSE 0 END) AS v_sfx" """ - aggregates: t.List[exp.Expression] = [] + aggregates: t.List[exp.Expr] = [] for value in values: proj = f"{agg.name}(" if distinct: proj += "DISTINCT " proj += f"CASE WHEN {column} {cmp.name} {value.sql(evaluator.dialect)} THEN {then_value} ELSE {else_value} END) " - node = evaluator.parse_one(proj) + node: exp.Expr = evaluator.parse_one(proj) if alias: node = node.as_( @@ -1196,7 +1190,7 @@ def pivot( @macro("AND") -def and_(evaluator: MacroEvaluator, *expressions: t.Optional[exp.Expression]) -> exp.Condition: +def and_(evaluator: MacroEvaluator, *expressions: t.Optional[exp.Expr]) -> exp.Condition: """Returns an AND statement filtering out any NULL expressions.""" conditions = [e for e in expressions if not isinstance(e, exp.Null)] @@ -1207,7 +1201,7 @@ def and_(evaluator: MacroEvaluator, *expressions: t.Optional[exp.Expression]) -> @macro("OR") -def or_(evaluator: MacroEvaluator, *expressions: t.Optional[exp.Expression]) -> exp.Condition: +def or_(evaluator: MacroEvaluator, *expressions: t.Optional[exp.Expr]) -> exp.Condition: """Returns an OR statement filtering out any NULL expressions.""" conditions = [e for e in expressions if not isinstance(e, exp.Null)] @@ -1219,8 +1213,8 @@ def or_(evaluator: MacroEvaluator, *expressions: t.Optional[exp.Expression]) -> @macro("VAR") def var( - evaluator: MacroEvaluator, var_name: exp.Expression, default: t.Optional[exp.Expression] = None -) -> exp.Expression: + evaluator: MacroEvaluator, var_name: exp.Expr, default: t.Optional[exp.Expr] = None +) -> exp.Expr: """Returns the value of a variable or the default value if the variable is not set.""" if not var_name.is_string: raise SQLMeshError(f"Invalid variable name '{var_name.sql()}'. Expected a string literal.") @@ -1230,8 +1224,8 @@ def var( @macro("BLUEPRINT_VAR") def blueprint_var( - evaluator: MacroEvaluator, var_name: exp.Expression, default: t.Optional[exp.Expression] = None -) -> exp.Expression: + evaluator: MacroEvaluator, var_name: exp.Expr, default: t.Optional[exp.Expr] = None +) -> exp.Expr: """Returns the value of a blueprint variable or the default value if the variable is not set.""" if not var_name.is_string: raise SQLMeshError( @@ -1244,8 +1238,8 @@ def blueprint_var( @macro() def deduplicate( evaluator: MacroEvaluator, - relation: exp.Expression, - partition_by: t.List[exp.Expression], + relation: exp.Expr, + partition_by: t.List[exp.Expr], order_by: t.List[str], ) -> exp.Query: """Returns a QUERY to deduplicate rows within a table @@ -1301,9 +1295,9 @@ def deduplicate( @macro() def date_spine( evaluator: MacroEvaluator, - datepart: exp.Expression, - start_date: exp.Expression, - end_date: exp.Expression, + datepart: exp.Expr, + start_date: exp.Expr, + end_date: exp.Expr, ) -> exp.Select: """Returns a query that produces a date spine with the given datepart, and range of start_date and end_date. Useful for joining as a date lookup table. @@ -1491,7 +1485,7 @@ def _coerce( """Coerces the given expression to the specified type on a best-effort basis.""" base_err_msg = f"Failed to coerce expression '{expr}' to type '{typ}'." try: - if typ is None or typ is t.Any or not isinstance(expr, exp.Expression): + if typ is None or typ is t.Any or not isinstance(expr, exp.Expr): return expr base = t.get_origin(typ) or typ @@ -1503,7 +1497,7 @@ def _coerce( except Exception: pass raise SQLMeshError(base_err_msg) - if base is SQL and isinstance(expr, exp.Expression): + if base is SQL and isinstance(expr, exp.Expr): return expr.sql(dialect) if base is t.Literal: @@ -1528,7 +1522,7 @@ def _coerce( if isinstance(expr, base): return expr - if issubclass(base, exp.Expression): + if issubclass(base, exp.Expr): d = Dialect.get_or_raise(dialect) into = base if base in d.parser_class.EXPRESSION_PARSERS else None if into is None: @@ -1603,7 +1597,7 @@ def _convert_sql(v: t.Any, dialect: DialectType) -> t.Any: except Exception: pass - if isinstance(v, exp.Expression): + if isinstance(v, exp.Expr): if (isinstance(v, exp.Column) and not v.table) or ( isinstance(v, exp.Identifier) or v.is_string ): diff --git a/sqlmesh/core/metric/definition.py b/sqlmesh/core/metric/definition.py index dd11cfd38d..70f10b2347 100644 --- a/sqlmesh/core/metric/definition.py +++ b/sqlmesh/core/metric/definition.py @@ -16,7 +16,7 @@ def load_metric_ddl( - expression: exp.Expression, dialect: t.Optional[str], path: Path = Path(), **kwargs: t.Any + expression: exp.Expr, dialect: t.Optional[str], path: Path = Path(), **kwargs: t.Any ) -> MetricMeta: """Returns a MetricMeta from raw Metric DDL.""" if not isinstance(expression, d.Metric): @@ -70,7 +70,7 @@ class MetricMeta(PydanticModel, frozen=True): name: str dialect: str - expression: exp.Expression + expression: exp.Expr description: t.Optional[str] = None owner: t.Optional[str] = None @@ -87,11 +87,11 @@ def _string_validator(cls, v: t.Any) -> t.Optional[str]: return str_or_exp_to_str(v) @field_validator("expression", mode="before") - def _validate_expression(cls, v: t.Any, info: ValidationInfo) -> exp.Expression: + def _validate_expression(cls, v: t.Any, info: ValidationInfo) -> exp.Expr: if isinstance(v, str): dialect = info.data.get("dialect") return d.parse_one(v, dialect=dialect) - if isinstance(v, exp.Expression): + if isinstance(v, exp.Expr): return v return v @@ -139,7 +139,7 @@ def to_metric( class Metric(MetricMeta, frozen=True): - expanded: exp.Expression + expanded: exp.Expr @property def aggs(self) -> t.Dict[exp.AggFunc, MeasureAndDimTables]: @@ -150,7 +150,7 @@ def aggs(self) -> t.Dict[exp.AggFunc, MeasureAndDimTables]: return { t.cast( exp.AggFunc, - t.cast(exp.Expression, agg.parent).transform( + t.cast(exp.Expr, agg.parent).transform( lambda node: ( exp.column(node.this, table=remove_namespace(node)) if isinstance(node, exp.Column) and node.table @@ -162,7 +162,7 @@ def aggs(self) -> t.Dict[exp.AggFunc, MeasureAndDimTables]: } @property - def formula(self) -> exp.Expression: + def formula(self) -> exp.Expr: """Returns the post aggregation formula of a metric. For simple metrics it is just the metric name. For derived metrics, @@ -181,7 +181,7 @@ def _raise_metric_config_error(msg: str, path: Path) -> None: raise ConfigError(f"{msg}. '{path}'") -def _get_measure_and_dim_tables(expression: exp.Expression) -> MeasureAndDimTables: +def _get_measure_and_dim_tables(expression: exp.Expr) -> MeasureAndDimTables: """Finds all the table references in a metric definition. Additionally ensure than the first table returned is the 'measure' or numeric value being aggregated. @@ -190,7 +190,7 @@ def _get_measure_and_dim_tables(expression: exp.Expression) -> MeasureAndDimTabl tables = {} measure_table = None - def is_measure(node: exp.Expression) -> bool: + def is_measure(node: exp.Expr) -> bool: parent = node.parent if isinstance(parent, exp.AggFunc) and node.arg_key == "this": diff --git a/sqlmesh/core/metric/rewriter.py b/sqlmesh/core/metric/rewriter.py index bbdc6c6135..6c9ec429a8 100644 --- a/sqlmesh/core/metric/rewriter.py +++ b/sqlmesh/core/metric/rewriter.py @@ -34,13 +34,13 @@ def __init__( self.join_type = join_type self.semantic_name = f"{semantic_schema}.{semantic_table}" - def rewrite(self, expression: exp.Expression) -> exp.Expression: + def rewrite(self, expression: exp.Expr) -> exp.Expr: for select in list(expression.find_all(exp.Select)): self._expand(select) return expression - def _build_sources(self, projections: t.List[exp.Expression]) -> SourceAggsAndJoins: + def _build_sources(self, projections: t.List[exp.Expr]) -> SourceAggsAndJoins: sources: SourceAggsAndJoins = {} for projection in projections: @@ -78,7 +78,7 @@ def _expand(self, select: exp.Select) -> None: explicit_joins = {exp.table_name(join.this): join for join in select.args.pop("joins", [])} for i, (name, (aggs, joins)) in enumerate(sources.items()): - source: exp.Expression = exp.to_table(name) + source: exp.Expr = exp.to_table(name) table_name = remove_namespace(name) if not isinstance(source, exp.Select): @@ -110,7 +110,7 @@ def _expand(self, select: exp.Select) -> None: copy=False, ) - for node in find_all_in_scope(query, (exp.Column, exp.TableAlias)): + for node in find_all_in_scope(query, exp.Column, exp.TableAlias): # type: ignore[arg-type,var-annotated] if isinstance(node, exp.Column): if node.table in mapping: node.set("table", exp.to_identifier(mapping[node.table])) @@ -123,7 +123,7 @@ def _add_joins( source: exp.Select, name: str, joins: t.Dict[str, t.Optional[exp.Join]], - group_by: t.List[exp.Expression], + group_by: t.List[exp.Expr], mapping: t.Dict[str, str], ) -> exp.Select: grain = [e.copy() for e in group_by] @@ -177,7 +177,7 @@ def _add_joins( return source.select(*grain, copy=False).group_by(*grain, copy=False) -def _replace_table(node: exp.Expression, table: str, base_alias: str) -> exp.Expression: +def _replace_table(node: exp.Expr, table: str, base_alias: str) -> exp.Expr: for column in find_all_in_scope(node, exp.Column): if column.table == base_alias: column.args["table"] = exp.to_identifier(table) @@ -185,11 +185,11 @@ def _replace_table(node: exp.Expression, table: str, base_alias: str) -> exp.Exp def rewrite( - sql: str | exp.Expression, + sql: str | exp.Expr, graph: ReferenceGraph, metrics: t.Dict[str, Metric], dialect: t.Optional[str] = "", -) -> exp.Expression: +) -> exp.Expr: rewriter = Rewriter(graph=graph, metrics=metrics, dialect=dialect) return optimize( diff --git a/sqlmesh/core/model/cache.py b/sqlmesh/core/model/cache.py index 774bfa402b..1f038c5d79 100644 --- a/sqlmesh/core/model/cache.py +++ b/sqlmesh/core/model/cache.py @@ -81,7 +81,7 @@ def get(self, name: str, entry_id: str = "") -> t.List[Model]: @dataclass class OptimizedQueryCacheEntry: - optimized_rendered_query: t.Optional[exp.Expression] + optimized_rendered_query: t.Optional[exp.Query] renderer_violations: t.Optional[t.Dict[type[Rule], t.Any]] diff --git a/sqlmesh/core/model/common.py b/sqlmesh/core/model/common.py index dc51b3379c..ccde7624bd 100644 --- a/sqlmesh/core/model/common.py +++ b/sqlmesh/core/model/common.py @@ -33,8 +33,8 @@ def make_python_env( expressions: t.Union[ - exp.Expression, - t.List[t.Union[exp.Expression, t.Tuple[exp.Expression, bool]]], + exp.Expr, + t.List[t.Union[exp.Expr, t.Tuple[exp.Expr, bool]]], ], jinja_macro_references: t.Optional[t.Set[MacroReference]], module_path: Path, @@ -71,7 +71,7 @@ def make_python_env( visited_macro_funcs: t.Set[int] = set() def _is_metadata_var( - name: str, expression: exp.Expression, appears_in_metadata_expression: bool + name: str, expression: exp.Expr, appears_in_metadata_expression: bool ) -> t.Optional[bool]: is_metadata_so_far = used_variables.get(name, True) if is_metadata_so_far is False: @@ -202,7 +202,7 @@ def _is_metadata_macro(name: str, appears_in_metadata_expression: bool) -> bool: def _extract_macro_func_variable_references( - macro_func: exp.Expression, + macro_func: exp.Expr, is_metadata: bool, ) -> t.Tuple[t.Set[str], t.Dict[int, bool], t.Set[int]]: var_references = set() @@ -292,12 +292,12 @@ def _add_variables_to_python_env( if blueprint_variables: metadata_blueprint_variables = { - k: SqlValue(sql=v.sql(dialect=dialect)) if isinstance(v, exp.Expression) else v + k: SqlValue(sql=v.sql(dialect=dialect)) if isinstance(v, exp.Expr) else v for k, v in blueprint_variables.items() if k in metadata_used_variables } blueprint_variables = { - k.lower(): SqlValue(sql=v.sql(dialect=dialect)) if isinstance(v, exp.Expression) else v + k.lower(): SqlValue(sql=v.sql(dialect=dialect)) if isinstance(v, exp.Expr) else v for k, v in blueprint_variables.items() if k in non_metadata_used_variables } @@ -469,9 +469,9 @@ def single_value_or_tuple(values: t.Sequence) -> exp.Identifier | exp.Tuple: def parse_expression( cls: t.Type, - v: t.Union[t.List[str], t.List[exp.Expression], str, exp.Expression, t.Callable, None], + v: t.Union[t.List[str], t.List[exp.Expr], str, exp.Expr, t.Callable, None], info: t.Optional[ValidationInfo], -) -> t.List[exp.Expression] | exp.Expression | t.Callable | None: +) -> t.List[exp.Expr] | exp.Expr | t.Callable | None: """Helper method to deserialize SQLGlot expressions in Pydantic Models.""" if v is None: return None @@ -483,7 +483,7 @@ def parse_expression( if isinstance(v, list): return [ - e if isinstance(e, exp.Expression) else d.parse_one(e, dialect=dialect) + e if isinstance(e, exp.Expr) else d.parse_one(e, dialect=dialect) # type: ignore[misc] for e in v if not isinstance(e, exp.Semicolon) ] @@ -498,7 +498,7 @@ def parse_expression( def parse_bool(v: t.Any) -> bool: - if isinstance(v, exp.Expression): + if isinstance(v, exp.Expr): if not isinstance(v, exp.Boolean): from sqlglot.optimizer.simplify import simplify @@ -524,7 +524,7 @@ def parse_properties( if isinstance(v, str): v = d.parse_one(v, dialect=dialect) if isinstance(v, (exp.Array, exp.Paren, exp.Tuple)): - eq_expressions: t.List[exp.Expression] = ( + eq_expressions: t.List[exp.Expr] = ( [v.unnest()] if isinstance(v, exp.Paren) else v.expressions ) @@ -665,18 +665,18 @@ class ParsableSql(PydanticModel): sql: str transaction: t.Optional[bool] = None - _parsed: t.Optional[exp.Expression] = None + _parsed: t.Optional[exp.Expr] = None _parsed_dialect: t.Optional[str] = None - def parse(self, dialect: str) -> exp.Expression: + def parse(self, dialect: str) -> exp.Expr: if self._parsed is None or self._parsed_dialect != dialect: self._parsed = d.parse_one(self.sql, dialect=dialect) self._parsed_dialect = dialect - return self._parsed + return self._parsed # type: ignore[return-value] @classmethod def from_parsed_expression( - cls, parsed_expression: exp.Expression, dialect: str, use_meta_sql: bool = False + cls, parsed_expression: exp.Expr, dialect: str, use_meta_sql: bool = False ) -> ParsableSql: sql = ( parsed_expression.meta.get("sql") or parsed_expression.sql(dialect=dialect) @@ -697,7 +697,7 @@ def _validate_parsable_sql( return v if isinstance(v, str): return ParsableSql(sql=v) - if isinstance(v, exp.Expression): + if isinstance(v, exp.Expr): return ParsableSql.from_parsed_expression( v, get_dialect(info.data), use_meta_sql=False ) @@ -707,7 +707,7 @@ def _validate_parsable_sql( ParsableSql(sql=s) if isinstance(s, str) else ParsableSql.from_parsed_expression(s, dialect, use_meta_sql=False) - if isinstance(s, exp.Expression) + if isinstance(s, exp.Expr) else ParsableSql.parse_obj(s) for s in v ] diff --git a/sqlmesh/core/model/decorator.py b/sqlmesh/core/model/decorator.py index 73452cc165..328b763f9f 100644 --- a/sqlmesh/core/model/decorator.py +++ b/sqlmesh/core/model/decorator.py @@ -193,7 +193,7 @@ def model( ) rendered_name = rendered_fields["name"] - if isinstance(rendered_name, exp.Expression): + if isinstance(rendered_name, exp.Expr): rendered_fields["name"] = rendered_name.sql(dialect=dialect) rendered_defaults = ( diff --git a/sqlmesh/core/model/definition.py b/sqlmesh/core/model/definition.py index 831b52a44e..8d4f72e918 100644 --- a/sqlmesh/core/model/definition.py +++ b/sqlmesh/core/model/definition.py @@ -215,7 +215,7 @@ def render_definition( include_python: bool = True, include_defaults: bool = False, render_query: bool = False, - ) -> t.List[exp.Expression]: + ) -> t.List[exp.Expr]: """Returns the original list of sql expressions comprising the model definition. Args: @@ -366,7 +366,7 @@ def render_pre_statements( engine_adapter: t.Optional[EngineAdapter] = None, inside_transaction: t.Optional[bool] = True, **kwargs: t.Any, - ) -> t.List[exp.Expression]: + ) -> t.List[exp.Expr]: """Renders pre-statements for a model. Pre-statements are statements that preceded the model's SELECT query. @@ -413,7 +413,7 @@ def render_post_statements( engine_adapter: t.Optional[EngineAdapter] = None, inside_transaction: t.Optional[bool] = True, **kwargs: t.Any, - ) -> t.List[exp.Expression]: + ) -> t.List[exp.Expr]: """Renders post-statements for a model. Post-statements are statements that follow after the model's SELECT query. @@ -460,7 +460,7 @@ def render_on_virtual_update( deployability_index: t.Optional[DeployabilityIndex] = None, engine_adapter: t.Optional[EngineAdapter] = None, **kwargs: t.Any, - ) -> t.List[exp.Expression]: + ) -> t.List[exp.Expr]: return self._render_statements( self.on_virtual_update, start=start, @@ -552,15 +552,15 @@ def render_audit_query( return rendered_query @property - def pre_statements(self) -> t.List[exp.Expression]: + def pre_statements(self) -> t.List[exp.Expr]: return self._get_parsed_statements("pre_statements_") @property - def post_statements(self) -> t.List[exp.Expression]: + def post_statements(self) -> t.List[exp.Expr]: return self._get_parsed_statements("post_statements_") @property - def on_virtual_update(self) -> t.List[exp.Expression]: + def on_virtual_update(self) -> t.List[exp.Expr]: return self._get_parsed_statements("on_virtual_update_") @property @@ -572,7 +572,7 @@ def macro_definitions(self) -> t.List[d.MacroDef]: if isinstance(s, d.MacroDef) ] - def _get_parsed_statements(self, attr_name: str) -> t.List[exp.Expression]: + def _get_parsed_statements(self, attr_name: str) -> t.List[exp.Expr]: value = getattr(self, attr_name) if not value: return [] @@ -587,9 +587,9 @@ def _get_parsed_statements(self, attr_name: str) -> t.List[exp.Expression]: def _render_statements( self, - statements: t.Iterable[exp.Expression], + statements: t.Iterable[exp.Expr], **kwargs: t.Any, - ) -> t.List[exp.Expression]: + ) -> t.List[exp.Expr]: rendered = ( self._statement_renderer(statement).render(**kwargs) for statement in statements @@ -597,7 +597,7 @@ def _render_statements( ) return [r for expressions in rendered if expressions for r in expressions] - def _statement_renderer(self, expression: exp.Expression) -> ExpressionRenderer: + def _statement_renderer(self, expression: exp.Expr) -> ExpressionRenderer: expression_key = id(expression) if expression_key not in self._statement_renderer_cache: self._statement_renderer_cache[expression_key] = ExpressionRenderer( @@ -631,7 +631,7 @@ def render_signals( The list of rendered expressions. """ - def _render(e: exp.Expression) -> str | int | float | bool: + def _render(e: exp.Expr) -> str | int | float | bool: rendered_exprs = ( self._create_renderer(e).render(start=start, end=end, execution_time=execution_time) or [] @@ -676,7 +676,7 @@ def render_merge_filter( start: t.Optional[TimeLike] = None, end: t.Optional[TimeLike] = None, execution_time: t.Optional[TimeLike] = None, - ) -> t.Optional[exp.Expression]: + ) -> t.Optional[exp.Expr]: if self.merge_filter is None: return None rendered_exprs = ( @@ -690,9 +690,9 @@ def render_merge_filter( return rendered_exprs[0].transform(d.replace_merge_table_aliases, dialect=self.dialect) def _render_properties( - self, properties: t.Dict[str, exp.Expression] | SessionProperties, **render_kwargs: t.Any + self, properties: t.Dict[str, exp.Expr] | SessionProperties, **render_kwargs: t.Any ) -> t.Dict[str, t.Any]: - def _render(expression: exp.Expression) -> exp.Expression | None: + def _render(expression: exp.Expr) -> exp.Expr | None: # note: we use the _statement_renderer instead of _create_renderer because it sets model_fqn which # in turn makes @this_model available in the evaluation context rendered_exprs = self._statement_renderer(expression).render(**render_kwargs) @@ -714,7 +714,7 @@ def _render(expression: exp.Expression) -> exp.Expression | None: return { k: rendered for k, v in properties.items() - if (rendered := (_render(v) if isinstance(v, exp.Expression) else v)) + if (rendered := (_render(v) if isinstance(v, exp.Expr) else v)) } def render_physical_properties(self, **render_kwargs: t.Any) -> t.Dict[str, t.Any]: @@ -726,7 +726,7 @@ def render_virtual_properties(self, **render_kwargs: t.Any) -> t.Dict[str, t.Any def render_session_properties(self, **render_kwargs: t.Any) -> t.Dict[str, t.Any]: return self._render_properties(properties=self.session_properties, **render_kwargs) - def _create_renderer(self, expression: exp.Expression) -> ExpressionRenderer: + def _create_renderer(self, expression: exp.Expr) -> ExpressionRenderer: return ExpressionRenderer( expression, self.dialect, @@ -822,7 +822,7 @@ def set_time_format(self, default_time_format: str = c.DEFAULT_TIME_COLUMN_FORMA def convert_to_time_column( self, time: TimeLike, columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None - ) -> exp.Expression: + ) -> exp.Expr: """Convert a TimeLike object to the same time format and type as the model's time column.""" if self.time_column: if columns_to_types is None: @@ -970,7 +970,7 @@ def validate_definition(self) -> None: col.name for expr in values for col in t.cast( - exp.Expression, exp.maybe_parse(expr, dialect=self.dialect) + exp.Expr, exp.maybe_parse(expr, dialect=self.dialect) ).find_all(exp.Column) ] @@ -1266,7 +1266,7 @@ def _additional_metadata(self) -> t.List[str]: return additional_metadata - def _is_metadata_statement(self, statement: exp.Expression) -> bool: + def _is_metadata_statement(self, statement: exp.Expr) -> bool: if isinstance(statement, d.MacroDef): return True if isinstance(statement, d.MacroFunc): @@ -1295,7 +1295,7 @@ def full_depends_on(self) -> t.Set[str]: return self._full_depends_on @property - def partitioned_by(self) -> t.List[exp.Expression]: + def partitioned_by(self) -> t.List[exp.Expr]: """Columns to partition the model by, including the time column if it is not already included.""" if self.time_column and not self._is_time_column_in_partitioned_by: # This allows the user to opt out of automatic time_column injection @@ -1323,7 +1323,7 @@ def partition_interval_unit(self) -> t.Optional[IntervalUnit]: return None @property - def audits_with_args(self) -> t.List[t.Tuple[Audit, t.Dict[str, exp.Expression]]]: + def audits_with_args(self) -> t.List[t.Tuple[Audit, t.Dict[str, exp.Expr]]]: from sqlmesh.core.audit.builtin import BUILT_IN_AUDITS audits_by_name = {**BUILT_IN_AUDITS, **self.audit_definitions} @@ -1422,8 +1422,8 @@ def render_definition( include_python: bool = True, include_defaults: bool = False, render_query: bool = False, - ) -> t.List[exp.Expression]: - result = super().render_definition( + ) -> t.List[exp.Expr]: + result: t.List[exp.Expr] = super().render_definition( include_python=include_python, include_defaults=include_defaults ) @@ -1946,7 +1946,7 @@ def render_definition( include_python: bool = True, include_defaults: bool = False, render_query: bool = False, - ) -> t.List[exp.Expression]: + ) -> t.List[exp.Expr]: # Ignore the provided value for the include_python flag, since the Pyhon model's # definition without Python code is meaningless. return super().render_definition( @@ -2001,7 +2001,7 @@ class AuditResult(PydanticModel): """The model this audit is for.""" count: t.Optional[int] = None """The number of records returned by the audit query. This could be None if the audit was skipped.""" - query: t.Optional[exp.Expression] = None + query: t.Optional[exp.Expr] = None """The rendered query used by the audit. This could be None if the audit was skipped.""" skipped: bool = False """Whether or not the audit was blocking. This can be overriden by the user.""" @@ -2009,7 +2009,7 @@ class AuditResult(PydanticModel): class EvaluatableSignals(PydanticModel): - signals_to_kwargs: t.Dict[str, t.Dict[str, t.Optional[exp.Expression]]] + signals_to_kwargs: t.Dict[str, t.Dict[str, t.Optional[exp.Expr]]] """A mapping of signal names to the kwargs passed to the signal.""" python_env: t.Dict[str, Executable] """The Python environment that should be used to evaluated the rendered signal calls.""" @@ -2054,7 +2054,7 @@ def _extract_blueprint_variables(blueprint: t.Any, path: Path) -> t.Dict[str, t. def create_models_from_blueprints( - gateway: t.Optional[str | exp.Expression], + gateway: t.Optional[str | exp.Expr], blueprints: t.Any, get_variables: t.Callable[[t.Optional[str]], t.Dict[str, str]], loader: t.Callable[..., Model], @@ -2105,7 +2105,7 @@ def create_models_from_blueprints( def load_sql_based_models( - expressions: t.List[exp.Expression], + expressions: t.List[exp.Expr], get_variables: t.Callable[[t.Optional[str]], t.Dict[str, str]], path: Path = Path(), module_path: Path = Path(), @@ -2113,8 +2113,8 @@ def load_sql_based_models( default_catalog_per_gateway: t.Optional[t.Dict[str, str]] = None, **loader_kwargs: t.Any, ) -> t.List[Model]: - gateway: t.Optional[exp.Expression] = None - blueprints: t.Optional[exp.Expression] = None + gateway: t.Optional[exp.Expr] = None + blueprints: t.Optional[exp.Expr] = None model_meta = seq_get(expressions, 0) for prop in (isinstance(model_meta, d.Model) and model_meta.expressions) or []: @@ -2160,7 +2160,7 @@ def load_sql_based_models( def load_sql_based_model( - expressions: t.List[exp.Expression], + expressions: t.List[exp.Expr], *, defaults: t.Optional[t.Dict[str, t.Any]] = None, path: t.Optional[Path] = None, @@ -2306,7 +2306,7 @@ def load_sql_based_model( if kind_prop.name.lower() == "merge_filter": meta_fields["kind"].expressions[idx] = unrendered_merge_filter - if isinstance(meta_fields.get("dialect"), exp.Expression): + if isinstance(meta_fields.get("dialect"), exp.Expr): meta_fields["dialect"] = meta_fields["dialect"].name # The name of the model will be inferred from its path relative to `models/`, if it's not explicitly specified @@ -2367,7 +2367,7 @@ def load_sql_based_model( def create_sql_model( name: TableName, - query: t.Optional[exp.Expression], + query: t.Optional[exp.Expr], **kwargs: t.Any, ) -> Model: """Creates a SQL model. @@ -2492,7 +2492,7 @@ def create_python_model( ) depends_on = { dep.sql(dialect=dialect) - for dep in t.cast(t.List[exp.Expression], depends_on_rendered)[0].expressions + for dep in t.cast(t.List[exp.Expr], depends_on_rendered)[0].expressions } used_variables = {k: v for k, v in (variables or {}).items() if k in referenced_variables} @@ -2597,7 +2597,7 @@ def _create_model( if not issubclass(klass, SqlModel): defaults.pop("optimize_query", None) - statements: t.List[t.Union[exp.Expression, t.Tuple[exp.Expression, bool]]] = [] + statements: t.List[t.Union[exp.Expr, t.Tuple[exp.Expr, bool]]] = [] if "query" in kwargs: statements.append(kwargs["query"]) @@ -2636,11 +2636,11 @@ def _create_model( if isinstance(property_values, exp.Tuple): statements.extend(property_values.expressions) - if isinstance(getattr(kwargs.get("kind"), "merge_filter", None), exp.Expression): + if isinstance(getattr(kwargs.get("kind"), "merge_filter", None), exp.Expr): statements.append(kwargs["kind"].merge_filter) jinja_macro_references, referenced_variables = extract_macro_references_and_variables( - *(gen(e if isinstance(e, exp.Expression) else e[0]) for e in statements) + *(gen(e if isinstance(e, exp.Expr) else e[0]) for e in statements) ) if jinja_macros: @@ -2687,7 +2687,7 @@ def _create_model( model.audit_definitions.update(audit_definitions) # Any macro referenced in audits or signals needs to be treated as metadata-only - statements.extend((audit.query, True) for audit in audit_definitions.values()) + statements.extend((audit.query, True) for audit in audit_definitions.values()) # type: ignore[misc] # Ensure that all audits referenced in the model are defined from sqlmesh.core.audit.builtin import BUILT_IN_AUDITS @@ -2743,14 +2743,14 @@ def _create_model( def _split_sql_model_statements( - expressions: t.List[exp.Expression], + expressions: t.List[exp.Expr], path: t.Optional[Path], dialect: t.Optional[str] = None, ) -> t.Tuple[ - t.Optional[exp.Expression], - t.List[exp.Expression], - t.List[exp.Expression], - t.List[exp.Expression], + t.Optional[exp.Expr], + t.List[exp.Expr], + t.List[exp.Expr], + t.List[exp.Expr], UniqueKeyDict[str, ModelAudit], ]: """Extracts the SELECT query from a sequence of expressions. @@ -2811,8 +2811,8 @@ def _split_sql_model_statements( def _resolve_properties( default: t.Optional[t.Dict[str, t.Any]], - provided: t.Optional[exp.Expression | t.Dict[str, t.Any]], -) -> t.Optional[exp.Expression]: + provided: t.Optional[exp.Expr | t.Dict[str, t.Any]], +) -> t.Optional[exp.Expr]: if isinstance(provided, dict): properties = {k: exp.Literal.string(k).eq(v) for k, v in provided.items()} elif provided: @@ -2834,7 +2834,7 @@ def _resolve_properties( return None -def _list_of_calls_to_exp(value: t.List[t.Tuple[str, t.Dict[str, t.Any]]]) -> exp.Expression: +def _list_of_calls_to_exp(value: t.List[t.Tuple[str, t.Dict[str, t.Any]]]) -> exp.Expr: return exp.Tuple( expressions=[ exp.Anonymous( @@ -2849,16 +2849,16 @@ def _list_of_calls_to_exp(value: t.List[t.Tuple[str, t.Dict[str, t.Any]]]) -> ex ) -def _is_projection(expr: exp.Expression) -> bool: +def _is_projection(expr: exp.Expr) -> bool: parent = expr.parent return isinstance(parent, exp.Select) and expr.arg_key == "expressions" -def _single_expr_or_tuple(values: t.Sequence[exp.Expression]) -> exp.Expression | exp.Tuple: +def _single_expr_or_tuple(values: t.Sequence[exp.Expr]) -> exp.Expr | exp.Tuple: return values[0] if len(values) == 1 else exp.Tuple(expressions=values) -def _refs_to_sql(values: t.Any) -> exp.Expression: +def _refs_to_sql(values: t.Any) -> exp.Expr: return exp.Tuple(expressions=values) @@ -2874,7 +2874,7 @@ def render_meta_fields( blueprint_variables: t.Optional[t.Dict[str, t.Any]] = None, ) -> t.Dict[str, t.Any]: def render_field_value(value: t.Any) -> t.Any: - if isinstance(value, exp.Expression) or (isinstance(value, str) and "@" in value): + if isinstance(value, exp.Expr) or (isinstance(value, str) and "@" in value): expression = exp.maybe_parse(value, dialect=dialect) rendered_expr = render_expression( expression=expression, @@ -3011,7 +3011,7 @@ def parse_defaults_properties( def render_expression( - expression: exp.Expression, + expression: exp.Expr, module_path: Path, path: t.Optional[Path], jinja_macros: t.Optional[JinjaMacroRegistry] = None, @@ -3020,7 +3020,7 @@ def render_expression( variables: t.Optional[t.Dict[str, t.Any]] = None, default_catalog: t.Optional[str] = None, blueprint_variables: t.Optional[t.Dict[str, t.Any]] = None, -) -> t.Optional[t.List[exp.Expression]]: +) -> t.Optional[t.List[exp.Expr]]: meta_python_env = make_python_env( expressions=expression, jinja_macro_references=None, @@ -3092,8 +3092,8 @@ def get_model_name(path: Path) -> str: # function applied to time column when automatically used for partitioning in INCREMENTAL_BY_TIME_RANGE models def clickhouse_partition_func( - column: exp.Expression, columns_to_types: t.Optional[t.Dict[str, exp.DataType]] -) -> exp.Expression: + column: exp.Expr, columns_to_types: t.Optional[t.Dict[str, exp.DataType]] +) -> exp.Expr: # `toMonday()` function accepts a Date or DateTime type column col_type = (columns_to_types and columns_to_types.get(column.name)) or exp.DataType.build( diff --git a/sqlmesh/core/model/kind.py b/sqlmesh/core/model/kind.py index 9abaa9c650..d7a7bb9579 100644 --- a/sqlmesh/core/model/kind.py +++ b/sqlmesh/core/model/kind.py @@ -279,7 +279,7 @@ def model_kind_name(self) -> t.Optional[ModelKindName]: return self.name def to_expression( - self, expressions: t.Optional[t.List[exp.Expression]] = None, **kwargs: t.Any + self, expressions: t.Optional[t.List[exp.Expr]] = None, **kwargs: t.Any ) -> d.ModelKind: kwargs["expressions"] = expressions return d.ModelKind(this=self.name.value.upper(), **kwargs) @@ -294,7 +294,7 @@ def metadata_hash_values(self) -> t.List[t.Optional[str]]: class TimeColumn(PydanticModel): - column: exp.Expression + column: exp.Expr format: t.Optional[str] = None @classmethod @@ -306,7 +306,7 @@ def _time_column_validator(v: t.Any, info: ValidationInfo) -> TimeColumn: @field_validator("column", mode="before") @classmethod - def _column_validator(cls, v: t.Union[str, exp.Expression]) -> exp.Expression: + def _column_validator(cls, v: t.Union[str, exp.Expr]) -> exp.Expr: if not v: raise ConfigError("Time Column cannot be empty.") if isinstance(v, str): @@ -314,14 +314,14 @@ def _column_validator(cls, v: t.Union[str, exp.Expression]) -> exp.Expression: return v @property - def expression(self) -> exp.Expression: + def expression(self) -> exp.Expr: """Convert this pydantic model into a time_column SQLGlot expression.""" if not self.format: return self.column return exp.Tuple(expressions=[self.column, exp.Literal.string(self.format)]) - def to_expression(self, dialect: str) -> exp.Expression: + def to_expression(self, dialect: str) -> exp.Expr: """Convert this pydantic model into a time_column SQLGlot expression.""" if not self.format: return self.column @@ -346,7 +346,7 @@ def create(cls, v: t.Any, dialect: str) -> Self: exp.column(column_expr) if isinstance(column_expr, exp.Identifier) else column_expr ) format = v.expressions[1].name if len(v.expressions) > 1 else None - elif isinstance(v, exp.Expression): + elif isinstance(v, exp.Expr): column = exp.column(v) if isinstance(v, exp.Identifier) else v format = None elif isinstance(v, str): @@ -400,7 +400,7 @@ def metadata_hash_values(self) -> t.List[t.Optional[str]]: ] def to_expression( - self, expressions: t.Optional[t.List[exp.Expression]] = None, **kwargs: t.Any + self, expressions: t.Optional[t.List[exp.Expr]] = None, **kwargs: t.Any ) -> d.ModelKind: return super().to_expression( expressions=[ @@ -444,7 +444,7 @@ def metadata_hash_values(self) -> t.List[t.Optional[str]]: ] def to_expression( - self, expressions: t.Optional[t.List[exp.Expression]] = None, **kwargs: t.Any + self, expressions: t.Optional[t.List[exp.Expr]] = None, **kwargs: t.Any ) -> d.ModelKind: return super().to_expression( expressions=[ @@ -473,7 +473,7 @@ class IncrementalByTimeRangeKind(_IncrementalBy): _time_column_validator = TimeColumn.validator() def to_expression( - self, expressions: t.Optional[t.List[exp.Expression]] = None, **kwargs: t.Any + self, expressions: t.Optional[t.List[exp.Expr]] = None, **kwargs: t.Any ) -> d.ModelKind: return super().to_expression( expressions=[ @@ -513,7 +513,7 @@ class IncrementalByUniqueKeyKind(_IncrementalBy): ) unique_key: SQLGlotListOfFields when_matched: t.Optional[exp.Whens] = None - merge_filter: t.Optional[exp.Expression] = None + merge_filter: t.Optional[exp.Expr] = None batch_concurrency: t.Literal[1] = 1 @field_validator("when_matched", mode="before") @@ -543,9 +543,9 @@ def _when_matched_validator( @field_validator("merge_filter", mode="before") def _merge_filter_validator( cls, - v: t.Optional[exp.Expression], + v: t.Optional[exp.Expr], info: ValidationInfo, - ) -> t.Optional[exp.Expression]: + ) -> t.Optional[exp.Expr]: if v is None: return v @@ -568,7 +568,7 @@ def data_hash_values(self) -> t.List[t.Optional[str]]: ] def to_expression( - self, expressions: t.Optional[t.List[exp.Expression]] = None, **kwargs: t.Any + self, expressions: t.Optional[t.List[exp.Expr]] = None, **kwargs: t.Any ) -> d.ModelKind: return super().to_expression( expressions=[ @@ -590,7 +590,7 @@ class IncrementalByPartitionKind(_Incremental): disable_restatement: SQLGlotBool = False @field_validator("forward_only", mode="before") - def _forward_only_validator(cls, v: t.Union[bool, exp.Expression]) -> t.Literal[True]: + def _forward_only_validator(cls, v: t.Union[bool, exp.Expr]) -> t.Literal[True]: if v is not True: raise ConfigError( "Do not specify the `forward_only` configuration key - INCREMENTAL_BY_PARTITION models are always forward_only." @@ -606,7 +606,7 @@ def metadata_hash_values(self) -> t.List[t.Optional[str]]: ] def to_expression( - self, expressions: t.Optional[t.List[exp.Expression]] = None, **kwargs: t.Any + self, expressions: t.Optional[t.List[exp.Expr]] = None, **kwargs: t.Any ) -> d.ModelKind: return super().to_expression( expressions=[ @@ -640,7 +640,7 @@ def metadata_hash_values(self) -> t.List[t.Optional[str]]: ] def to_expression( - self, expressions: t.Optional[t.List[exp.Expression]] = None, **kwargs: t.Any + self, expressions: t.Optional[t.List[exp.Expr]] = None, **kwargs: t.Any ) -> d.ModelKind: return super().to_expression( expressions=[ @@ -669,7 +669,7 @@ def supports_python_models(self) -> bool: return False def to_expression( - self, expressions: t.Optional[t.List[exp.Expression]] = None, **kwargs: t.Any + self, expressions: t.Optional[t.List[exp.Expr]] = None, **kwargs: t.Any ) -> d.ModelKind: return super().to_expression( expressions=[ @@ -690,7 +690,7 @@ class SeedKind(_ModelKind): def _parse_csv_settings(cls, v: t.Any) -> t.Optional[CsvSettings]: if v is None or isinstance(v, CsvSettings): return v - if isinstance(v, exp.Expression): + if isinstance(v, exp.Expr): tuple_exp = parse_properties(cls, v, None) if not tuple_exp: return None @@ -700,7 +700,7 @@ def _parse_csv_settings(cls, v: t.Any) -> t.Optional[CsvSettings]: return v def to_expression( - self, expressions: t.Optional[t.List[exp.Expression]] = None, **kwargs: t.Any + self, expressions: t.Optional[t.List[exp.Expr]] = None, **kwargs: t.Any ) -> d.ModelKind: """Convert the seed kind into a SQLGlot expression.""" return super().to_expression( @@ -756,13 +756,16 @@ class _SCDType2Kind(_Incremental): @field_validator("time_data_type", mode="before") @classmethod - def _time_data_type_validator( - cls, v: t.Union[str, exp.Expression], values: t.Any - ) -> exp.Expression: - if isinstance(v, exp.Expression) and not isinstance(v, exp.DataType): + def _time_data_type_validator(cls, v: t.Union[str, exp.Expr], values: t.Any) -> exp.Expr: + if isinstance(v, exp.Expr) and not isinstance(v, exp.DataType): v = v.name dialect = get_dialect(values) data_type = exp.DataType.build(v, dialect=dialect) + # Clear meta["sql"] (set by our parser extension) so the pydantic encoder + # uses dialect-aware rendering: e.sql(dialect=meta["dialect"]). Without this, + # the raw SQL text takes priority, which can be wrong for dialect-normalized + # types (e.g., default "TIMESTAMP" should render as "DATETIME" in BigQuery). + data_type.meta.pop("sql", None) data_type.meta["dialect"] = dialect return data_type @@ -795,7 +798,7 @@ def metadata_hash_values(self) -> t.List[t.Optional[str]]: ] def to_expression( - self, expressions: t.Optional[t.List[exp.Expression]] = None, **kwargs: t.Any + self, expressions: t.Optional[t.List[exp.Expr]] = None, **kwargs: t.Any ) -> d.ModelKind: return super().to_expression( expressions=[ @@ -835,7 +838,7 @@ def data_hash_values(self) -> t.List[t.Optional[str]]: ] def to_expression( - self, expressions: t.Optional[t.List[exp.Expression]] = None, **kwargs: t.Any + self, expressions: t.Optional[t.List[exp.Expr]] = None, **kwargs: t.Any ) -> d.ModelKind: return super().to_expression( expressions=[ @@ -871,7 +874,7 @@ def data_hash_values(self) -> t.List[t.Optional[str]]: ] def to_expression( - self, expressions: t.Optional[t.List[exp.Expression]] = None, **kwargs: t.Any + self, expressions: t.Optional[t.List[exp.Expr]] = None, **kwargs: t.Any ) -> d.ModelKind: return super().to_expression( expressions=[ @@ -922,7 +925,7 @@ def data_hash_values(self) -> t.List[t.Optional[str]]: ] def to_expression( - self, expressions: t.Optional[t.List[exp.Expression]] = None, **kwargs: t.Any + self, expressions: t.Optional[t.List[exp.Expr]] = None, **kwargs: t.Any ) -> d.ModelKind: return super().to_expression( expressions=[ @@ -1005,7 +1008,7 @@ def metadata_hash_values(self) -> t.List[t.Optional[str]]: ] def to_expression( - self, expressions: t.Optional[t.List[exp.Expression]] = None, **kwargs: t.Any + self, expressions: t.Optional[t.List[exp.Expr]] = None, **kwargs: t.Any ) -> d.ModelKind: return super().to_expression( expressions=[ @@ -1142,7 +1145,7 @@ def create_model_kind(v: t.Any, dialect: str, defaults: t.Dict[str, t.Any]) -> M ) return kind_type(**props) - name = (v.name if isinstance(v, exp.Expression) else str(v)).upper() + name = (v.name if isinstance(v, exp.Expr) else str(v)).upper() return model_kind_type_from_name(name)(name=name) # type: ignore diff --git a/sqlmesh/core/model/meta.py b/sqlmesh/core/model/meta.py index c48b7d1524..a73d6d871a 100644 --- a/sqlmesh/core/model/meta.py +++ b/sqlmesh/core/model/meta.py @@ -50,7 +50,7 @@ from sqlmesh.core._typing import CustomMaterializationProperties, SessionProperties from sqlmesh.core.engine_adapter._typing import GrantsConfig -FunctionCall = t.Tuple[str, t.Dict[str, exp.Expression]] +FunctionCall = t.Tuple[str, t.Dict[str, exp.Expr]] class GrantsTargetLayer(str, Enum): @@ -92,8 +92,8 @@ class ModelMeta(_Node): retention: t.Optional[int] = None # not implemented yet table_format: t.Optional[str] = None storage_format: t.Optional[str] = None - partitioned_by_: t.List[exp.Expression] = Field(default=[], alias="partitioned_by") - clustered_by: t.List[exp.Expression] = [] + partitioned_by_: t.List[exp.Expr] = Field(default=[], alias="partitioned_by") + clustered_by: t.List[exp.Expr] = [] default_catalog: t.Optional[str] = None depends_on_: t.Optional[t.Set[str]] = Field(default=None, alias="depends_on") columns_to_types_: t.Optional[t.Dict[str, exp.DataType]] = Field(default=None, alias="columns") @@ -101,8 +101,8 @@ class ModelMeta(_Node): default=None, alias="column_descriptions" ) audits: t.List[FunctionCall] = [] - grains: t.List[exp.Expression] = [] - references: t.List[exp.Expression] = [] + grains: t.List[exp.Expr] = [] + references: t.List[exp.Expr] = [] physical_schema_override: t.Optional[str] = None physical_properties_: t.Optional[exp.Tuple] = Field(default=None, alias="physical_properties") virtual_properties_: t.Optional[exp.Tuple] = Field(default=None, alias="virtual_properties") @@ -151,11 +151,11 @@ def _normalize(value: t.Any) -> t.Any: if isinstance(v, (exp.Tuple, exp.Array)): return [_normalize(e).name for e in v.expressions] - if isinstance(v, exp.Expression): + if isinstance(v, exp.Expr): return _normalize(v).name if isinstance(v, str): value = _normalize(v) - return value.name if isinstance(value, exp.Expression) else value + return value.name if isinstance(value, exp.Expr) else value if isinstance(v, (list, tuple)): return [cls._validate_value_or_tuple(elm, data, normalize=normalize) for elm in v] @@ -163,7 +163,7 @@ def _normalize(value: t.Any) -> t.Any: @field_validator("table_format", "storage_format", mode="before") def _format_validator(cls, v: t.Any, info: ValidationInfo) -> t.Optional[str]: - if isinstance(v, exp.Expression) and not (isinstance(v, (exp.Literal, exp.Identifier))): + if isinstance(v, exp.Expr) and not (isinstance(v, (exp.Literal, exp.Identifier))): return v.sql(info.data.get("dialect")) return str_or_exp_to_str(v) @@ -188,9 +188,7 @@ def _gateway_validator(cls, v: t.Any) -> t.Optional[str]: return gateway and gateway.lower() @field_validator("partitioned_by_", "clustered_by", mode="before") - def _partition_and_cluster_validator( - cls, v: t.Any, info: ValidationInfo - ) -> t.List[exp.Expression]: + def _partition_and_cluster_validator(cls, v: t.Any, info: ValidationInfo) -> t.List[exp.Expr]: if ( isinstance(v, list) and all(isinstance(i, str) for i in v) @@ -244,9 +242,33 @@ def _columns_validator( return columns_to_types if isinstance(v, dict): - udt = Dialect.get_or_raise(dialect).SUPPORTS_USER_DEFINED_TYPES + dialect_obj = Dialect.get_or_raise(dialect) + udt = dialect_obj.SUPPORTS_USER_DEFINED_TYPES for k, data_type in v.items(): + is_string_type = isinstance(data_type, str) expr = exp.DataType.build(data_type, dialect=dialect, udt=udt) + # When deserializing from a string (e.g. JSON roundtrip), normalize the type + # through the dialect's type system so that aliases (e.g. INT in BigQuery, + # which is an alias for INT64/BIGINT) are resolved to their canonical form. + # This ensures stable data hash computation across serialization/deserialization + # roundtrips. We skip this for DataType objects passed directly (Python API) + # since those should be used as-is. + if ( + is_string_type + and dialect + and expr.this + not in ( + exp.DataType.Type.USERDEFINED, + exp.DataType.Type.UNKNOWN, + ) + ): + sql_repr = expr.sql(dialect=dialect) + try: + normalized = parse_one(sql_repr, read=dialect, into=exp.DataType) + if normalized is not None: + expr = normalized + except Exception: + pass expr.meta["dialect"] = dialect columns_to_types[normalize_identifiers(k, dialect=dialect).name] = expr @@ -295,7 +317,7 @@ def _column_descriptions_validator( return col_descriptions @field_validator("grains", "references", mode="before") - def _refs_validator(cls, vs: t.Any, info: ValidationInfo) -> t.List[exp.Expression]: + def _refs_validator(cls, vs: t.Any, info: ValidationInfo) -> t.List[exp.Expr]: dialect = info.data.get("dialect") if isinstance(vs, exp.Paren): @@ -349,7 +371,7 @@ def session_properties_validator(cls, v: t.Any, info: ValidationInfo) -> t.Any: "Invalid value for `session_properties.query_label`. Must be an array or tuple." ) - label_tuples: t.List[exp.Expression] = ( + label_tuples: t.List[exp.Expr] = ( [query_label.unnest()] if isinstance(query_label, exp.Paren) else query_label.expressions @@ -449,7 +471,7 @@ def time_column(self) -> t.Optional[TimeColumn]: return getattr(self.kind, "time_column", None) @property - def unique_key(self) -> t.List[exp.Expression]: + def unique_key(self) -> t.List[exp.Expr]: if isinstance( self.kind, (SCDType2ByTimeKind, SCDType2ByColumnKind, IncrementalByUniqueKeyKind) ): @@ -485,14 +507,14 @@ def batch_concurrency(self) -> t.Optional[int]: return getattr(self.kind, "batch_concurrency", None) @cached_property - def physical_properties(self) -> t.Dict[str, exp.Expression]: + def physical_properties(self) -> t.Dict[str, exp.Expr]: """A dictionary of properties that will be applied to the physical layer. It replaces table_properties which is deprecated.""" if self.physical_properties_: return {e.this.name: e.expression for e in self.physical_properties_.expressions} return {} @cached_property - def virtual_properties(self) -> t.Dict[str, exp.Expression]: + def virtual_properties(self) -> t.Dict[str, exp.Expr]: """A dictionary of properties that will be applied to the virtual layer.""" if self.virtual_properties_: return {e.this.name: e.expression for e in self.virtual_properties_.expressions} @@ -568,7 +590,7 @@ def when_matched(self) -> t.Optional[exp.Whens]: return None @property - def merge_filter(self) -> t.Optional[exp.Expression]: + def merge_filter(self) -> t.Optional[exp.Expr]: if isinstance(self.kind, IncrementalByUniqueKeyKind): return self.kind.merge_filter return None @@ -601,7 +623,7 @@ def on_additive_change(self) -> OnAdditiveChange: def ignored_rules(self) -> t.Set[str]: return self.ignored_rules_ or set() - def _validate_config_expression(self, expr: exp.Expression) -> str: + def _validate_config_expression(self, expr: exp.Expr) -> str: if isinstance(expr, (d.MacroFunc, d.MacroVar)): raise ConfigError(f"Unresolved macro: {expr.sql(dialect=self.dialect)}") @@ -614,10 +636,10 @@ def _validate_config_expression(self, expr: exp.Expression) -> str: return expr.name return expr.sql(dialect=self.dialect).strip() - def _validate_nested_config_values(self, value_expr: exp.Expression) -> t.List[str]: + def _validate_nested_config_values(self, value_expr: exp.Expr) -> t.List[str]: result = [] - def flatten_expr(expr: exp.Expression) -> None: + def flatten_expr(expr: exp.Expr) -> None: if isinstance(expr, exp.Array): for elem in expr.expressions: flatten_expr(elem) diff --git a/sqlmesh/core/model/seed.py b/sqlmesh/core/model/seed.py index fe1aa85204..9fd57fe6d3 100644 --- a/sqlmesh/core/model/seed.py +++ b/sqlmesh/core/model/seed.py @@ -49,7 +49,7 @@ def _bool_validator(cls, v: t.Any) -> t.Optional[bool]: ) @classmethod def _str_validator(cls, v: t.Any) -> t.Optional[str]: - if v is None or not isinstance(v, exp.Expression): + if v is None or not isinstance(v, exp.Expr): return v # SQLGlot parses escape sequences like \t as \\t for dialects that don't treat \ as @@ -60,7 +60,7 @@ def _str_validator(cls, v: t.Any) -> t.Optional[str]: @field_validator("na_values", mode="before") @classmethod def _na_values_validator(cls, v: t.Any) -> t.Optional[NaValues]: - if v is None or not isinstance(v, exp.Expression): + if v is None or not isinstance(v, exp.Expr): return v try: diff --git a/sqlmesh/core/node.py b/sqlmesh/core/node.py index 4a3bf2564b..d3b63312f1 100644 --- a/sqlmesh/core/node.py +++ b/sqlmesh/core/node.py @@ -215,7 +215,7 @@ def post_init(self) -> Self: self.alias = None return self - def to_expression(self) -> exp.Expression: + def to_expression(self) -> exp.Expr: """Produce a SQLGlot expression representing this object, for use in things like the model/audit definition renderers""" return exp.tuple_( *( @@ -324,7 +324,7 @@ def copy(self, **kwargs: t.Any) -> Self: def _name_validator(cls, v: t.Any) -> t.Optional[str]: if v is None: return None - if isinstance(v, exp.Expression): + if isinstance(v, exp.Expr): return v.meta["sql"] return str(v) @@ -352,7 +352,7 @@ def _cron_tz_validator(cls, v: t.Any) -> t.Optional[zoneinfo.ZoneInfo]: @field_validator("start", "end", mode="before") @classmethod def _date_validator(cls, v: t.Any) -> t.Optional[TimeLike]: - if isinstance(v, exp.Expression): + if isinstance(v, exp.Expr): v = v.name if v and not to_datetime(v): raise ConfigError(f"'{v}' needs to be time-like: https://pypi.org/project/dateparser") @@ -555,6 +555,6 @@ def __str__(self) -> str: def str_or_exp_to_str(v: t.Any) -> t.Optional[str]: - if isinstance(v, exp.Expression): + if isinstance(v, exp.Expr): return v.name return str(v) if v is not None else None diff --git a/sqlmesh/core/reference.py b/sqlmesh/core/reference.py index 2bf2c04e98..9e93ce7b38 100644 --- a/sqlmesh/core/reference.py +++ b/sqlmesh/core/reference.py @@ -14,7 +14,7 @@ class Reference(PydanticModel, frozen=True): model_name: str - expression: exp.Expression + expression: exp.Expr unique: bool = False _name: str = "" diff --git a/sqlmesh/core/renderer.py b/sqlmesh/core/renderer.py index 50c1faeb63..7683956064 100644 --- a/sqlmesh/core/renderer.py +++ b/sqlmesh/core/renderer.py @@ -48,7 +48,7 @@ class BaseExpressionRenderer: def __init__( self, - expression: exp.Expression, + expression: exp.Expr, dialect: DialectType, macro_definitions: t.List[d.MacroDef], path: t.Optional[Path] = None, @@ -73,7 +73,7 @@ def __init__( self._normalize_identifiers = normalize_identifiers self._quote_identifiers = quote_identifiers self.update_schema({} if schema is None else schema) - self._cache: t.List[t.Optional[exp.Expression]] = [] + self._cache: t.List[t.Optional[exp.Expr]] = [] self._model_fqn = model.fqn if model else None self._optimize_query_flag = optimize_query is not False self._model = model @@ -91,7 +91,7 @@ def _render( deployability_index: t.Optional[DeployabilityIndex] = None, runtime_stage: RuntimeStage = RuntimeStage.LOADING, **kwargs: t.Any, - ) -> t.List[t.Optional[exp.Expression]]: + ) -> t.List[t.Optional[exp.Expr]]: """Renders a expression, expanding macros with provided kwargs Args: @@ -205,7 +205,7 @@ def _resolve_table(table: str | exp.Table) -> str: if variables: macro_evaluator.locals.setdefault(c.SQLMESH_VARS, {}).update(variables) - expressions = [self._expression] + expressions: t.List[exp.Expr] = [self._expression] if isinstance(self._expression, d.Jinja): try: jinja_env_kwargs = { @@ -283,7 +283,7 @@ def _resolve_table(table: str | exp.Table) -> str: f"Failed to evaluate macro '{definition}'.\n\n{ex}\n", self._path ) - resolved_expressions: t.List[t.Optional[exp.Expression]] = [] + resolved_expressions: t.List[t.Optional[exp.Expr]] = [] for expression in expressions: try: @@ -294,7 +294,7 @@ def _resolve_table(table: str | exp.Table) -> str: self._path, ) - for expression in t.cast(t.List[exp.Expression], transformed_expressions): + for expression in t.cast(t.List[exp.Expr], transformed_expressions): with self._normalize_and_quote(expression) as expression: if hasattr(expression, "selects"): for select in expression.selects: @@ -320,12 +320,12 @@ def _resolve_table(table: str | exp.Table) -> str: self._cache = resolved_expressions return resolved_expressions - def update_cache(self, expression: t.Optional[exp.Expression]) -> None: + def update_cache(self, expression: t.Optional[exp.Expr]) -> None: self._cache = [expression] def _resolve_table( self, - table_name: str | exp.Expression, + table_name: str | exp.Expr, snapshots: t.Optional[t.Dict[str, Snapshot]] = None, table_mapping: t.Optional[t.Dict[str, str]] = None, deployability_index: t.Optional[DeployabilityIndex] = None, @@ -380,7 +380,7 @@ def _resolve_tables( if snapshot.is_model } - def _expand(node: exp.Expression) -> exp.Expression: + def _expand(node: exp.Expr) -> exp.Expr: if isinstance(node, exp.Table) and snapshots: name = exp.table_name(node, identify=True) model = model_mapping.get(name) @@ -449,7 +449,7 @@ def render( deployability_index: t.Optional[DeployabilityIndex] = None, expand: t.Iterable[str] = tuple(), **kwargs: t.Any, - ) -> t.Optional[t.List[exp.Expression]]: + ) -> t.Optional[t.List[exp.Expr]]: try: expressions = super()._render( start=start, @@ -631,7 +631,7 @@ def render( def update_cache( self, - expression: t.Optional[exp.Expression], + expression: t.Optional[exp.Expr], violated_rules: t.Optional[t.Dict[type[Rule], t.Any]] = None, optimized: bool = False, ) -> None: diff --git a/sqlmesh/core/schema_diff.py b/sqlmesh/core/schema_diff.py index e1f9d72a6c..ecf38b18a8 100644 --- a/sqlmesh/core/schema_diff.py +++ b/sqlmesh/core/schema_diff.py @@ -37,7 +37,7 @@ def is_additive(self) -> bool: @property @abc.abstractmethod - def _alter_actions(self) -> t.List[exp.Expression]: + def _alter_actions(self) -> t.List[exp.Expr]: pass @property @@ -104,7 +104,7 @@ def is_destructive(self) -> bool: return self.is_part_of_destructive_change @property - def _alter_actions(self) -> t.List[exp.Expression]: + def _alter_actions(self) -> t.List[exp.Expr]: column_def = exp.ColumnDef( this=self.column, kind=self.column_type, @@ -127,7 +127,7 @@ def is_destructive(self) -> bool: return True @property - def _alter_actions(self) -> t.List[exp.Expression]: + def _alter_actions(self) -> t.List[exp.Expr]: return [exp.Drop(this=self.column, kind="COLUMN", cascade=self.cascade)] @@ -145,7 +145,7 @@ def is_destructive(self) -> bool: return self.is_part_of_destructive_change @property - def _alter_actions(self) -> t.List[exp.Expression]: + def _alter_actions(self) -> t.List[exp.Expr]: return [ exp.AlterColumn( this=self.column, @@ -363,14 +363,12 @@ class SchemaDiffer(PydanticModel): coerceable_types_: t.Dict[exp.DataType, t.Set[exp.DataType]] = Field( default_factory=dict, alias="coerceable_types" ) - precision_increase_allowed_types: t.Optional[t.Set[exp.DataType.Type]] = None + precision_increase_allowed_types: t.Optional[t.Set[exp.DType]] = None support_coercing_compatible_types: bool = False drop_cascade: bool = False - parameterized_type_defaults: t.Dict[ - exp.DataType.Type, t.List[t.Tuple[t.Union[int, float], ...]] - ] = {} - max_parameter_length: t.Dict[exp.DataType.Type, t.Union[int, float]] = {} - types_with_unlimited_length: t.Dict[exp.DataType.Type, t.Set[exp.DataType.Type]] = {} + parameterized_type_defaults: t.Dict[exp.DType, t.List[t.Tuple[t.Union[int, float], ...]]] = {} + max_parameter_length: t.Dict[exp.DType, t.Union[int, float]] = {} + types_with_unlimited_length: t.Dict[exp.DType, t.Set[exp.DType]] = {} treat_alter_data_type_as_destructive: bool = False _coerceable_types: t.Dict[exp.DataType, t.Set[exp.DataType]] = {} diff --git a/sqlmesh/core/selector.py b/sqlmesh/core/selector.py index 3865327acd..9eaf4995c8 100644 --- a/sqlmesh/core/selector.py +++ b/sqlmesh/core/selector.py @@ -191,7 +191,7 @@ def expand_model_selections( models_by_tags.setdefault(tag, set()) models_by_tags[tag].add(model.fqn) - def evaluate(node: exp.Expression) -> t.Set[str]: + def evaluate(node: exp.Expr) -> t.Set[str]: if isinstance(node, exp.Var): pattern = node.this if "*" in pattern: @@ -400,7 +400,7 @@ class Direction(exp.Expression): pass -def parse(selector: str, dialect: DialectType = None) -> exp.Expression: +def parse(selector: str, dialect: DialectType = None) -> exp.Expr: tokens = SelectorDialect().tokenize(selector) i = 0 @@ -444,7 +444,7 @@ def _parse_kind(kind: str) -> bool: return True return False - def _parse_var() -> exp.Expression: + def _parse_var() -> exp.Expr: upstream = _match(TokenType.PLUS) downstream = None tag = _parse_kind("tag") @@ -457,7 +457,7 @@ def _parse_var() -> exp.Expression: name = _prev().text rstar = "*" if _match(TokenType.STAR) else "" downstream = _match(TokenType.PLUS) - this: exp.Expression = exp.Var(this=f"{lstar}{name}{rstar}") + this: exp.Expr = exp.Var(this=f"{lstar}{name}{rstar}") elif _match(TokenType.L_PAREN): this = exp.Paren(this=_parse_conjunction()) @@ -483,12 +483,12 @@ def _parse_var() -> exp.Expression: this = Direction(this=this, **directions) return this - def _parse_unary() -> exp.Expression: + def _parse_unary() -> exp.Expr: if _match(TokenType.CARET): return exp.Not(this=_parse_unary()) return _parse_var() - def _parse_conjunction() -> exp.Expression: + def _parse_conjunction() -> exp.Expr: this = _parse_unary() if _match(TokenType.AMP): diff --git a/sqlmesh/core/snapshot/evaluator.py b/sqlmesh/core/snapshot/evaluator.py index 4f5102cbef..b1ffd4dc26 100644 --- a/sqlmesh/core/snapshot/evaluator.py +++ b/sqlmesh/core/snapshot/evaluator.py @@ -249,7 +249,7 @@ def evaluate_and_fetch( query_or_df = next(queries_or_dfs) if isinstance(query_or_df, pd.DataFrame): return query_or_df.head(limit) - if not isinstance(query_or_df, exp.Expression): + if not isinstance(query_or_df, exp.Expr): # We assume that if this branch is reached, `query_or_df` is a pyspark / snowpark / bigframe dataframe, # so we use `limit` instead of `head` to get back a dataframe instead of List[Row] # https://spark.apache.org/docs/3.1.1/api/python/reference/api/pyspark.sql.DataFrame.head.html#pyspark.sql.DataFrame.head @@ -940,7 +940,7 @@ def _render_and_insert_snapshot( snapshots: t.Dict[str, Snapshot], render_kwargs: t.Dict[str, t.Any], create_render_kwargs: t.Dict[str, t.Any], - rendered_physical_properties: t.Dict[str, exp.Expression], + rendered_physical_properties: t.Dict[str, exp.Expr], deployability_index: DeployabilityIndex, target_table_name: str, is_first_insert: bool, @@ -1069,7 +1069,7 @@ def _clone_snapshot_in_dev( snapshots: t.Dict[str, Snapshot], deployability_index: DeployabilityIndex, render_kwargs: t.Dict[str, t.Any], - rendered_physical_properties: t.Dict[str, exp.Expression], + rendered_physical_properties: t.Dict[str, exp.Expr], allow_destructive_snapshots: t.Set[str], allow_additive_snapshots: t.Set[str], run_pre_post_statements: bool = False, @@ -1186,7 +1186,7 @@ def _migrate_target_table( snapshots: t.Dict[str, Snapshot], deployability_index: DeployabilityIndex, render_kwargs: t.Dict[str, t.Any], - rendered_physical_properties: t.Dict[str, exp.Expression], + rendered_physical_properties: t.Dict[str, exp.Expr], allow_destructive_snapshots: t.Set[str], allow_additive_snapshots: t.Set[str], run_pre_post_statements: bool = False, @@ -1472,7 +1472,7 @@ def _execute_create( is_table_deployable: bool, deployability_index: DeployabilityIndex, create_render_kwargs: t.Dict[str, t.Any], - rendered_physical_properties: t.Dict[str, exp.Expression], + rendered_physical_properties: t.Dict[str, exp.Expr], dry_run: bool, run_pre_post_statements: bool = True, skip_grants: bool = False, @@ -3106,7 +3106,7 @@ def create( query=model.render_query_or_raise(**render_kwargs), target_columns_to_types=model.columns_to_types, partitioned_by=model.partitioned_by, - clustered_by=model.clustered_by, + clustered_by=model.clustered_by, # type: ignore[arg-type] table_properties=kwargs.get("physical_properties", model.physical_properties), table_description=model.description, column_descriptions=model.column_descriptions, @@ -3151,7 +3151,7 @@ def insert( query=query_or_df, # type: ignore target_columns_to_types=model.columns_to_types, partitioned_by=model.partitioned_by, - clustered_by=model.clustered_by, + clustered_by=model.clustered_by, # type: ignore[arg-type] table_properties=kwargs.get("physical_properties", model.physical_properties), table_description=model.description, column_descriptions=model.column_descriptions, diff --git a/sqlmesh/core/state_sync/common.py b/sqlmesh/core/state_sync/common.py index 2e8c67ac29..d1208c5213 100644 --- a/sqlmesh/core/state_sync/common.py +++ b/sqlmesh/core/state_sync/common.py @@ -141,8 +141,8 @@ def _expanded_tuple_comparison( cls, columns: t.List[exp.Column], values: t.List[t.Union[exp.Literal, exp.Neg]], - operator: t.Type[exp.Expression], - ) -> exp.Expression: + operator: t.Type[exp.Expr], + ) -> exp.Condition: """Generate expanded tuple comparison that works across all SQL engines. Converts tuple comparisons like (a, b, c) OP (x, y, z) into an expanded form @@ -177,8 +177,8 @@ def _expanded_tuple_comparison( # e.g., (a, b) <= (x, y) becomes: a < x OR (a = x AND b <= y) # For < and >, we use the strict operator throughout # e.g., (a, b) > (x, y) becomes: a > x OR (a = x AND b > x) - strict_operator: t.Type[exp.Expression] - final_operator: t.Type[exp.Expression] + strict_operator: t.Type[exp.Expr] + final_operator: t.Type[exp.Expr] if operator in (exp.LTE, exp.GTE): # For inclusive operators (<=, >=), use strict form for intermediate columns @@ -190,7 +190,7 @@ def _expanded_tuple_comparison( strict_operator = operator final_operator = operator - conditions: t.List[exp.Expression] = [] + conditions: t.List[exp.Expr] = [] for i in range(len(columns)): # Build equality conditions for all columns before current equality_conditions = [exp.EQ(this=columns[j], expression=values[j]) for j in range(i)] @@ -204,10 +204,10 @@ def _expanded_tuple_comparison( else: conditions.append(comparison_condition) - return exp.or_(*conditions) if len(conditions) > 1 else conditions[0] + return exp.or_(*conditions) if len(conditions) > 1 else t.cast(exp.Condition, conditions[0]) @property - def where_filter(self) -> exp.Expression: + def where_filter(self) -> exp.Condition: # Use expanded tuple comparisons for cross-engine compatibility # Native tuple comparisons like (a, b) > (x, y) don't work reliably across all SQL engines columns = [ @@ -223,7 +223,7 @@ def where_filter(self) -> exp.Expression: start_condition = self._expanded_tuple_comparison(columns, start_values, exp.GT) - range_filter: exp.Expression + range_filter: exp.Condition if isinstance(self.end, RowBoundary): end_values = [ exp.Literal.number(self.end.updated_ts), diff --git a/sqlmesh/core/state_sync/db/environment.py b/sqlmesh/core/state_sync/db/environment.py index e3f1d1ec9e..713ce0193e 100644 --- a/sqlmesh/core/state_sync/db/environment.py +++ b/sqlmesh/core/state_sync/db/environment.py @@ -296,7 +296,7 @@ def _environment_summmary_from_row(self, row: t.Tuple[str, ...]) -> EnvironmentS def _environments_query( self, - where: t.Optional[str | exp.Expression] = None, + where: t.Optional[str | exp.Expr] = None, lock_for_update: bool = False, required_fields: t.Optional[t.List[str]] = None, ) -> exp.Select: @@ -310,7 +310,7 @@ def _environments_query( return query.lock(copy=False) return query - def _create_expiration_filter_expr(self, current_ts: int) -> exp.Expression: + def _create_expiration_filter_expr(self, current_ts: int) -> exp.Expr: """Creates a SQLGlot filter expression to find expired environments. Args: @@ -322,7 +322,7 @@ def _create_expiration_filter_expr(self, current_ts: int) -> exp.Expression: ) def _fetch_environment_summaries( - self, where: t.Optional[str | exp.Expression] = None + self, where: t.Optional[str | exp.Expr] = None ) -> t.List[EnvironmentSummary]: return [ self._environment_summmary_from_row(row) diff --git a/sqlmesh/core/state_sync/db/snapshot.py b/sqlmesh/core/state_sync/db/snapshot.py index d584c69d65..8ca98f2d48 100644 --- a/sqlmesh/core/state_sync/db/snapshot.py +++ b/sqlmesh/core/state_sync/db/snapshot.py @@ -623,7 +623,7 @@ def _get_snapshots_expressions( self, snapshot_ids: t.Iterable[SnapshotIdLike], lock_for_update: bool = False, - ) -> t.Iterator[exp.Expression]: + ) -> t.Iterator[exp.Expr]: for where in snapshot_id_filter( self.engine_adapter, snapshot_ids, diff --git a/sqlmesh/core/state_sync/db/utils.py b/sqlmesh/core/state_sync/db/utils.py index 87c259f5d6..b0f321e21f 100644 --- a/sqlmesh/core/state_sync/db/utils.py +++ b/sqlmesh/core/state_sync/db/utils.py @@ -123,11 +123,9 @@ def create_batches(l: t.List[T], batch_size: int) -> t.List[t.List[T]]: return [l[i : i + batch_size] for i in range(0, len(l), batch_size)] -def fetchone( - engine_adapter: EngineAdapter, query: t.Union[exp.Expression, str] -) -> t.Optional[t.Tuple]: +def fetchone(engine_adapter: EngineAdapter, query: t.Union[exp.Expr, str]) -> t.Optional[t.Tuple]: return engine_adapter.fetchone(query, ignore_unsupported_errors=True, quote_identifiers=True) -def fetchall(engine_adapter: EngineAdapter, query: t.Union[exp.Expression, str]) -> t.List[t.Tuple]: +def fetchall(engine_adapter: EngineAdapter, query: t.Union[exp.Expr, str]) -> t.List[t.Tuple]: return engine_adapter.fetchall(query, ignore_unsupported_errors=True, quote_identifiers=True) diff --git a/sqlmesh/core/state_sync/export_import.py b/sqlmesh/core/state_sync/export_import.py index 3a63351ddb..2461ee50fa 100644 --- a/sqlmesh/core/state_sync/export_import.py +++ b/sqlmesh/core/state_sync/export_import.py @@ -29,7 +29,7 @@ class SQLMeshJSONStreamEncoder(JSONStreamEncoder): def default(self, obj: t.Any) -> t.Any: - if isinstance(obj, exp.Expression): + if isinstance(obj, exp.Expr): return _expression_encoder(obj) return super().default(obj) diff --git a/sqlmesh/core/table_diff.py b/sqlmesh/core/table_diff.py index bd32cc170f..df99227f89 100644 --- a/sqlmesh/core/table_diff.py +++ b/sqlmesh/core/table_diff.py @@ -224,9 +224,9 @@ def __init__( adapter: EngineAdapter, source: TableName, target: TableName, - on: t.List[str] | exp.Condition, + on: t.List[str] | exp.Expr, skip_columns: t.List[str] | None = None, - where: t.Optional[str | exp.Condition] = None, + where: t.Optional[str | exp.Expr] = None, limit: int = 20, source_alias: t.Optional[str] = None, target_alias: t.Optional[str] = None, @@ -305,18 +305,18 @@ def key_columns(self) -> t.Tuple[t.List[exp.Column], t.List[exp.Column], t.List[ return s_index, t_index, index_cols @property - def source_key_expression(self) -> exp.Expression: + def source_key_expression(self) -> exp.Expr: s_index, _, _ = self.key_columns return self._key_expression(s_index, self.source_schema) @property - def target_key_expression(self) -> exp.Expression: + def target_key_expression(self) -> exp.Expr: _, t_index, _ = self.key_columns return self._key_expression(t_index, self.target_schema) def _key_expression( self, cols: t.List[exp.Column], schema: t.Dict[str, exp.DataType] - ) -> exp.Expression: + ) -> exp.Expr: # if there is a single column, dont do anything fancy to it in order to allow existing indexes to be hit if len(cols) == 1: return exp.to_column(cols[0].name) @@ -363,7 +363,7 @@ def row_diff( s_index_names = [c.name for c in s_index] t_index_names = [t.name for t in t_index] - def _column_expr(name: str, table: str) -> exp.Expression: + def _column_expr(name: str, table: str) -> exp.Expr: column_type = matched_columns[name] qualified_column = exp.column(name, table) @@ -678,9 +678,9 @@ def _column_expr(name: str, table: str) -> exp.Expression: def _fetch_sample( self, sample_table: exp.Table, - s_selects: t.Dict[str, exp.Alias], + s_selects: t.Dict[str, exp.Expr], s_index: t.List[exp.Column], - t_selects: t.Dict[str, exp.Alias], + t_selects: t.Dict[str, exp.Expr], t_index: t.List[exp.Column], limit: int, ) -> pd.DataFrame: @@ -742,5 +742,5 @@ def _fetch_sample( return self.adapter.fetchdf(query, quote_identifiers=True) -def name(e: exp.Expression) -> str: +def name(e: exp.Expr) -> str: return e.args["alias"].sql(identify=True) diff --git a/sqlmesh/core/test/definition.py b/sqlmesh/core/test/definition.py index 2a838753de..629e8f8d5b 100644 --- a/sqlmesh/core/test/definition.py +++ b/sqlmesh/core/test/definition.py @@ -674,7 +674,7 @@ def _add_missing_columns( class SqlModelTest(ModelTest): - def test_ctes(self, ctes: t.Dict[str, exp.Expression], recursive: bool = False) -> None: + def test_ctes(self, ctes: t.Dict[str, exp.Expr], recursive: bool = False) -> None: """Run CTE queries and compare output to expected output""" for cte_name, values in self.body["outputs"].get("ctes", {}).items(): with self.subTest(cte=cte_name): @@ -819,7 +819,7 @@ def _execute_model(self) -> pd.DataFrame: time_kwargs = {key: variables.pop(key) for key in TIME_KWARG_KEYS if key in variables} df = next(self.model.render(context=self.context, variables=variables, **time_kwargs)) - assert not isinstance(df, exp.Expression) + assert not isinstance(df, exp.Expr) return df if isinstance(df, pd.DataFrame) else df.toPandas() diff --git a/sqlmesh/dbt/model.py b/sqlmesh/dbt/model.py index 41cea9b9ae..55994abf85 100644 --- a/sqlmesh/dbt/model.py +++ b/sqlmesh/dbt/model.py @@ -485,7 +485,7 @@ def model_kind(self, context: DbtContext) -> ModelKind: raise ConfigError(f"{materialization.value} materialization not supported.") - def _big_query_partition_by_expr(self, context: DbtContext) -> exp.Expression: + def _big_query_partition_by_expr(self, context: DbtContext) -> exp.Expr: assert isinstance(self.partition_by, dict) data_type = self.partition_by["data_type"].lower() raw_field = self.partition_by["field"] diff --git a/sqlmesh/lsp/hints.py b/sqlmesh/lsp/hints.py index a8d56e2f31..611ce8608d 100644 --- a/sqlmesh/lsp/hints.py +++ b/sqlmesh/lsp/hints.py @@ -5,7 +5,6 @@ from lsprotocol import types from sqlglot import exp -from sqlglot.expressions import Expression from sqlglot.optimizer.normalize_identifiers import normalize_identifiers from sqlmesh.core.model.definition import SqlModel from sqlmesh.lsp.context import LSPContext, ModelTarget @@ -60,7 +59,7 @@ def get_hints( def _get_type_hints_for_select( - expression: exp.Expression, + expression: exp.Expr, dialect: str, columns_to_types: t.Dict[str, exp.DataType], start_line: int, @@ -113,7 +112,7 @@ def _get_type_hints_for_select( def _get_type_hints_for_model_from_query( - query: Expression, + query: exp.Expr, dialect: str, columns_to_types: t.Dict[str, exp.DataType], start_line: int, diff --git a/sqlmesh/lsp/reference.py b/sqlmesh/lsp/reference.py index 80d401f79c..73c4e5681b 100644 --- a/sqlmesh/lsp/reference.py +++ b/sqlmesh/lsp/reference.py @@ -209,7 +209,7 @@ def get_macro_reference( target: t.Union[Model, StandaloneAudit], read_file: t.List[str], config_path: t.Optional[Path], - node: exp.Expression, + node: exp.Expr, macro_name: str, ) -> t.Optional[Reference]: # Get the file path where the macro is defined diff --git a/sqlmesh/utils/date.py b/sqlmesh/utils/date.py index c9bb19c835..bdc15125d4 100644 --- a/sqlmesh/utils/date.py +++ b/sqlmesh/utils/date.py @@ -168,7 +168,7 @@ def to_datetime( dt: t.Optional[datetime] = value elif isinstance(value, date): dt = datetime(value.year, value.month, value.day) - elif isinstance(value, exp.Expression): + elif isinstance(value, exp.Expr): return to_datetime(value.name) else: try: @@ -401,7 +401,7 @@ def to_time_column( dialect: str, time_column_format: t.Optional[str] = None, nullable: bool = False, -) -> exp.Expression: +) -> exp.Expr: """Convert a TimeLike object to the same time format and type as the model's time column.""" if dialect == "clickhouse" and time_column_type.is_type( *(exp.DataType.TEMPORAL_TYPES - {exp.DataType.Type.DATE, exp.DataType.Type.DATE32}) diff --git a/sqlmesh/utils/jinja.py b/sqlmesh/utils/jinja.py index 240b183391..725842c842 100644 --- a/sqlmesh/utils/jinja.py +++ b/sqlmesh/utils/jinja.py @@ -12,7 +12,8 @@ from jinja2 import Environment, Template, nodes, UndefinedError from jinja2.runtime import Macro -from sqlglot import Dialect, Expression, Parser, TokenType +from sqlglot import Dialect, Parser, TokenType +from sqlglot.expressions import Expression from sqlmesh.core import constants as c from sqlmesh.core import dialect as d diff --git a/sqlmesh/utils/lineage.py b/sqlmesh/utils/lineage.py index f5b4506c68..f63395708d 100644 --- a/sqlmesh/utils/lineage.py +++ b/sqlmesh/utils/lineage.py @@ -70,7 +70,7 @@ class MacroReference(PydanticModel): def extract_references_from_query( - query: exp.Expression, + query: exp.Expr, context: t.Union["Context", "GenericContext[t.Any]"], document_path: Path, read_file: t.List[str], @@ -95,7 +95,11 @@ def extract_references_from_query( # Check if this table reference is a CTE in the current scope if cte_scope := scope.cte_sources.get(table_name): + if cte_scope.expression is None: + continue cte = cte_scope.expression.parent + if cte is None: + continue alias = cte.args["alias"] if isinstance(alias, exp.TableAlias): identifier = alias.this diff --git a/sqlmesh/utils/metaprogramming.py b/sqlmesh/utils/metaprogramming.py index 753db427f3..cd77c36353 100644 --- a/sqlmesh/utils/metaprogramming.py +++ b/sqlmesh/utils/metaprogramming.py @@ -444,6 +444,41 @@ def value( ) +def _resolve_import_module(obj: t.Any, name: str) -> str: + """Resolve the most appropriate module path for importing an object. + + When a callable's ``__module__`` points to a submodule of a known public + module (e.g. ``sqlglot.expressions.builders`` is a submodule of + ``sqlglot.expressions``), and the object is re-exported from that public + parent module, prefer the public parent so that generated import statements + remain stable across internal restructurings of third-party packages. + + Args: + obj: The callable to resolve. + name: The name under which the object will be imported. + + Returns: + The module path to use in the ``from import `` statement. + """ + module_name = getattr(obj, "__module__", None) or "" + parts = module_name.split(".") + + # Walk from the shallowest ancestor (excluding the top-level package) up to + # the immediate parent, returning the shallowest one that re-exports the object. + # We skip the top-level package to avoid over-normalizing (e.g. ``sqlglot`` + # re-exports everything, but callers expect ``sqlglot.expressions``). + for i in range(2, len(parts)): + parent = ".".join(parts[:i]) + try: + parent_module = sys.modules.get(parent) or importlib.import_module(parent) + if getattr(parent_module, name, None) is obj: + return parent + except Exception: + continue + + return module_name + + def serialize_env(env: t.Dict[str, t.Any], path: Path) -> t.Dict[str, Executable]: """Serializes a python function into a self contained dictionary. @@ -512,7 +547,7 @@ def serialize_env(env: t.Dict[str, t.Any], path: Path) -> t.Dict[str, Executable ) else: serialized[k] = Executable( - payload=f"from {v.__module__} import {name}", + payload=f"from {_resolve_import_module(v, name)} import {name}", kind=ExecutableKind.IMPORT, is_metadata=is_metadata, ) diff --git a/sqlmesh/utils/pydantic.py b/sqlmesh/utils/pydantic.py index 2c9c570e5b..8bc81e2774 100644 --- a/sqlmesh/utils/pydantic.py +++ b/sqlmesh/utils/pydantic.py @@ -56,7 +56,7 @@ def get_dialect(values: t.Any) -> str: return model._dialect if dialect is None else dialect # type: ignore -def _expression_encoder(e: exp.Expression) -> str: +def _expression_encoder(e: exp.Expr) -> str: return e.meta.get("sql") or e.sql(dialect=e.meta.get("dialect")) @@ -70,7 +70,7 @@ class PydanticModel(pydantic.BaseModel): # crippled badly. Here we need to enumerate all different ways of how sqlglot expressions # show up in pydantic models. json_encoders={ - exp.Expression: _expression_encoder, + exp.Expr: _expression_encoder, exp.DataType: _expression_encoder, exp.Tuple: _expression_encoder, AuditQueryTypes: _expression_encoder, # type: ignore @@ -190,7 +190,7 @@ def validate_list_of_strings(v: t.Any) -> t.List[str]: def validate_string(v: t.Any) -> str: - if isinstance(v, exp.Expression): + if isinstance(v, exp.Expr): return v.name return str(v) @@ -204,13 +204,13 @@ def validate_expression(expression: E, dialect: str) -> E: def bool_validator(v: t.Any) -> bool: if isinstance(v, exp.Boolean): return v.this - if isinstance(v, exp.Expression): + if isinstance(v, exp.Expr): return str_to_bool(v.name) return str_to_bool(str(v or "")) def positive_int_validator(v: t.Any) -> int: - if isinstance(v, exp.Expression) and v.is_int: + if isinstance(v, exp.Expr) and v.is_int: v = int(v.name) if not isinstance(v, int): raise ValueError(f"Invalid num {v}. Value must be an integer value") @@ -237,10 +237,10 @@ def _formatted_validation_errors(error: pydantic.ValidationError) -> t.List[str] def _get_field( v: t.Any, values: t.Any, -) -> exp.Expression: +) -> exp.Expr: dialect = get_dialect(values) - if isinstance(v, exp.Expression): + if isinstance(v, exp.Expr): expression = v else: expression = parse_one(v, dialect=dialect) @@ -257,16 +257,16 @@ def _get_field( def _get_fields( v: t.Any, values: t.Any, -) -> t.List[exp.Expression]: +) -> t.List[exp.Expr]: dialect = get_dialect(values) if isinstance(v, (exp.Tuple, exp.Array)): - expressions: t.List[exp.Expression] = v.expressions - elif isinstance(v, exp.Expression): + expressions: t.List[exp.Expr] = v.expressions + elif isinstance(v, exp.Expr): expressions = [v] else: expressions = [ - parse_one(entry, dialect=dialect) if isinstance(entry, str) else entry + parse_one(entry, dialect=dialect) if isinstance(entry, str) else entry # type: ignore[misc] for entry in ensure_list(v) ] @@ -278,7 +278,7 @@ def _get_fields( return results -def list_of_fields_validator(v: t.Any, values: t.Any) -> t.List[exp.Expression]: +def list_of_fields_validator(v: t.Any, values: t.Any) -> t.List[exp.Expr]: return _get_fields(v, values) @@ -291,15 +291,15 @@ def column_validator(v: t.Any, values: t.Any) -> exp.Column: def list_of_fields_or_star_validator( v: t.Any, values: t.Any -) -> t.Union[exp.Star, t.List[exp.Expression]]: +) -> t.Union[exp.Star, t.List[exp.Expr]]: expressions = _get_fields(v, values) if len(expressions) == 1 and isinstance(expressions[0], exp.Star): return t.cast(exp.Star, expressions[0]) - return t.cast(t.List[exp.Expression], expressions) + return t.cast(t.List[exp.Expr], expressions) def cron_validator(v: t.Any) -> str: - if isinstance(v, exp.Expression): + if isinstance(v, exp.Expr): v = v.name from croniter import CroniterBadCronError, croniter @@ -338,7 +338,7 @@ def get_concrete_types_from_typehint(typehint: type[t.Any]) -> set[type[t.Any]]: SQLGlotBool = bool SQLGlotPositiveInt = int SQLGlotColumn = exp.Column - SQLGlotListOfFields = t.List[exp.Expression] + SQLGlotListOfFields = t.List[exp.Expr] SQLGlotListOfFieldsOrStar = t.Union[SQLGlotListOfFields, exp.Star] SQLGlotCron = str else: @@ -348,10 +348,8 @@ def get_concrete_types_from_typehint(typehint: type[t.Any]) -> set[type[t.Any]]: SQLGlotString = t.Annotated[str, BeforeValidator(validate_string)] SQLGlotBool = t.Annotated[bool, BeforeValidator(bool_validator)] SQLGlotPositiveInt = t.Annotated[int, BeforeValidator(positive_int_validator)] - SQLGlotColumn = t.Annotated[exp.Expression, BeforeValidator(column_validator)] - SQLGlotListOfFields = t.Annotated[ - t.List[exp.Expression], BeforeValidator(list_of_fields_validator) - ] + SQLGlotColumn = t.Annotated[exp.Expr, BeforeValidator(column_validator)] + SQLGlotListOfFields = t.Annotated[t.List[exp.Expr], BeforeValidator(list_of_fields_validator)] SQLGlotListOfFieldsOrStar = t.Annotated[ t.Union[SQLGlotListOfFields, exp.Star], BeforeValidator(list_of_fields_or_star_validator) ] diff --git a/tests/conftest.py b/tests/conftest.py index b18271465d..46086444bd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -381,7 +381,7 @@ def _make_function( @pytest.fixture def assert_exp_eq() -> t.Callable: def _assert_exp_eq( - source: exp.Expression | str, expected: exp.Expression | str, dialect: DialectType = None + source: exp.Expr | str, expected: exp.Expr | str, dialect: DialectType = None ) -> None: source_exp = maybe_parse(source, dialect=dialect) expected_exp = maybe_parse(expected, dialect=dialect) diff --git a/tests/core/engine_adapter/__init__.py b/tests/core/engine_adapter/__init__.py index 4761c4100b..a9370b8cc3 100644 --- a/tests/core/engine_adapter/__init__.py +++ b/tests/core/engine_adapter/__init__.py @@ -11,7 +11,7 @@ def to_sql_calls(adapter: EngineAdapter, identify: bool = True) -> t.List[str]: value = call[0][0] sql = ( value.sql(dialect=adapter.dialect, identify=identify) - if isinstance(value, exp.Expression) + if isinstance(value, exp.Expr) else str(value) ) output.append(sql) diff --git a/tests/core/engine_adapter/integration/__init__.py b/tests/core/engine_adapter/integration/__init__.py index 4ad6a17944..47ccdc876a 100644 --- a/tests/core/engine_adapter/integration/__init__.py +++ b/tests/core/engine_adapter/integration/__init__.py @@ -276,7 +276,7 @@ def time_formatter(self) -> t.Callable: return lambda x, _: exp.Literal.string(to_ds(x)) @property - def partitioned_by(self) -> t.List[exp.Expression]: + def partitioned_by(self) -> t.List[exp.Expr]: return [parse_one(self.time_column)] @property @@ -388,8 +388,8 @@ def table(self, table_name: TableName, schema: str = TEST_SCHEMA) -> exp.Table: ) def physical_properties( - self, properties_for_dialect: t.Dict[str, t.Dict[str, str | exp.Expression]] - ) -> t.Dict[str, exp.Expression]: + self, properties_for_dialect: t.Dict[str, t.Dict[str, str | exp.Expr]] + ) -> t.Dict[str, exp.Expr]: if props := properties_for_dialect.get(self.dialect): return {k: exp.Literal.string(v) if isinstance(v, str) else v for k, v in props.items()} return {} diff --git a/tests/core/engine_adapter/integration/test_integration_athena.py b/tests/core/engine_adapter/integration/test_integration_athena.py index 1c0ece6d78..9d23af206e 100644 --- a/tests/core/engine_adapter/integration/test_integration_athena.py +++ b/tests/core/engine_adapter/integration/test_integration_athena.py @@ -378,7 +378,7 @@ def test_insert_overwrite_by_time_partition_date_type( ), # note: columns_to_types_from_df() would infer this as TEXT but we need a DATE type } - def time_formatter(time: TimeLike, _: t.Optional[t.Dict[str, exp.DataType]]) -> exp.Expression: + def time_formatter(time: TimeLike, _: t.Optional[t.Dict[str, exp.DataType]]) -> exp.Expr: return exp.cast(exp.Literal.string(to_ds(time)), "date") engine_adapter.create_table( @@ -440,7 +440,7 @@ def test_insert_overwrite_by_time_partition_datetime_type( ), # note: columns_to_types_from_df() would infer this as TEXT but we need a DATETIME type } - def time_formatter(time: TimeLike, _: t.Optional[t.Dict[str, exp.DataType]]) -> exp.Expression: + def time_formatter(time: TimeLike, _: t.Optional[t.Dict[str, exp.DataType]]) -> exp.Expr: return exp.cast(exp.Literal.string(to_ts(time)), "datetime") engine_adapter.create_table( diff --git a/tests/core/engine_adapter/integration/test_integration_clickhouse.py b/tests/core/engine_adapter/integration/test_integration_clickhouse.py index f09360c673..4420acec71 100644 --- a/tests/core/engine_adapter/integration/test_integration_clickhouse.py +++ b/tests/core/engine_adapter/integration/test_integration_clickhouse.py @@ -64,9 +64,7 @@ def _create_table_and_insert_existing_data( "ds": exp.DataType.build("Date", "clickhouse"), }, table_name: str = "data_existing", - partitioned_by: t.Optional[t.List[exp.Expression]] = [ - parse_one("toMonth(ds)", dialect="clickhouse") - ], + partitioned_by: t.Optional[t.List[exp.Expr]] = [parse_one("toMonth(ds)", dialect="clickhouse")], ) -> exp.Table: existing_data = existing_data existing_table_name: exp.Table = ctx.table(table_name) diff --git a/tests/core/engine_adapter/test_athena.py b/tests/core/engine_adapter/test_athena.py index 66e84ae025..19c92f66ac 100644 --- a/tests/core/engine_adapter/test_athena.py +++ b/tests/core/engine_adapter/test_athena.py @@ -81,7 +81,7 @@ def table_diff(adapter: AthenaEngineAdapter) -> TableDiff: def test_table_location( adapter: AthenaEngineAdapter, config_s3_warehouse_location: t.Optional[str], - table_properties: t.Optional[t.Dict[str, exp.Expression]], + table_properties: t.Optional[t.Dict[str, exp.Expr]], table: exp.Table, expected_location: t.Optional[str], ) -> None: diff --git a/tests/core/engine_adapter/test_bigquery.py b/tests/core/engine_adapter/test_bigquery.py index 9a6bc7d851..134f144df1 100644 --- a/tests/core/engine_adapter/test_bigquery.py +++ b/tests/core/engine_adapter/test_bigquery.py @@ -593,7 +593,7 @@ def _to_sql_calls(execute_mock: t.Any, identify: bool = True) -> t.List[str]: for value in values: sql = ( value.sql(dialect="bigquery", identify=identify) - if isinstance(value, exp.Expression) + if isinstance(value, exp.Expr) else str(value) ) output.append(sql) diff --git a/tests/core/engine_adapter/test_clickhouse.py b/tests/core/engine_adapter/test_clickhouse.py index 54fbe7c323..7ff971b742 100644 --- a/tests/core/engine_adapter/test_clickhouse.py +++ b/tests/core/engine_adapter/test_clickhouse.py @@ -1365,7 +1365,7 @@ def test_exchange_tables( # The EXCHANGE TABLES call errored, so we RENAME TABLE instead assert [ quote_identifiers(call.args[0]).sql("clickhouse") - if isinstance(call.args[0], exp.Expression) + if isinstance(call.args[0], exp.Expr) else call.args[0] for call in execute_mock.call_args_list ] == [ diff --git a/tests/core/engine_adapter/test_snowflake.py b/tests/core/engine_adapter/test_snowflake.py index 60f6d38e5f..dcb6820297 100644 --- a/tests/core/engine_adapter/test_snowflake.py +++ b/tests/core/engine_adapter/test_snowflake.py @@ -123,7 +123,7 @@ def test_get_data_objects_lowercases_columns( def test_session( mocker: MockerFixture, make_mocked_engine_adapter: t.Callable, - current_warehouse: t.Union[str, exp.Expression], + current_warehouse: t.Union[str, exp.Expr], current_warehouse_exp: str, configured_warehouse: t.Optional[str], configured_warehouse_exp: t.Optional[str], diff --git a/tests/core/integration/test_auto_restatement.py b/tests/core/integration/test_auto_restatement.py index 70ca227fd3..1bda373a8f 100644 --- a/tests/core/integration/test_auto_restatement.py +++ b/tests/core/integration/test_auto_restatement.py @@ -27,7 +27,7 @@ def test_run_auto_restatement(init_and_plan_context: t.Callable): @macro() def record_intervals( - evaluator, name: exp.Expression, start: exp.Expression, end: exp.Expression, **kwargs: t.Any + evaluator, name: exp.Expr, start: exp.Expr, end: exp.Expr, **kwargs: t.Any ) -> None: if evaluator.runtime_stage == "evaluating": evaluator.engine_adapter.insert_append( @@ -178,7 +178,7 @@ def test_run_auto_restatement_failure(init_and_plan_context: t.Callable): context, _ = init_and_plan_context("examples/sushi") @macro() - def fail_auto_restatement(evaluator, start: exp.Expression, **kwargs: t.Any) -> None: + def fail_auto_restatement(evaluator, start: exp.Expr, **kwargs: t.Any) -> None: if evaluator.runtime_stage == "evaluating" and start.name != "2023-01-01": raise Exception("Failed") diff --git a/tests/core/integration/utils.py b/tests/core/integration/utils.py index bc731e6cc8..ba233080b5 100644 --- a/tests/core/integration/utils.py +++ b/tests/core/integration/utils.py @@ -105,7 +105,10 @@ def apply_to_environment( def change_data_type( - context: Context, model_name: str, old_type: DataType.Type, new_type: DataType.Type + context: Context, + model_name: str, + old_type: exp.DType, + new_type: exp.DType, ) -> None: model = context.get_model(model_name) assert model is not None diff --git a/tests/core/test_audit.py b/tests/core/test_audit.py index 66897ed088..90ac655cc6 100644 --- a/tests/core/test_audit.py +++ b/tests/core/test_audit.py @@ -329,7 +329,7 @@ def test_load_with_dictionary_defaults(): audit = load_audit(expressions, dialect="spark") assert audit.defaults.keys() == {"field1", "field2"} for value in audit.defaults.values(): - assert isinstance(value, exp.Expression) + assert isinstance(value, exp.Expr) def test_load_with_single_defaults(): @@ -350,7 +350,7 @@ def test_load_with_single_defaults(): audit = load_audit(expressions, dialect="duckdb") assert audit.defaults.keys() == {"field1"} for value in audit.defaults.values(): - assert isinstance(value, exp.Expression) + assert isinstance(value, exp.Expr) def test_no_audit_statement(): diff --git a/tests/core/test_config.py b/tests/core/test_config.py index 9ae239f298..8c81a90b8d 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -570,7 +570,8 @@ def test_variables(): assert config.get_gateway("local").variables == {"uppercase_var": 2} with pytest.raises( - ConfigError, match="Unsupported variable value type: " + ConfigError, + match=r"Unsupported variable value type: ", ): Config(variables={"invalid_var": exp.column("sqlglot_expr")}) diff --git a/tests/core/test_macros.py b/tests/core/test_macros.py index fb10f64b27..e37a7ec05b 100644 --- a/tests/core/test_macros.py +++ b/tests/core/test_macros.py @@ -98,7 +98,7 @@ def test_select_macro(evaluator): @macro() def test_literal_type(evaluator, a: t.Literal["test_literal_a", "test_literal_b", 1, True]): - if isinstance(a, exp.Expression): + if isinstance(a, exp.Expr): raise SQLMeshError("Coercion failed") return f"'{a}'" @@ -694,8 +694,8 @@ def test_macro_coercion(macro_evaluator: MacroEvaluator, assert_exp_eq): ) == (1, "2", (3.0,)) # Using exp.Expression will always return the input expression - assert coerce(parse_one("order", into=exp.Column), exp.Expression) == exp.column("order") - assert coerce(exp.Literal.string("OK"), exp.Expression) == exp.Literal.string("OK") + assert coerce(parse_one("order", into=exp.Column), exp.Expr) == exp.column("order") + assert coerce(exp.Literal.string("OK"), exp.Expr) == exp.Literal.string("OK") # Strict flag allows raising errors and is used when recursively coercing expressions # otherwise, in general, we want to be lenient and just warn the user when something is not possible @@ -930,12 +930,10 @@ def test_date_spine(assert_exp_eq, dialect, date_part): FLATTEN( INPUT => ARRAY_GENERATE_RANGE( 0, - ( - DATEDIFF( - {date_part.upper()}, - CAST('2022-01-01' AS DATE), - CAST('2024-12-31' AS DATE) - ) + 1 - 1 + DATEDIFF( + {date_part.upper()}, + CAST('2022-01-01' AS DATE), + CAST('2024-12-31' AS DATE) ) + 1 ) ) diff --git a/tests/core/test_model.py b/tests/core/test_model.py index cfcb843739..81707c075f 100644 --- a/tests/core/test_model.py +++ b/tests/core/test_model.py @@ -6011,7 +6011,7 @@ def test_when_matched_normalization() -> None: assert isinstance(model.kind, IncrementalByUniqueKeyKind) assert isinstance(model.kind.when_matched, exp.Whens) first_expression = model.kind.when_matched.expressions[0] - assert isinstance(first_expression, exp.Expression) + assert isinstance(first_expression, exp.Expr) assert ( first_expression.sql(dialect="snowflake") == 'WHEN MATCHED THEN UPDATE SET "__MERGE_TARGET__"."KEY_A" = "__MERGE_SOURCE__"."KEY_A", "__MERGE_TARGET__"."KEY_B" = "__MERGE_SOURCE__"."KEY_B"' @@ -6039,7 +6039,7 @@ def test_when_matched_normalization() -> None: assert isinstance(model.kind, IncrementalByUniqueKeyKind) assert isinstance(model.kind.when_matched, exp.Whens) first_expression = model.kind.when_matched.expressions[0] - assert isinstance(first_expression, exp.Expression) + assert isinstance(first_expression, exp.Expr) assert ( first_expression.sql(dialect="snowflake") == 'WHEN MATCHED THEN UPDATE SET "__MERGE_TARGET__"."kEy_A" = "__MERGE_SOURCE__"."kEy_A", "__MERGE_TARGET__"."kEY_b" = "__MERGE_SOURCE__"."KEY_B"' @@ -6447,7 +6447,7 @@ def test_end_no_start(): def test_variables(): @macro() - def test_macro_var(evaluator) -> exp.Expression: + def test_macro_var(evaluator) -> exp.Expr: return exp.convert(evaluator.var("TEST_VAR_D") + 10) expressions = parse( @@ -6946,7 +6946,7 @@ def test_unrendered_macros_sql_model(mocker: MockerFixture) -> None: # merge_filter will stay unrendered as well assert model.unique_key[0] == exp.column("a", quoted=True) assert ( - t.cast(exp.Expression, model.merge_filter).sql() + t.cast(exp.Expr, model.merge_filter).sql() == '"__MERGE_SOURCE__"."id" > 0 AND "__MERGE_TARGET__"."updated_at" < @end_ds AND "__MERGE_SOURCE__"."updated_at" > @start_ds AND @merge_filter_var' ) @@ -7149,7 +7149,7 @@ def test_gateway_macro() -> None: assert model.render_query_or_raise().sql() == "SELECT 'in_memory' AS \"gateway\"" @macro() - def macro_uses_gateway(evaluator) -> exp.Expression: + def macro_uses_gateway(evaluator) -> exp.Expr: return exp.convert(evaluator.gateway + "_from_macro") model = load_sql_based_model( @@ -8729,7 +8729,7 @@ def test_merge_filter_macro(): def predicate( evaluator: MacroEvaluator, cluster_column: exp.Column, - ) -> exp.Expression: + ) -> exp.Expr: return parse_one(f"source.{cluster_column} > dateadd(day, -7, target.{cluster_column})") expressions = d.parse( @@ -9904,7 +9904,7 @@ def entrypoint(evaluator): {"customer": SqlValue(sql="customer1"), "customer_field": SqlValue(sql="'bar'")} ) - assert t.cast(exp.Expression, customer1_model.render_query()).sql() == ( + assert t.cast(exp.Expr, customer1_model.render_query()).sql() == ( """SELECT 'bar' AS "foo", "bar" AS "foo2", 'bar' AS "foo3" FROM "db"."customer1"."my_source" AS "my_source\"""" ) @@ -9917,7 +9917,7 @@ def entrypoint(evaluator): {"customer": SqlValue(sql="customer2"), "customer_field": SqlValue(sql="qux")} ) - assert t.cast(exp.Expression, customer2_model.render_query()).sql() == ( + assert t.cast(exp.Expr, customer2_model.render_query()).sql() == ( '''SELECT "qux" AS "foo", "qux" AS "foo2", "qux" AS "foo3" FROM "db"."customer2"."my_source" AS "my_source"''' ) @@ -10703,12 +10703,12 @@ def m4_non_metadata_references_v6(evaluator): query_with_vars = macro_evaluator.transform( parse_one("SELECT " + ", ".join(f"@v{var}, @VAR('v{var}')" for var in [1, 2, 3, 6])) ) - assert t.cast(exp.Expression, query_with_vars).sql() == "SELECT 1, 1, 2, 2, 3, 3, 6, 6" + assert t.cast(exp.Expr, query_with_vars).sql() == "SELECT 1, 1, 2, 2, 3, 3, 6, 6" query_with_blueprint_vars = macro_evaluator.transform( parse_one("SELECT " + ", ".join(f"@v{var}, @BLUEPRINT_VAR('v{var}')" for var in [4, 5])) ) - assert t.cast(exp.Expression, query_with_blueprint_vars).sql() == "SELECT 4, 4, 5, 5" + assert t.cast(exp.Expr, query_with_blueprint_vars).sql() == "SELECT 4, 4, 5, 5" def test_variable_mentioned_in_both_metadata_and_non_metadata_macro(tmp_path: Path) -> None: diff --git a/tests/core/test_plan.py b/tests/core/test_plan.py index 4b330c376f..590cda01ec 100644 --- a/tests/core/test_plan.py +++ b/tests/core/test_plan.py @@ -1795,7 +1795,7 @@ def test_forward_only_models_model_kind_changed(make_snapshot, mocker: MockerFix ) def test_forward_only_models_model_kind_changed_to_incremental_by_time_range( make_snapshot, - partitioned_by: t.List[exp.Expression], + partitioned_by: t.List[exp.Expr], expected_forward_only: bool, ): snapshot = make_snapshot( diff --git a/tests/core/test_snapshot_evaluator.py b/tests/core/test_snapshot_evaluator.py index 1413ac81f1..f3fae15e8a 100644 --- a/tests/core/test_snapshot_evaluator.py +++ b/tests/core/test_snapshot_evaluator.py @@ -3683,7 +3683,7 @@ def test_custom_materialization_strategy_with_custom_properties(adapter_mock, ma custom_insert_kind = None class TestCustomKind(CustomKind): - _primary_key: t.List[exp.Expression] # type: ignore[no-untyped-def] + _primary_key: t.List[exp.Expr] # type: ignore[no-untyped-def] @model_validator(mode="after") def _validate_model(self) -> Self: @@ -3695,7 +3695,7 @@ def _validate_model(self) -> Self: return self @property - def primary_key(self) -> t.List[exp.Expression]: + def primary_key(self) -> t.List[exp.Expr]: return self._primary_key class TestCustomMaterializationStrategy(CustomMaterialization[TestCustomKind]): diff --git a/tests/utils/test_metaprogramming.py b/tests/utils/test_metaprogramming.py index 4e55ae490e..9a6f0c95cd 100644 --- a/tests/utils/test_metaprogramming.py +++ b/tests/utils/test_metaprogramming.py @@ -23,6 +23,7 @@ Executable, ExecutableKind, _dict_sort, + _resolve_import_module, build_env, func_globals, normalize_source, @@ -49,7 +50,7 @@ def test_print_exception(mocker: MockerFixture): except Exception as ex: print_exception(ex, test_env, out_mock) - expected_message = r""" File ".*?.tests.utils.test_metaprogramming\.py", line 48, in test_print_exception + expected_message = r""" File ".*?.tests.utils.test_metaprogramming\.py", line 49, in test_print_exception eval\("test_fun\(\)", env\).* File '/test/path.py' \(or imported file\), line 2, in test_fun @@ -638,3 +639,18 @@ def test_dict_sort_executable_integration(): # non-deterministic repr should not change the payload exec3 = Executable.value(variables1) assert exec3.payload == "{'env': 'dev', 'debug': True, 'timeout': 30}" + + +def test_resolve_import_module(): + """Test that _resolve_import_module finds the shallowest public re-exporting module.""" + # to_table lives in sqlglot.expressions.builders but is re-exported from sqlglot.expressions + assert _resolve_import_module(to_table, "to_table") == "sqlglot.expressions" + + # Objects whose __module__ is already the public module should be returned as-is + assert _resolve_import_module(exp.Column, "Column") == "sqlglot.expressions" + + # Objects not re-exported by any parent should return the original module + class _Local: + __module__ = "some.deep.internal.module" + + assert _resolve_import_module(_Local, "_Local") == "some.deep.internal.module" diff --git a/web/server/api/endpoints/table_diff.py b/web/server/api/endpoints/table_diff.py index d441d49e5a..b0167ed032 100644 --- a/web/server/api/endpoints/table_diff.py +++ b/web/server/api/endpoints/table_diff.py @@ -126,7 +126,7 @@ def get_table_diff( table_diffs = context.table_diff( source=source, target=target, - on=exp.condition(on) if on else None, + on=t.cast(exp.Condition, exp.condition(on)) if on else None, select_models={model_or_snapshot} if model_or_snapshot else None, where=where, limit=limit, From 08c1375752992d1d8f6fe09b17ae5e2858b44d24 Mon Sep 17 00:00:00 2001 From: Vaggelis Danias Date: Thu, 19 Mar 2026 17:13:43 +0200 Subject: [PATCH 35/39] Chore: Fix typing error (#5737) Signed-off-by: vaggelisd --- sqlmesh/core/dialect.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/sqlmesh/core/dialect.py b/sqlmesh/core/dialect.py index 122b287ac0..ad2d799a6b 100644 --- a/sqlmesh/core/dialect.py +++ b/sqlmesh/core/dialect.py @@ -499,13 +499,20 @@ def _parse_types( # # See: https://docs.snowflake.com/en/user-guide/querying-stage def _parse_table_parts( - self: Parser, schema: bool = False, is_db_reference: bool = False, wildcard: bool = False + self: Parser, + schema: bool = False, + is_db_reference: bool = False, + wildcard: bool = False, + fast: bool = False, ) -> exp.Table | StagedFilePath: index = self._index table = self.__parse_table_parts( # type: ignore - schema=schema, is_db_reference=is_db_reference, wildcard=wildcard + schema=schema, is_db_reference=is_db_reference, wildcard=wildcard, fast=fast ) + if table is None: + return table # type: ignore[return-value] + table_arg = table.this name = table_arg.name if isinstance(table_arg, exp.Var) else "" @@ -529,7 +536,9 @@ def _parse_table_parts( ) ): self._retreat(index) - return Parser._parse_table_parts(self, schema=schema, is_db_reference=is_db_reference) + return Parser._parse_table_parts( + self, schema=schema, is_db_reference=is_db_reference, fast=fast + ) # type: ignore[return-value] table_arg.replace(MacroVar(this=name[1:])) return StagedFilePath(**table.args) From 6f58fe9aab67cd962ef141c369808cf54da361b0 Mon Sep 17 00:00:00 2001 From: Themis Valtinos <73662635+themisvaltinos@users.noreply.github.com> Date: Thu, 19 Mar 2026 22:24:05 +0200 Subject: [PATCH 36/39] Chore: Fix tests and adapt parser methods for tokens size (#5738) Signed-off-by: Themis Valtinos <73662635+themisvaltinos@users.noreply.github.com> --- sqlmesh/core/dialect.py | 4 ++++ sqlmesh/utils/jinja.py | 5 +++++ tests/dbt/cli/test_run.py | 5 +++++ 3 files changed, 14 insertions(+) diff --git a/sqlmesh/core/dialect.py b/sqlmesh/core/dialect.py index ad2d799a6b..3e8f4fe9a7 100644 --- a/sqlmesh/core/dialect.py +++ b/sqlmesh/core/dialect.py @@ -566,6 +566,10 @@ def _parse_if(self: Parser) -> t.Optional[exp.Expr]: if last_token.token_type == TokenType.R_PAREN: self._tokens[-2].comments.extend(last_token.comments) self._tokens.pop() + if hasattr(self, "_tokens_size"): + # keep _tokens_size in sync sqlglot 30.0.3 caches len(_tokens) + # _advance() tries to read tokens[index + 1] past the new end + self._tokens_size -= 1 else: self.raise_error("Expecting )") diff --git a/sqlmesh/utils/jinja.py b/sqlmesh/utils/jinja.py index 725842c842..bd82cf225c 100644 --- a/sqlmesh/utils/jinja.py +++ b/sqlmesh/utils/jinja.py @@ -79,6 +79,11 @@ def extract(self, jinja: str, dialect: str = "") -> t.Dict[str, MacroInfo]: self.reset() self.sql = jinja self._tokens = Dialect.get_or_raise(dialect).tokenize(jinja) + + # guard for older sqlglot versions (before 30.0.3) + if hasattr(self, "_tokens_size"): + # keep the cached length in sync + self._tokens_size = len(self._tokens) self._index = -1 self._advance() diff --git a/tests/dbt/cli/test_run.py b/tests/dbt/cli/test_run.py index 4fdb7a0cdb..c640950a27 100644 --- a/tests/dbt/cli/test_run.py +++ b/tests/dbt/cli/test_run.py @@ -1,6 +1,7 @@ import typing as t import pytest from pathlib import Path +import shutil from click.testing import Result import time_machine from sqlmesh_dbt.operations import create @@ -71,6 +72,10 @@ def test_run_with_changes_and_full_refresh( if partial_parse_file.exists(): partial_parse_file.unlink() + cache_dir = project_path / ".cache" + if cache_dir.exists(): + shutil.rmtree(cache_dir) + # run with --full-refresh. this should: # - fully refresh model_a (pick up the new records from external_table) # - deploy the local change to model_b (introducing the 'changed' column) From 7043369f900e164b4efb23d30cc82cd86115f620 Mon Sep 17 00:00:00 2001 From: Alberto Suman Date: Wed, 25 Mar 2026 17:08:08 +0100 Subject: [PATCH 37/39] feat(bigquery): Add support for Bigquery reservations in config (again) (#5727) Signed-off-by: Alberto Suman --- sqlmesh/core/config/connection.py | 2 ++ sqlmesh/core/engine_adapter/bigquery.py | 6 +++++- tests/cli/test_cli.py | 2 +- tests/core/test_connection_config.py | 21 +++++++++++++++++++++ 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/sqlmesh/core/config/connection.py b/sqlmesh/core/config/connection.py index 26bfa78730..7a002faebb 100644 --- a/sqlmesh/core/config/connection.py +++ b/sqlmesh/core/config/connection.py @@ -1062,6 +1062,7 @@ class BigQueryConnectionConfig(ConnectionConfig): job_retry_deadline_seconds: t.Optional[int] = None priority: t.Optional[BigQueryPriority] = None maximum_bytes_billed: t.Optional[int] = None + reservation: t.Optional[str] = None concurrent_tasks: int = 1 register_comments: bool = True @@ -1171,6 +1172,7 @@ def _extra_engine_config(self) -> t.Dict[str, t.Any]: "job_retry_deadline_seconds", "priority", "maximum_bytes_billed", + "reservation", } } diff --git a/sqlmesh/core/engine_adapter/bigquery.py b/sqlmesh/core/engine_adapter/bigquery.py index 4741f90d27..d136445114 100644 --- a/sqlmesh/core/engine_adapter/bigquery.py +++ b/sqlmesh/core/engine_adapter/bigquery.py @@ -140,8 +140,10 @@ def _job_params(self) -> t.Dict[str, t.Any]: "priority", BigQueryPriority.INTERACTIVE.bigquery_constant ), } - if self._extra_config.get("maximum_bytes_billed"): + if self._extra_config.get("maximum_bytes_billed") is not None: params["maximum_bytes_billed"] = self._extra_config.get("maximum_bytes_billed") + if self._extra_config.get("reservation") is not None: + params["reservation"] = self._extra_config.get("reservation") if self.correlation_id: # BigQuery label keys must be lowercase key = self.correlation_id.job_type.value.lower() @@ -1106,7 +1108,9 @@ def _execute( else [] ) + # Create job config job_config = QueryJobConfig(**self._job_params, connection_properties=connection_properties) + self._query_job = self._db_call( self.client.query, query=sql, diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 576ce95d91..5e0737e1b6 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -1949,7 +1949,7 @@ def test_init_dbt_template(runner: CliRunner, tmp_path: Path): def test_init_project_engine_configs(tmp_path): engine_type_to_config = { "redshift": "# concurrent_tasks: 4\n # register_comments: True\n # pre_ping: False\n # pretty_sql: False\n # schema_differ_overrides: \n # catalog_type_overrides: \n # user: \n # password: \n # database: \n # host: \n # port: \n # source_address: \n # unix_sock: \n # ssl: \n # sslmode: \n # timeout: \n # tcp_keepalive: \n # application_name: \n # preferred_role: \n # principal_arn: \n # credentials_provider: \n # region: \n # cluster_identifier: \n # iam: \n # is_serverless: \n # serverless_acct_id: \n # serverless_work_group: \n # enable_merge: ", - "bigquery": "# concurrent_tasks: 1\n # register_comments: True\n # pre_ping: False\n # pretty_sql: False\n # schema_differ_overrides: \n # catalog_type_overrides: \n # method: oauth\n # project: \n # execution_project: \n # quota_project: \n # location: \n # keyfile: \n # keyfile_json: \n # token: \n # refresh_token: \n # client_id: \n # client_secret: \n # token_uri: \n # scopes: \n # impersonated_service_account: \n # job_creation_timeout_seconds: \n # job_execution_timeout_seconds: \n # job_retries: 1\n # job_retry_deadline_seconds: \n # priority: \n # maximum_bytes_billed: ", + "bigquery": "# concurrent_tasks: 1\n # register_comments: True\n # pre_ping: False\n # pretty_sql: False\n # schema_differ_overrides: \n # catalog_type_overrides: \n # method: oauth\n # project: \n # execution_project: \n # quota_project: \n # location: \n # keyfile: \n # keyfile_json: \n # token: \n # refresh_token: \n # client_id: \n # client_secret: \n # token_uri: \n # scopes: \n # impersonated_service_account: \n # job_creation_timeout_seconds: \n # job_execution_timeout_seconds: \n # job_retries: 1\n # job_retry_deadline_seconds: \n # priority: \n # maximum_bytes_billed: \n # reservation: ", "snowflake": "account: \n # concurrent_tasks: 4\n # register_comments: True\n # pre_ping: False\n # pretty_sql: False\n # schema_differ_overrides: \n # catalog_type_overrides: \n # user: \n # password: \n # warehouse: \n # database: \n # role: \n # authenticator: \n # token: \n # host: \n # port: \n # application: Tobiko_SQLMesh\n # private_key: \n # private_key_path: \n # private_key_passphrase: \n # session_parameters: ", "databricks": "# concurrent_tasks: 1\n # register_comments: True\n # pre_ping: False\n # pretty_sql: False\n # schema_differ_overrides: \n # catalog_type_overrides: \n # server_hostname: \n # http_path: \n # access_token: \n # auth_type: \n # oauth_client_id: \n # oauth_client_secret: \n # catalog: \n # http_headers: \n # session_configuration: \n # databricks_connect_server_hostname: \n # databricks_connect_access_token: \n # databricks_connect_cluster_id: \n # databricks_connect_use_serverless: False\n # force_databricks_connect: False\n # disable_databricks_connect: False\n # disable_spark_session: False", "postgres": "host: \n user: \n password: \n port: \n database: \n # concurrent_tasks: 4\n # register_comments: True\n # pre_ping: True\n # pretty_sql: False\n # schema_differ_overrides: \n # catalog_type_overrides: \n # keepalives_idle: \n # connect_timeout: 10\n # role: \n # sslmode: \n # application_name: ", diff --git a/tests/core/test_connection_config.py b/tests/core/test_connection_config.py index dd979a2551..2ff95525f7 100644 --- a/tests/core/test_connection_config.py +++ b/tests/core/test_connection_config.py @@ -1131,6 +1131,27 @@ def test_bigquery(make_config): assert config.get_catalog() == "project" assert config.is_recommended_for_state_sync is False + # Test reservation + config_with_reservation = make_config( + type="bigquery", + project="project", + reservation="projects/my-project/locations/us-central1/reservations/my-reservation", + check_import=False, + ) + assert isinstance(config_with_reservation, BigQueryConnectionConfig) + assert ( + config_with_reservation.reservation + == "projects/my-project/locations/us-central1/reservations/my-reservation" + ) + + # Test that reservation is included in _extra_engine_config + extra_config = config_with_reservation._extra_engine_config + assert "reservation" in extra_config + assert ( + extra_config["reservation"] + == "projects/my-project/locations/us-central1/reservations/my-reservation" + ) + with pytest.raises(ConfigError, match="you must also specify the `project` field"): make_config(type="bigquery", execution_project="execution_project", check_import=False) From 8f092acda767f3139fb6aa97405a6aaee5333ae1 Mon Sep 17 00:00:00 2001 From: Dan Lynn Date: Wed, 25 Mar 2026 11:20:28 -0600 Subject: [PATCH 38/39] Chore: update github org links --- docs/guides/vscode.md | 6 +++--- vscode/extension/README.md | 6 +++--- vscode/extension/package.json | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/guides/vscode.md b/docs/guides/vscode.md index 151e630f27..5ef3cd71ce 100644 --- a/docs/guides/vscode.md +++ b/docs/guides/vscode.md @@ -6,7 +6,7 @@ The SQLMesh Visual Studio Code extension is in preview and undergoing active development. You may encounter bugs or API incompatibilities with the SQLMesh version you are running. - We encourage you to try the extension and [create Github issues](https://github.com/tobikodata/sqlmesh/issues) for any problems you encounter. + We encourage you to try the extension and [create Github issues](https://github.com/SQLMesh/sqlmesh/issues) for any problems you encounter. In this guide, you'll set up the SQLMesh extension in the Visual Studio Code IDE software (which we refer to as "VSCode"). @@ -187,7 +187,7 @@ The most common problem is the extension not using the correct Python interprete Follow the [setup process described above](#vscode-python-interpreter) to ensure that the extension is using the correct Python interpreter. -If you have checked the VSCode `sqlmesh` output channel and the extension is still not using the correct Python interpreter, please raise an issue [here](https://github.com/tobikodata/sqlmesh/issues). +If you have checked the VSCode `sqlmesh` output channel and the extension is still not using the correct Python interpreter, please raise an issue [here](https://github.com/SQLMesh/sqlmesh/issues). ### Missing Python dependencies @@ -205,4 +205,4 @@ If you are using Tobiko Cloud, make sure `lsp` is included in the list of extras While the SQLMesh VSCode extension is in preview and the APIs to the underlying SQLMesh version are not stable, we do not guarantee compatibility between the extension and the SQLMesh version you are using. -If you encounter a problem, please raise an issue [here](https://github.com/tobikodata/sqlmesh/issues). \ No newline at end of file +If you encounter a problem, please raise an issue [here](https://github.com/SQLMesh/sqlmesh/issues). \ No newline at end of file diff --git a/vscode/extension/README.md b/vscode/extension/README.md index 64f6c3e130..dac6d9cae6 100644 --- a/vscode/extension/README.md +++ b/vscode/extension/README.md @@ -77,8 +77,8 @@ If you encounter issues, please refer to the [VSCode Extension Guide](https://sq We welcome contributions! Please: -1. [Report bugs](https://github.com/tobikodata/sqlmesh/issues) you encounter -2. [Request features](https://github.com/tobikodata/sqlmesh/issues) you'd like to see +1. [Report bugs](https://github.com/SQLMesh/sqlmesh/issues) you encounter +2. [Request features](https://github.com/SQLMesh/sqlmesh/issues) you'd like to see 3. Share feedback on your experience ## 📄 License @@ -87,7 +87,7 @@ This extension is licensed under the Apache License 2.0. See [LICENSE](LICENSE) ## 🔗 Links -- [SQLMesh GitHub Repository](https://github.com/tobikodata/sqlmesh) +- [SQLMesh GitHub Repository](https://github.com/SQLMesh/sqlmesh) - [Tobiko Data Website](https://tobikodata.com) - [Extension Marketplace Page](https://marketplace.visualstudio.com/items?itemName=tobikodata.sqlmesh) diff --git a/vscode/extension/package.json b/vscode/extension/package.json index 35499ad68f..342096731f 100644 --- a/vscode/extension/package.json +++ b/vscode/extension/package.json @@ -6,7 +6,7 @@ "version": "0.0.7", "repository": { "type": "git", - "url": "https://github.com/tobikodata/sqlmesh" + "url": "https://github.com/SQLMesh/sqlmesh" }, "main": "./dist/extension.js", "icon": "assets/logo.png", From c7874156557b282495bc5a8bfe70f2a6529e4cb1 Mon Sep 17 00:00:00 2001 From: mday-io Date: Mon, 30 Mar 2026 15:39:49 -0400 Subject: [PATCH 39/39] fix: prevent default catalog leak into catalog-unsupported gateways (#5752) Signed-off-by: Michael Day Co-authored-by: Michael Day --- sqlmesh/core/model/definition.py | 17 ++-- tests/core/test_model.py | 167 +++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+), 6 deletions(-) diff --git a/sqlmesh/core/model/definition.py b/sqlmesh/core/model/definition.py index 8d4f72e918..d4f23b4fc0 100644 --- a/sqlmesh/core/model/definition.py +++ b/sqlmesh/core/model/definition.py @@ -2065,7 +2065,9 @@ def create_models_from_blueprints( **loader_kwargs: t.Any, ) -> t.List[Model]: model_blueprints: t.List[Model] = [] + original_default_catalog = loader_kwargs.get("default_catalog") for blueprint in _extract_blueprints(blueprints, path): + loader_kwargs["default_catalog"] = original_default_catalog blueprint_variables = _extract_blueprint_variables(blueprint, path) if gateway: @@ -2083,12 +2085,15 @@ def create_models_from_blueprints( else: gateway_name = None - if ( - default_catalog_per_gateway - and gateway_name - and (catalog := default_catalog_per_gateway.get(gateway_name)) is not None - ): - loader_kwargs["default_catalog"] = catalog + if default_catalog_per_gateway and gateway_name: + catalog = default_catalog_per_gateway.get(gateway_name) + if catalog is not None: + loader_kwargs["default_catalog"] = catalog + else: + # Gateway exists but has no entry in the dict (e.g., catalog-unsupported + # engines like ClickHouse). Clear the default catalog so the global + # default from the primary gateway doesn't leak into this model's name. + loader_kwargs["default_catalog"] = None model_blueprints.append( loader( diff --git a/tests/core/test_model.py b/tests/core/test_model.py index 81707c075f..9bdc976b56 100644 --- a/tests/core/test_model.py +++ b/tests/core/test_model.py @@ -12342,3 +12342,170 @@ def test_audits_in_embedded_model(): ) with pytest.raises(ConfigError, match="Audits are not supported for embedded models"): load_sql_based_model(expression).validate_definition() + + +def test_default_catalog_not_leaked_to_unsupported_gateway(): + """ + Regression test for https://github.com/SQLMesh/sqlmesh/issues/5748 + + When a model targets a gateway that is NOT in default_catalog_per_gateway, + the global default_catalog should be cleared (set to None) instead of + leaking through from the default gateway. + """ + from sqlglot import parse + + expressions = parse( + """ + MODEL ( + name my_schema.my_model, + kind FULL, + gateway clickhouse_gw, + dialect clickhouse, + ); + + SELECT 1 AS id + """, + read="clickhouse", + ) + + default_catalog_per_gateway = { + "default_gw": "example_catalog", + } + + models = load_sql_based_models( + expressions, + get_variables=lambda gw: {}, + dialect="clickhouse", + default_catalog_per_gateway=default_catalog_per_gateway, + default_catalog="example_catalog", + ) + + assert len(models) == 1 + model = models[0] + + assert not model.catalog, ( + f"Default gateway catalog leaked into catalog-unsupported gateway model. " + f"Expected no catalog, got: {model.catalog}" + ) + assert "example_catalog" not in model.fqn, ( + f"Default gateway catalog found in model FQN: {model.fqn}" + ) + + +def test_default_catalog_still_applied_to_supported_gateway(): + """ + Control test: when a model targets a gateway that IS in default_catalog_per_gateway, + the catalog should still be correctly applied. + """ + from sqlglot import parse + + expressions = parse( + """ + MODEL ( + name my_schema.my_model, + kind FULL, + gateway other_duckdb, + ); + + SELECT 1 AS id + """, + read="duckdb", + ) + + default_catalog_per_gateway = { + "default_gw": "example_catalog", + "other_duckdb": "other_db", + } + + models = load_sql_based_models( + expressions, + get_variables=lambda gw: {}, + dialect="duckdb", + default_catalog_per_gateway=default_catalog_per_gateway, + default_catalog="example_catalog", + ) + + assert len(models) == 1 + model = models[0] + + assert model.catalog == "other_db", f"Expected catalog 'other_db', got: {model.catalog}" + + +def test_no_gateway_uses_global_default_catalog(): + """ + Control test: when a model does NOT specify a gateway, the global + default_catalog should still be applied as before. + """ + from sqlglot import parse + + expressions = parse( + """ + MODEL ( + name my_schema.my_model, + kind FULL, + ); + + SELECT 1 AS id + """, + read="duckdb", + ) + + model = load_sql_based_model( + expressions, + default_catalog="example_catalog", + dialect="duckdb", + ) + + assert model.catalog == "example_catalog" + + +def test_blueprint_catalog_not_cross_contaminated(): + """ + When blueprints iterate over different gateways, the catalog from one + blueprint iteration should not leak into the next. A ClickHouse blueprint + setting default_catalog to None should not prevent the following blueprint + from getting its correct catalog. + """ + from sqlglot import parse + + expressions = parse( + """ + MODEL ( + name @{blueprint}.my_model, + kind FULL, + gateway @{gw}, + blueprints ( + (blueprint := ch_schema, gw := clickhouse_gw), + (blueprint := db_schema, gw := default_gw), + ), + ); + + SELECT 1 AS id + """, + read="duckdb", + ) + + default_catalog_per_gateway = { + "default_gw": "example_catalog", + } + + models = load_sql_based_models( + expressions, + get_variables=lambda gw: {}, + dialect="duckdb", + default_catalog_per_gateway=default_catalog_per_gateway, + default_catalog="example_catalog", + ) + + assert len(models) == 2 + + ch_model = next(m for m in models if "ch_schema" in m.fqn) + db_model = next(m for m in models if "db_schema" in m.fqn) + + assert not ch_model.catalog, ( + f"Catalog leaked into ClickHouse blueprint. Got: {ch_model.catalog}" + ) + + assert db_model.catalog == "example_catalog", ( + f"Catalog lost for DuckDB blueprint after ClickHouse iteration. Got: {db_model.catalog}" + )