Skip to content

Commit ec229f8

Browse files
Ewan-Keithbenc-db
andauthored
Fix: Retain multiple foreign-keys from one table to another after an incremental run (#1296)
<!-- Please review our pull request review process in CONTRIBUTING.md before your proceed. --> <!--- Include the number of the issue addressed by this PR above if applicable. Example: resolves #1234 Please review our pull request review process in CONTRIBUTING.md before your proceed. --> ### Description Encountered a bug when: * `"use_materialization_v2": False` * `config.contract.enforced: true` * Multiple foreign keys are specified from one table to another (e.g. a fact table with multiple date values, each of which has a foreign key constrain to a common data dimension) * An initial full run of the model with the FK constraints is carried out, followed by an incremental run. The bug is that on the initial full run the multiple FK constraints to the common table are created correctly on the Databricks table, but then when an incremental run is carried out these FKs are removed from the Databricks table. Single FKs to other tables seem to be retained (this isn't replicated in the test case in the interest of keeping it minimal). The bug is replicated in the integration test [TestIncrementalMultipleFKsToSameTableNoV2](https://github.com/databricks/dbt-databricks/compare/main...Ewan-Keith:dbt-databricks:consistenty-persist-databricks-constraints?expand=1#diff-64ad276353f7ed237d5072874d1fe18c67310f90493ce7dc8a9fc69756925335R380), before the fix is applied this passes the first assertion (after the initial full run) but fails the second assertion (after the incremental run) with an empty set of constraints returned. A matching test is also present replicating the logic when using materialization_v2 [TestIncrementalMultipleFKsToSameTable](https://github.com/databricks/dbt-databricks/compare/main...Ewan-Keith:dbt-databricks:consistenty-persist-databricks-constraints?expand=1#diff-64ad276353f7ed237d5072874d1fe18c67310f90493ce7dc8a9fc69756925335R334) which confirms the issue is not seen with v2 materialization (this passes without any actual code changes). The fix I've landed on in `dbt/include/databricks/macros/materializations/incremental/incremental.sql` is to treat constraints as one of the configuration changes that is explicitly applied on an incremental run when changes are detected (the same as for `tags`, `tblproperties`, and `liquid_clustering`). This gets the failing test passing. I don't think this is necessarily a root-cause fix, I think the root issue is likely a change in constraints being falsely detected in this instance, but I wasn't sure where to start on that, and this fix seems sensible (even if the root issue is resolved it seems likely we'd want changes to constraints to be applied without forcing a full-refresh where this is possible?). There's a unit test checking if there are any obvious bugs in the constraint diff checker but this doesn't turn anything up (passes as expected). ### Checklist - [x] I have run this code in development and it appears to resolve the stated issue - [x] This PR includes tests, or tests are not required/relevant for this PR - [x] I have updated the `CHANGELOG.md` and added information about my change to the "dbt-databricks next" section. --------- Signed-off-by: Ewan Keith <ewan.keith@kaluza.com> Co-authored-by: Ben Cassell <98852248+benc-db@users.noreply.github.com>
1 parent 3fa9099 commit ec229f8

File tree

5 files changed

+146
-5
lines changed

5 files changed

+146
-5
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
## dbt-databricks 1.11.5 (TBD)
2+
3+
### Fixes
4+
5+
- Fix foreign-key on an incremental table to a primary key on a non-incremental table being lost after incremental run
6+
17
## dbt-databricks 1.11.4 (Jan 12, 2026)
28

39
### Features

dbt/adapters/databricks/constraints.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -119,12 +119,14 @@ def _validate(self) -> None:
119119
)
120120

121121
def _render_suffix(self) -> str:
122-
suffix = f"FOREIGN KEY ({', '.join(self.columns)})"
123122
if self.expression:
124-
suffix += f" {self.expression}"
125-
else:
126-
suffix += f" REFERENCES {self.to} ({', '.join(self.to_columns)})"
127-
return suffix
123+
if self.expression.strip().startswith("("):
124+
return f"FOREIGN KEY {self.expression}"
125+
return f"FOREIGN KEY ({', '.join(self.columns)}) {self.expression}"
126+
return (
127+
f"FOREIGN KEY ({', '.join(self.columns)}) REFERENCES "
128+
+ f"{self.to} ({', '.join(self.to_columns)})"
129+
)
128130

129131

130132
class CheckConstraint(TypedConstraint):

