Repository navigation
feat(duckdb): use native MERGE for incremental by unique key models #6216
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,13 +1,16 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import typing as t | ||
| from functools import cached_property, partial | ||
| from sqlglot import exp | ||
| from pathlib import Path | ||
| from packaging import version | ||
|
|
||
| from sqlmesh.core.engine_adapter.base import MERGE_TARGET_ALIAS | ||
| from sqlmesh.core.engine_adapter.mixins import ( | ||
| GetCurrentCatalogFromFunctionMixin, | ||
| LogicalMergeMixin, | ||
| RowDiffMixin, | ||
| logical_merge, | ||
| ) | ||
| from sqlmesh.core.engine_adapter.shared import ( | ||
| CatalogSupport, | ||
|
|
@@ -21,11 +24,11 @@ | |
|
|
||
| if t.TYPE_CHECKING: | ||
| from sqlmesh.core._typing import SchemaName, TableName | ||
| from sqlmesh.core.engine_adapter._typing import DF | ||
| from sqlmesh.core.engine_adapter._typing import DF, Query, QueryOrDF | ||
|
|
||
|
|
||
| @set_catalog(override_mapping={"_get_data_objects": CatalogSupport.REQUIRES_SET_CATALOG}) | ||
| class DuckDBEngineAdapter(LogicalMergeMixin, GetCurrentCatalogFromFunctionMixin, RowDiffMixin): | ||
| class DuckDBEngineAdapter(GetCurrentCatalogFromFunctionMixin, RowDiffMixin): | ||
| DIALECT = "duckdb" | ||
| SUPPORTS_TRANSACTIONS = False | ||
| SCHEMA_DIFFER_KWARGS = { | ||
|
|
@@ -37,6 +40,11 @@ class DuckDBEngineAdapter(LogicalMergeMixin, GetCurrentCatalogFromFunctionMixin, | |
| COMMENT_CREATION_VIEW = CommentCreationView.COMMENT_COMMAND_ONLY | ||
| SUPPORTS_CREATE_DROP_CATALOG = True | ||
| SUPPORTED_DROP_CASCADE_OBJECT_KINDS = ["SCHEMA", "TABLE", "VIEW"] | ||
| # MERGE INTO was added in DuckDB 1.4.0 | ||
| MIN_NATIVE_MERGE_VERSION = version.parse("1.4.0") | ||
| # Catalog types whose storage extension supports MERGE INTO. Other attached catalogs | ||
| # (postgres, sqlite, mysql, ...) fall back to DELETE + INSERT. | ||
| NATIVE_MERGE_CATALOG_TYPES = {"duckdb", "ducklake"} | ||
|
|
||
| @property | ||
| def catalog_support(self) -> CatalogSupport: | ||
|
|
@@ -72,6 +80,76 @@ def _drop_catalog(self, catalog_name: exp.Identifier) -> None: | |
| ) | ||
| ) | ||
|
|
||
| @cached_property | ||
| def _duckdb_supports_merge(self) -> bool: | ||
| import duckdb | ||
|
|
||
| return version.parse(duckdb.__version__) >= self.MIN_NATIVE_MERGE_VERSION | ||
|
|
||
| def _supports_native_merge(self, target_table: TableName) -> bool: | ||
| if not self._duckdb_supports_merge: | ||
| return False | ||
| if self._is_motherduck: | ||
| return True | ||
| catalog = exp.to_table(target_table).catalog or self.get_current_catalog() | ||
| # Errors from the catalog lookup propagate on purpose; only a None/unknown type falls back. | ||
| return self._get_catalog_type(catalog) in self.NATIVE_MERGE_CATALOG_TYPES | ||
|
|
||
| def merge( | ||
| self, | ||
| target_table: TableName, | ||
| source_table: QueryOrDF, | ||
| target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]], | ||
| unique_key: t.Sequence[exp.Expr], | ||
| when_matched: t.Optional[exp.Whens] = None, | ||
| merge_filter: t.Optional[exp.Expr] = None, | ||
| source_columns: t.Optional[t.List[str]] = None, | ||
| **kwargs: t.Any, | ||
| ) -> None: | ||
| merge_impl = ( | ||
| super().merge | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Preserve matching for nullable composite keys. With The native path instead uses the base adapter's per-column equality predicates. Preserve the previous matching behavior for nullable composite keys, or retain the logical path for those models.
|
||
| if self._supports_native_merge(target_table) | ||
| else partial(logical_merge, self) | ||
| ) | ||
| merge_impl( # type: ignore | ||
| target_table, | ||
| source_table, | ||
| target_columns_to_types, | ||
| unique_key, | ||
| when_matched=when_matched, | ||
| merge_filter=merge_filter, | ||
| source_columns=source_columns, | ||
| ) | ||
|
|
||
| def _prepare_merge_source_query(self, query: Query, unique_key: t.Sequence[exp.Expr]) -> Query: | ||
| # A native MERGE inserts every unmatched source row, so duplicate keys in the source would | ||
| # produce duplicate keys in the target. Keep one row per key, like the DELETE + INSERT path. | ||
| return exp.select("*").distinct(*unique_key).from_(query.subquery("_src")) # type: ignore | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Coerce native MERGE source columns to target types before deduplication and matching. The native DuckDB merge path does not cast source columns. When a source key has a different type than the target key, the Apply the same type coercion that the logical path uses (
|
||
|
|
||
| def _merge( | ||
| self, | ||
| target_table: TableName, | ||
| query: Query, | ||
| on: exp.Expr, | ||
| whens: exp.Whens, | ||
| ) -> None: | ||
| # DuckDB rejects a qualified column on the left-hand side of UPDATE SET and in the INSERT | ||
| # column list. Conditions and right-hand sides may keep the qualifier. | ||
| # TODO: sqlglot's DuckDB generator doesn't apply `merge_without_target_sql` yet. Remove this | ||
| # once the pinned sqlglot version does. | ||
| whens = whens.copy() | ||
| for when in whens.expressions: | ||
| then = when.args.get("then") | ||
| targets: t.List[exp.Expr] = [] | ||
| if isinstance(then, exp.Update): | ||
| targets = [e.this for e in then.expressions if isinstance(e, exp.EQ)] | ||
| elif isinstance(then, exp.Insert) and isinstance(then.this, exp.Tuple): | ||
| targets = then.this.expressions | ||
| for column in targets: | ||
| if isinstance(column, exp.Column) and column.table.upper() == MERGE_TARGET_ALIAS: | ||
| column.set("table", None) | ||
| super()._merge(target_table, query, on, whens) | ||
|
|
||
| def _df_to_source_queries( | ||
| self, | ||
| df: DF, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Check the target catalog before enabling MotherDuck native merge.
is_motherduck=Truedescribes the connection, not every attached catalog.MotherDuckConnectionConfiginherits thecatalogsconfiguration and attachment initialization fromBaseDuckDBConnectionConfig(sqlmesh/core/config/connection.py, Line 269–284, Line 409–424, and Line 498). (raw.githubusercontent.com)For a target such as
sqlite.main.tgtin that session, this early return bypasses the catalog check and selects nativeMERGE. This violates the fallback contract for attached SQLite targets. Check the target catalog and allow the MotherDuck catalog type explicitly instead of allowing every target on a MotherDuck connection.