From 62bf4364eee54fa97cada7920c298dc10a57fd05 Mon Sep 17 00:00:00 2001 From: James Colvin Date: Tue, 17 Mar 2026 10:08:52 -0400 Subject: [PATCH 1/4] Fix dbt clone for Snowflake Iceberg tables The clone macro now detects whether the source relation is an Iceberg table via load_cached_relation and emits CREATE ICEBERG TABLE ... CLONE instead of CREATE TABLE ... CLONE. The transient keyword is correctly omitted for Iceberg clones since Iceberg tables cannot be transient. Resolves #1767 --- .../unreleased/Fixes-20260317-100403.yaml | 6 ++ .../macros/materializations/clone.sql | 8 ++- .../adapter/dbt_clone/test_dbt_clone.py | 68 +++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 dbt-snowflake/.changes/unreleased/Fixes-20260317-100403.yaml diff --git a/dbt-snowflake/.changes/unreleased/Fixes-20260317-100403.yaml b/dbt-snowflake/.changes/unreleased/Fixes-20260317-100403.yaml new file mode 100644 index 0000000000..5ea770ebde --- /dev/null +++ b/dbt-snowflake/.changes/unreleased/Fixes-20260317-100403.yaml @@ -0,0 +1,6 @@ +kind: Fixes +body: Fix dbt clone for Iceberg tables by using CREATE ICEBERG TABLE syntax when source is an Iceberg table +time: 2026-03-17T10:04:03.215641-04:00 +custom: + Author: jcolvin + Issue: "1767" diff --git a/dbt-snowflake/src/dbt/include/snowflake/macros/materializations/clone.sql b/dbt-snowflake/src/dbt/include/snowflake/macros/materializations/clone.sql index 665fd593be..c700da4851 100644 --- a/dbt-snowflake/src/dbt/include/snowflake/macros/materializations/clone.sql +++ b/dbt-snowflake/src/dbt/include/snowflake/macros/materializations/clone.sql @@ -3,8 +3,14 @@ {% endmacro %} {% macro snowflake__create_or_replace_clone(this_relation, defer_relation) %} + {%- set source_relation = load_cached_relation(defer_relation) -%} + {%- set is_iceberg = source_relation is not none and source_relation.is_iceberg_format -%} create or replace - {{ "transient" if config.get("transient", true) }} + {% if is_iceberg -%} + iceberg + {%- else -%} + {{ "transient" if config.get("transient", true) }} + {%- endif %} table {{ this_relation }} clone {{ defer_relation }} {{ "copy grants" if config.get("copy_grants", false) }} diff --git a/dbt-snowflake/tests/functional/adapter/dbt_clone/test_dbt_clone.py b/dbt-snowflake/tests/functional/adapter/dbt_clone/test_dbt_clone.py index 1192fcef4b..d9176b4162 100644 --- a/dbt-snowflake/tests/functional/adapter/dbt_clone/test_dbt_clone.py +++ b/dbt-snowflake/tests/functional/adapter/dbt_clone/test_dbt_clone.py @@ -85,5 +85,73 @@ def test_can_clone_transient_table(self, project, other_schema): assert len(results) == 1 +iceberg_table_model_sql = """ + {{ config( + materialized='table', + table_format='iceberg', + external_volume='s3_iceberg_snow', + base_location_subpath='clone_test', + ) }} + + select 1 as id + """ + + +class TestSnowflakeCloneIcebergTable: + @pytest.fixture(scope="class") + def models(self): + return { + "iceberg_model.sql": iceberg_table_model_sql, + } + + @pytest.fixture(scope="class") + def other_schema(self, unique_schema): + return unique_schema + "_other" + + @pytest.fixture(scope="class") + def profiles_config_update(self, dbt_profile_target, unique_schema, other_schema): + outputs = {"default": dbt_profile_target, "otherschema": deepcopy(dbt_profile_target)} + outputs["default"]["schema"] = unique_schema + outputs["otherschema"]["schema"] = other_schema + return {"test": {"outputs": outputs, "target": "default"}} + + def copy_state(self, project_root): + state_path = os.path.join(project_root, "state") + if not os.path.exists(state_path): + os.makedirs(state_path) + shutil.copyfile( + f"{project_root}/target/manifest.json", f"{project_root}/state/manifest.json" + ) + + def run_and_save_state(self, project_root): + results = run_dbt(["run"]) + assert len(results) == 1 + + self.copy_state(project_root) + + def test_can_clone_iceberg_table(self, project, other_schema): + project.create_test_schema(other_schema) + self.run_and_save_state(project.project_root) + + clone_args = [ + "clone", + "--state", + "state", + "--target", + "otherschema", + ] + + results = run_dbt(clone_args) + assert len(results) == 1 + + # Verify the cloned relation is also an Iceberg table + with project.adapter.connection_named("__test"): + schema_relations = project.adapter.list_relations( + database=project.database, schema=other_schema + ) + assert len(schema_relations) == 1 + assert schema_relations[0].is_iceberg_format + + class TestSnowflakeCloneSameSourceAndTarget(BaseCloneSameSourceAndTarget): pass From bd7ff1f19574dbd90410ae3677f0092e435a7b5a Mon Sep 17 00:00:00 2001 From: James Colvin Date: Tue, 24 Mar 2026 09:20:54 -0400 Subject: [PATCH 2/4] Fix iceberg detection in clone when source schema is not cached load_cached_relation returns None during cross-schema clone operations because the source schema is not in the adapter cache. Fall back to querying Snowflake directly via SHOW TABLES to detect iceberg format. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../macros/materializations/clone.sql | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/dbt-snowflake/src/dbt/include/snowflake/macros/materializations/clone.sql b/dbt-snowflake/src/dbt/include/snowflake/macros/materializations/clone.sql index c700da4851..0d66c82f69 100644 --- a/dbt-snowflake/src/dbt/include/snowflake/macros/materializations/clone.sql +++ b/dbt-snowflake/src/dbt/include/snowflake/macros/materializations/clone.sql @@ -3,8 +3,7 @@ {% endmacro %} {% macro snowflake__create_or_replace_clone(this_relation, defer_relation) %} - {%- set source_relation = load_cached_relation(defer_relation) -%} - {%- set is_iceberg = source_relation is not none and source_relation.is_iceberg_format -%} + {%- set is_iceberg = snowflake__is_iceberg_table(defer_relation) -%} create or replace {% if is_iceberg -%} iceberg @@ -15,3 +14,20 @@ clone {{ defer_relation }} {{ "copy grants" if config.get("copy_grants", false) }} {% endmacro %} + +{% macro snowflake__is_iceberg_table(relation) %} + {%- set source_relation = load_cached_relation(relation) -%} + {%- if source_relation is not none -%} + {{ return(source_relation.is_iceberg_format) }} + {%- endif -%} + + {#- Cache miss: query Snowflake directly -#} + {%- set show_sql -%} + show tables like '{{ relation.identifier }}' in {{ relation.database }}.{{ relation.schema }} + {%- endset -%} + {%- set results = run_query(show_sql) -%} + {%- if results and results | length > 0 -%} + {{ return(results.columns.get('is_iceberg').values()[0] in ('Y', 'YES')) }} + {%- endif -%} + {{ return(false) }} +{% endmacro %} From a2561f32b992a2365f17b742064e85e2bc8fb1be Mon Sep 17 00:00:00 2001 From: James Colvin Date: Wed, 1 Apr 2026 14:07:20 -0400 Subject: [PATCH 3/4] Address PR review feedback for Iceberg clone - Harden SHOW TABLES fallback: add IN SCHEMA keyword and filter by exact name match to avoid LIKE wildcard false positives - Use SNOWFLAKE_TEST_ICEBERG_EXTERNAL_VOLUME env var for test portability - Extract copy_state/run_and_save_state to shared module-level functions Co-Authored-By: Claude Opus 4.6 (1M context) --- .../macros/materializations/clone.sql | 10 ++-- .../adapter/dbt_clone/test_dbt_clone.py | 55 +++++++------------ 2 files changed, 27 insertions(+), 38 deletions(-) diff --git a/dbt-snowflake/src/dbt/include/snowflake/macros/materializations/clone.sql b/dbt-snowflake/src/dbt/include/snowflake/macros/materializations/clone.sql index 0d66c82f69..d69947be27 100644 --- a/dbt-snowflake/src/dbt/include/snowflake/macros/materializations/clone.sql +++ b/dbt-snowflake/src/dbt/include/snowflake/macros/materializations/clone.sql @@ -23,11 +23,13 @@ {#- Cache miss: query Snowflake directly -#} {%- set show_sql -%} - show tables like '{{ relation.identifier }}' in {{ relation.database }}.{{ relation.schema }} + show tables like '{{ relation.identifier }}' in schema {{ relation.database }}.{{ relation.schema }} {%- endset -%} {%- set results = run_query(show_sql) -%} - {%- if results and results | length > 0 -%} - {{ return(results.columns.get('is_iceberg').values()[0] in ('Y', 'YES')) }} - {%- endif -%} + {%- for row in results -%} + {%- if row['name'] == relation.identifier -%} + {{ return(row['is_iceberg'] in ('Y', 'YES')) }} + {%- endif -%} + {%- endfor -%} {{ return(false) }} {% endmacro %} diff --git a/dbt-snowflake/tests/functional/adapter/dbt_clone/test_dbt_clone.py b/dbt-snowflake/tests/functional/adapter/dbt_clone/test_dbt_clone.py index d9176b4162..a12d5c24dc 100644 --- a/dbt-snowflake/tests/functional/adapter/dbt_clone/test_dbt_clone.py +++ b/dbt-snowflake/tests/functional/adapter/dbt_clone/test_dbt_clone.py @@ -27,6 +27,19 @@ def clean_up(self, project): pass +def copy_state(project_root): + state_path = os.path.join(project_root, "state") + if not os.path.exists(state_path): + os.makedirs(state_path) + shutil.copyfile(f"{project_root}/target/manifest.json", f"{project_root}/state/manifest.json") + + +def run_and_save_state(project_root): + results = run_dbt(["run"]) + assert len(results) == 1 + copy_state(project_root) + + table_model_1_sql = """ {{ config( materialized='table', @@ -55,23 +68,9 @@ def profiles_config_update(self, dbt_profile_target, unique_schema, other_schema outputs["otherschema"]["schema"] = other_schema return {"test": {"outputs": outputs, "target": "default"}} - def copy_state(self, project_root): - state_path = os.path.join(project_root, "state") - if not os.path.exists(state_path): - os.makedirs(state_path) - shutil.copyfile( - f"{project_root}/target/manifest.json", f"{project_root}/state/manifest.json" - ) - - def run_and_save_state(self, project_root, with_snapshot=False): - results = run_dbt(["run"]) - assert len(results) == 1 - - self.copy_state(project_root) - def test_can_clone_transient_table(self, project, other_schema): project.create_test_schema(other_schema) - self.run_and_save_state(project.project_root) + run_and_save_state(project.project_root) clone_args = [ "clone", @@ -85,13 +84,15 @@ def test_can_clone_transient_table(self, project, other_schema): assert len(results) == 1 -iceberg_table_model_sql = """ - {{ config( +ICEBERG_EXTERNAL_VOLUME = os.getenv("SNOWFLAKE_TEST_ICEBERG_EXTERNAL_VOLUME", "s3_iceberg_snow") + +iceberg_table_model_sql = f""" + {{{{ config( materialized='table', table_format='iceberg', - external_volume='s3_iceberg_snow', + external_volume='{ICEBERG_EXTERNAL_VOLUME}', base_location_subpath='clone_test', - ) }} + ) }}}} select 1 as id """ @@ -115,23 +116,9 @@ def profiles_config_update(self, dbt_profile_target, unique_schema, other_schema outputs["otherschema"]["schema"] = other_schema return {"test": {"outputs": outputs, "target": "default"}} - def copy_state(self, project_root): - state_path = os.path.join(project_root, "state") - if not os.path.exists(state_path): - os.makedirs(state_path) - shutil.copyfile( - f"{project_root}/target/manifest.json", f"{project_root}/state/manifest.json" - ) - - def run_and_save_state(self, project_root): - results = run_dbt(["run"]) - assert len(results) == 1 - - self.copy_state(project_root) - def test_can_clone_iceberg_table(self, project, other_schema): project.create_test_schema(other_schema) - self.run_and_save_state(project.project_root) + run_and_save_state(project.project_root) clone_args = [ "clone", From 428f6fec810dd12a6a3665f159f54138e8873dac Mon Sep 17 00:00:00 2001 From: James Colvin Date: Thu, 2 Apr 2026 09:38:58 -0400 Subject: [PATCH 4/4] Remove SHOW TABLES fallback, use os.path.join in test helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove SHOW TABLES fallback in snowflake__is_iceberg_table per reviewer feedback — cache should already have the relation - Use os.path.join consistently in copy_state helper Co-Authored-By: Claude Opus 4.6 (1M context) --- .../snowflake/macros/materializations/clone.sql | 10 ---------- .../functional/adapter/dbt_clone/test_dbt_clone.py | 5 ++++- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/dbt-snowflake/src/dbt/include/snowflake/macros/materializations/clone.sql b/dbt-snowflake/src/dbt/include/snowflake/macros/materializations/clone.sql index d69947be27..0105ff5273 100644 --- a/dbt-snowflake/src/dbt/include/snowflake/macros/materializations/clone.sql +++ b/dbt-snowflake/src/dbt/include/snowflake/macros/materializations/clone.sql @@ -21,15 +21,5 @@ {{ return(source_relation.is_iceberg_format) }} {%- endif -%} - {#- Cache miss: query Snowflake directly -#} - {%- set show_sql -%} - show tables like '{{ relation.identifier }}' in schema {{ relation.database }}.{{ relation.schema }} - {%- endset -%} - {%- set results = run_query(show_sql) -%} - {%- for row in results -%} - {%- if row['name'] == relation.identifier -%} - {{ return(row['is_iceberg'] in ('Y', 'YES')) }} - {%- endif -%} - {%- endfor -%} {{ return(false) }} {% endmacro %} diff --git a/dbt-snowflake/tests/functional/adapter/dbt_clone/test_dbt_clone.py b/dbt-snowflake/tests/functional/adapter/dbt_clone/test_dbt_clone.py index a12d5c24dc..6c5e86f75b 100644 --- a/dbt-snowflake/tests/functional/adapter/dbt_clone/test_dbt_clone.py +++ b/dbt-snowflake/tests/functional/adapter/dbt_clone/test_dbt_clone.py @@ -31,7 +31,10 @@ def copy_state(project_root): state_path = os.path.join(project_root, "state") if not os.path.exists(state_path): os.makedirs(state_path) - shutil.copyfile(f"{project_root}/target/manifest.json", f"{project_root}/state/manifest.json") + shutil.copyfile( + os.path.join(project_root, "target", "manifest.json"), + os.path.join(project_root, "state", "manifest.json"), + ) def run_and_save_state(project_root):