dbt/include/databricks/macros/materializations/incremental/incremental.sql

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@
177177
{% set tags = _configuration_changes.changes.get("tags", None) %}
178178
{% set tblproperties = _configuration_changes.changes.get("tblproperties", None) %}
179179
{% set liquid_clustering = _configuration_changes.changes.get("liquid_clustering") %}
180+
{% set constraints = _configuration_changes.changes.get("constraints") %}
180181
{% if tags is not none %}
181182
{% do apply_tags(target_relation, tags.set_tags) %}
182183
{%- endif -%}
@@ -186,6 +187,10 @@
186187
{% if liquid_clustering is not none %}
187188
{% do apply_liquid_clustered_cols(target_relation, liquid_clustering) %}
188189
{% endif %}
190+
{#- Incremental constraint application requires information_schema access (see fetch_*_constraints macros) -#}
191+
{% if constraints and not target_relation.is_hive_metastore() %}
192+
{{ apply_constraints(target_relation, constraints) }}
193+
{% endif %}
189194
{%- endif -%}
190195
{% do persist_docs(target_relation, model, for_relation=True) %}
191196
{%- endif -%}

tests/functional/adapter/incremental/fixtures.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1109,3 +1109,55 @@ def model(dbt, spark):
11091109
to_columns: [id]
11101110
warn_unenforced: false
11111111
"""
1112+
1113+
non_incremental_target_of_fk = """
1114+
{{ config(
1115+
materialized='table',
1116+
) }}
1117+
1118+
SELECT 'a' AS str_key;
1119+
"""
1120+
1121+
incremental_fk_sql = """
1122+
{{ config(
1123+
materialized='incremental',
1124+
unique_key=['fk_col'],
1125+
incremental_strategy='delete+insert',
1126+
on_schema_change='fail'
1127+
) }}
1128+
1129+
SELECT
1130+
'a' AS fk_col
1131+
"""
1132+
1133+
incremental_fk_on_non_incremental_target_schema_yml = """
1134+
version: 2
1135+
1136+
models:
1137+
- name: non_incremental_target_of_fk
1138+
config:
1139+
contract:
1140+
enforced: true
1141+
columns:
1142+
- name: str_key
1143+
data_type: string
1144+
constraints:
1145+
- type: not_null
1146+
- type: primary_key
1147+
name: pk_target_key
1148+
warn_unenforced: false
1149+
1150+
- name: incremental_fk
1151+
config:
1152+
contract:
1153+
enforced: true
1154+
columns:
1155+
- name: fk_col
1156+
data_type: string
1157+
constraints:
1158+
- type: foreign_key
1159+
to: ref('non_incremental_target_of_fk')
1160+
to_columns: ["str_key"]
1161+
name: fk
1162+
warn_unenforced: false
1163+
"""

tests/functional/adapter/incremental/test_incremental_constraints.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,3 +328,79 @@ def test_remove_foreign_key_constraint(self, project):
328328
util.run_dbt(["run"])
329329
referential_constraints = project.run_sql(referential_constraint_sql, fetch="all")
330330
assert len(referential_constraints) == 0
331+
332+
333+
@pytest.mark.skip_profile("databricks_cluster")
334+
class TestIncrementalFkTargetNonIncrementalIsRetained:
335+
@pytest.fixture(scope="class")
336+
def project_config_update(self):
337+
return {
338+
"flags": {"use_materialization_v2": True},
339+
}
340+
341+
@pytest.fixture(scope="class")
342+
def models(self):
343+
return {
344+
"non_incremental_target_of_fk.sql": fixtures.non_incremental_target_of_fk,
345+
"incremental_fk.sql": fixtures.incremental_fk_sql,
346+
"schema.yml": fixtures.incremental_fk_on_non_incremental_target_schema_yml,
347+
}
348+
349+
def test_multiple_fks_to_same_table_persist_after_incremental(self, project):
350+
expected_constraints = {
351+
("fk", "pk_target_key"),
352+
}
353+
354+
# Initial run - create tables with constraints
355+
util.run_dbt(["run"])
356+
357+
referential_constraints = project.run_sql(referential_constraint_sql, fetch="all")
358+
359+
constraints = {(row[0], row[1]) for row in referential_constraints}
360+
assert constraints == expected_constraints
361+
362+
# Incremental run - this should NOT lose any foreign keys
363+
util.run_dbt(["run"])
364+
365+
referential_constraints_after = project.run_sql(referential_constraint_sql, fetch="all")
366+
367+
constraint_names_after = {(row[0], row[1]) for row in referential_constraints_after}
368+
assert constraint_names_after == expected_constraints
369+
370+
371+
@pytest.mark.skip_profile("databricks_cluster")
372+
class TestIncrementalFkTargetNonIncrementalIsRetainedNoV2:
373+
@pytest.fixture(scope="class")
374+
def project_config_update(self):
375+
return {
376+
"flags": {"use_materialization_v2": False},
377+
}
378+
379+
@pytest.fixture(scope="class")
380+
def models(self):
381+
return {
382+
"non_incremental_target_of_fk.sql": fixtures.non_incremental_target_of_fk,
383+
"incremental_fk.sql": fixtures.incremental_fk_sql,
384+
"schema.yml": fixtures.incremental_fk_on_non_incremental_target_schema_yml,
385+
}
386+
387+
def test_multiple_fks_to_same_table_persist_after_incremental(self, project):
388+
expected_constraints = {
389+
("fk", "pk_target_key"),
390+
}
391+
392+
# Initial run - create tables with constraints
393+
util.run_dbt(["run"])
394+
395+
referential_constraints = project.run_sql(referential_constraint_sql, fetch="all")
396+
397+
constraints = {(row[0], row[1]) for row in referential_constraints}
398+
assert constraints == expected_constraints
399+
400+
# Incremental run - this should NOT lose any foreign keys
401+
util.run_dbt(["run"])
402+
403+
referential_constraints_after = project.run_sql(referential_constraint_sql, fetch="all")
404+
405+
constraint_names_after = {(row[0], row[1]) for row in referential_constraints_after}
406+
assert constraint_names_after == expected_constraints

0 commit comments

Comments
 (0)