Skip to content

Fix replacement in attached Postgres catalogs - #6078

Open
clowrance-proppilot wants to merge 1 commit into
SQLMesh:mainfrom
clowrance-proppilot:fix/duckdb-attached-postgres-replace
Open

clowrance-proppilot wants to merge 1 commit into
SQLMesh:mainfrom
clowrance-proppilot:fix/duckdb-attached-postgres-replace

Conversation

@clowrance-proppilot

@clowrance-proppilot clowrance-proppilot commented Sep 21, 2026 •

Copy link
Copy Markdown

Description

DuckDB does not support CREATE OR REPLACE TABLE for tables in an attached Postgres catalog. SQLMesh currently selects replacement behavior using the current DuckDB catalog rather than the target table catalog, so a full refresh of an attached Postgres table attempts unsupported DDL. It also fails when the physical table has dependent SQLMesh virtual-layer views.

This change:

  • detects catalog type from the target table catalog
  • replaces attached Postgres tables with DROP TABLE IF EXISTS ... CASCADE followed by the normal create path
  • adds a regression test for target-catalog lookup and generated replacement SQL

SQLMesh recreates the virtual-layer views after physical model evaluation, so cascading those dependencies matches the full-refresh lifecycle.

Related context:

This PR addresses physical table replacement in an attached Postgres catalog; it does not claim to close #5438 by itself.

Test Plan

  • make style
  • make fast-test
  • pytest tests/core/engine_adapter/test_duckdb.py -q

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

Signed-off-by: clowrance-proppilot <clowrance@propertypilot.com>
@mday-io

mday-io commented Sep 24, 2026

Copy link
Copy Markdown
Collaborator

Thanks for tracking this down, and for linking the related issues so clearly. Using the target table's catalog to decide how to replace it, instead of the connection's current catalog, is the right direction.

My main concern is the DROP TABLE ... CASCADE. The PR description says SQLMesh recreates the virtual-layer views after physical evaluation. That only happens when a plan is applied, during promotion. A regular sqlmesh run never re-promotes. So every scheduled run of a FULL model in an attached Postgres catalog would:

drop the prod virtual-layer view, and the views of any dev environments that share the snapshot, without restoring them until a later plan re-promotes that snapshot. A no-change plan doesn't do that.
drop any other Postgres objects that depend on the table: physical views of downstream VIEW-kind models, and views that users or BI tools created outside SQLMesh.

And the run still reports success. We'd be swapping a loud error for silent breakage, which is harder for users to notice.

Separately, DuckDB runs with SUPPORTS_TRANSACTIONS = False, so the DROP followed by the CTAS isn't atomic. If the CTAS fails, the table is gone and there's nothing to roll back to.

Could we avoid dropping the table at all? TrinoEngineAdapter.replace_query already solves a similar problem. It checks the target table's catalog type (get_catalog_type_from_table) and passes supports_replace_table_override to the base replace_query. For Postgres-attached catalogs we could pass False whenever the target is Postgres. The existing insert-overwrite path (delete and insert into the table in place) would then handle the refresh. Dependent views and grants stay intact, and no CASCADE is needed.

It would also be great to add an integration test against the Docker Postgres setup: run a FULL model twice with virtual-layer views in place, and check that the views still exist and return data afterward. The current unit test mocks fetchone, so it doesn't show how DuckDB's Postgres extension actually behaves.

if catalog_type == "ducklake":
partitioned_by_exps = kwargs.pop("partitioned_by", None)
elif catalog_type == "postgres" and replace:
self.execute(exp.Drop(this=table, kind="TABLE", exists=True, cascade=True))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This CASCADE will also drop every SQLMesh virtual-layer view that points at this physical table: prod and any dev environments sharing the snapshot. Those views only get recreated during plan promotion, not during sqlmesh run. So after a scheduled run of a FULL model, prod. would disappear until someone applies a plan that re-promotes this snapshot. It would also drop any non-SQLMesh views on this table in Postgres.

DuckDB doesn't run this in a transaction either (SUPPORTS_TRANSACTIONS = False), so if the CTAS below fails, the table's data is lost.

Could we route Postgres-attached targets to the insert-overwrite path instead (see the summary comment), so the table is never dropped?

if isinstance(table_name_or_schema, exp.Schema)
else exp.to_table(table_name_or_schema)
)
catalog = table.catalog or self.get_current_catalog()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 Resolving the catalog from the target table is the right fix here. Small nit: the base adapter already has get_catalog_type_from_table doing the same catalog resolution. If the fix moves into replace_query, following the Trino adapter's pattern, it may be worth reusing that logic or following its shape.

table_name.sql(dialect=self.dialect)
if isinstance(table_name, exp.Table)
else table_name
table.sql(dialect=self.dialect) if isinstance(table, exp.Table) else table

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: table is now always an exp.Table (it comes from exp.to_table or Schema.this), so this isinstance check is dead code. It can just be table.sql(dialect=self.dialect).

pd.testing.assert_frame_equal(adapter.fetchdf("SELECT * FROM test_table"), df)


def test_replace_query_attached_postgres(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This checks the generated SQL well. Because fetchone is mocked, though, it can't show how the Postgres extension behaves (whether CASCADE is pushed down, what happens to dependent views). Could we add an integration test with the Docker Postgres engine? For example: attach Postgres, plan a FULL model, run it again, then check that the prod virtual-layer view still exists and can be queried.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SQLMesh generates DROP TABLE for PostgreSQL views when using DuckDB engine

2 participants