delta-engine 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. {delta_engine-0.2.0 → delta_engine-0.3.0}/.github/workflows/ci.yaml +2 -1
  2. {delta_engine-0.2.0 → delta_engine-0.3.0}/CHANGELOG.md +38 -0
  3. {delta_engine-0.2.0 → delta_engine-0.3.0}/PKG-INFO +2 -2
  4. {delta_engine-0.2.0 → delta_engine-0.3.0}/README.md +1 -1
  5. delta_engine-0.3.0/body.md +38 -0
  6. {delta_engine-0.2.0 → delta_engine-0.3.0}/docs/explanation-architecture.md +69 -39
  7. {delta_engine-0.2.0 → delta_engine-0.3.0}/docs/explanation-safety-model.md +8 -6
  8. {delta_engine-0.2.0 → delta_engine-0.3.0}/docs/how-to-configure-table.md +125 -30
  9. {delta_engine-0.2.0 → delta_engine-0.3.0}/docs/how-to-deploy-metadata-only.md +18 -15
  10. {delta_engine-0.2.0 → delta_engine-0.3.0}/docs/reference-api.md +16 -6
  11. {delta_engine-0.2.0 → delta_engine-0.3.0}/docs/reference-limitations.md +19 -3
  12. {delta_engine-0.2.0 → delta_engine-0.3.0}/docs/reference-safe-change-rules.md +23 -4
  13. {delta_engine-0.2.0 → delta_engine-0.3.0}/docs/todo/todo.md +2 -2
  14. {delta_engine-0.2.0 → delta_engine-0.3.0}/notebooks/delta_engine_walkthrough.py +57 -39
  15. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/adapters/databricks/reader.py +18 -1
  16. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/adapters/databricks/sql/compile.py +20 -0
  17. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/api/delta_table.py +190 -69
  18. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/application/dependency_resolution.py +13 -5
  19. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/application/rendering.py +24 -3
  20. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/domain/model/column.py +0 -3
  21. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/domain/model/constraints.py +20 -9
  22. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/domain/model/table.py +19 -2
  23. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/domain/plan/__init__.py +4 -0
  24. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/domain/plan/actions.py +18 -0
  25. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/domain/plan/changes.py +26 -0
  26. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/domain/plan/diff.py +21 -0
  27. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/adapters/databricks/sql/test_compile.py +26 -0
  28. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/adapters/databricks/test_reader.py +23 -5
  29. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/adapters/databricks/test_reader_mappers.py +23 -2
  30. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/api/test_delta_table.py +282 -43
  31. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/api/test_foreign_key.py +227 -97
  32. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/application/test_dependency_resolution.py +94 -9
  33. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/application/test_engine.py +78 -18
  34. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/application/test_rendering.py +34 -1
  35. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/application/test_validation.py +90 -0
  36. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/domain/model/test_foreign_key.py +53 -0
  37. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/domain/model/test_table.py +42 -0
  38. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/domain/model/test_table_aspect.py +8 -0
  39. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/domain/plan/test_actions.py +30 -0
  40. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/domain/plan/test_diff.py +63 -0
  41. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/e2e/test_engine_e2e.py +57 -4
  42. delta_engine-0.2.0/body.md +0 -28
  43. {delta_engine-0.2.0 → delta_engine-0.3.0}/.claude/CLAUDE.md +0 -0
  44. {delta_engine-0.2.0 → delta_engine-0.3.0}/.claude/agents/ousterhout-design-reviewer.md +0 -0
  45. {delta_engine-0.2.0 → delta_engine-0.3.0}/.claude/agents/test-reviewer.md +0 -0
  46. {delta_engine-0.2.0 → delta_engine-0.3.0}/.claude/rules/coding-style.md +0 -0
  47. {delta_engine-0.2.0 → delta_engine-0.3.0}/.claude/rules/documentation.md +0 -0
  48. {delta_engine-0.2.0 → delta_engine-0.3.0}/.claude/rules/domain-architecture.md +0 -0
  49. {delta_engine-0.2.0 → delta_engine-0.3.0}/.claude/rules/testing.md +0 -0
  50. {delta_engine-0.2.0 → delta_engine-0.3.0}/.claude/skills/add-tests/SKILL.md +0 -0
  51. {delta_engine-0.2.0 → delta_engine-0.3.0}/.github/pull_request_template.md +0 -0
  52. {delta_engine-0.2.0 → delta_engine-0.3.0}/.github/workflows/pr-title.yaml +0 -0
  53. {delta_engine-0.2.0 → delta_engine-0.3.0}/.github/workflows/publish-testpypi.yaml +0 -0
  54. {delta_engine-0.2.0 → delta_engine-0.3.0}/.github/workflows/release.yaml +0 -0
  55. {delta_engine-0.2.0 → delta_engine-0.3.0}/.gitignore +0 -0
  56. {delta_engine-0.2.0 → delta_engine-0.3.0}/.pre-commit-config.yaml +0 -0
  57. {delta_engine-0.2.0 → delta_engine-0.3.0}/LICENSE +0 -0
  58. {delta_engine-0.2.0 → delta_engine-0.3.0}/docs/conf.py +0 -0
  59. {delta_engine-0.2.0 → delta_engine-0.3.0}/docs/explanation-sync-lifecycle.md +0 -0
  60. {delta_engine-0.2.0 → delta_engine-0.3.0}/docs/how-to-add-action-type.md +0 -0
  61. {delta_engine-0.2.0 → delta_engine-0.3.0}/docs/how-to-handle-sync-failures.md +0 -0
  62. {delta_engine-0.2.0 → delta_engine-0.3.0}/docs/how-to-implement-adapter.md +0 -0
  63. {delta_engine-0.2.0 → delta_engine-0.3.0}/docs/how-to-preview-changes.md +0 -0
  64. {delta_engine-0.2.0 → delta_engine-0.3.0}/docs/index.md +0 -0
  65. {delta_engine-0.2.0 → delta_engine-0.3.0}/docs/installation.md +0 -0
  66. {delta_engine-0.2.0 → delta_engine-0.3.0}/docs/reference-data-types.md +0 -0
  67. {delta_engine-0.2.0 → delta_engine-0.3.0}/docs/tutorial-getting-started.md +0 -0
  68. {delta_engine-0.2.0 → delta_engine-0.3.0}/notebooks/README.md +0 -0
  69. {delta_engine-0.2.0 → delta_engine-0.3.0}/notebooks/catalog_inspector.py +0 -0
  70. {delta_engine-0.2.0 → delta_engine-0.3.0}/pyproject.toml +0 -0
  71. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/__init__.py +0 -0
  72. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/adapters/__init__.py +0 -0
  73. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/adapters/databricks/__init__.py +0 -0
  74. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/adapters/databricks/errors.py +0 -0
  75. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/adapters/databricks/executor.py +0 -0
  76. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/adapters/databricks/factory.py +0 -0
  77. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/adapters/databricks/log_config.py +0 -0
  78. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/adapters/databricks/sql/__init__.py +0 -0
  79. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/adapters/databricks/sql/dialect.py +0 -0
  80. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/adapters/databricks/sql/queries.py +0 -0
  81. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/adapters/databricks/sql/types.py +0 -0
  82. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/api/__init__.py +0 -0
  83. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/application/__init__.py +0 -0
  84. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/application/desired_tables.py +0 -0
  85. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/application/engine.py +0 -0
  86. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/application/errors.py +0 -0
  87. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/application/failures.py +0 -0
  88. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/application/ports.py +0 -0
  89. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/application/properties.py +0 -0
  90. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/application/report.py +0 -0
  91. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/application/validation.py +0 -0
  92. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/databricks.py +0 -0
  93. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/domain/__init__.py +0 -0
  94. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/domain/model/__init__.py +0 -0
  95. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/domain/model/data_type.py +0 -0
  96. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/domain/model/qualified_name.py +0 -0
  97. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/py.typed +0 -0
  98. {delta_engine-0.2.0 → delta_engine-0.3.0}/src/delta_engine/schema.py +0 -0
  99. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/__init__.py +0 -0
  100. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/adapters/databricks/__init__.py +0 -0
  101. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/adapters/databricks/sql/__init__.py +0 -0
  102. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/adapters/databricks/sql/test_dialect.py +0 -0
  103. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/adapters/databricks/sql/test_queries.py +0 -0
  104. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/adapters/databricks/sql/test_types.py +0 -0
  105. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/adapters/databricks/test_build_engine.py +0 -0
  106. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/adapters/databricks/test_errors.py +0 -0
  107. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/adapters/databricks/test_executor.py +0 -0
  108. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/api/__init__.py +0 -0
  109. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/application/__init__.py +0 -0
  110. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/application/test_desired_tables.py +0 -0
  111. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/application/test_errors.py +0 -0
  112. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/application/test_failures.py +0 -0
  113. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/application/test_ports.py +0 -0
  114. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/application/test_properties.py +0 -0
  115. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/application/test_public_api.py +0 -0
  116. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/application/test_report.py +0 -0
  117. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/config.py +0 -0
  118. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/conftest.py +0 -0
  119. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/domain/__init__.py +0 -0
  120. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/domain/model/__init__.py +0 -0
  121. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/domain/model/test_column.py +0 -0
  122. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/domain/model/test_data_type.py +0 -0
  123. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/domain/model/test_primary_key.py +0 -0
  124. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/domain/model/test_qualified_name.py +0 -0
  125. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/domain/plan/__init__.py +0 -0
  126. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/e2e/__init__.py +0 -0
  127. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/e2e/conftest.py +0 -0
  128. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/test_public_api.py +0 -0
  129. {delta_engine-0.2.0 → delta_engine-0.3.0}/tests/test_public_imports.py +0 -0
  130. {delta_engine-0.2.0 → delta_engine-0.3.0}/uv.lock +0 -0
@@ -37,9 +37,10 @@ jobs:
37
37
  schema="silver",
38
38
  name="customers",
39
39
  columns=[
40
- Column("id", Integer(), nullable=False, primary_key=True),
40
+ Column("id", Integer(), nullable=False),
41
41
  Column("name", String(), nullable=True),
42
42
  ],
43
+ primary_key=["id"],
43
44
  )
44
45
 
45
46
  desired = table.to_desired_table()
@@ -1,3 +1,41 @@
1
+ ## v0.3.0 (2026-07-10)
2
+
3
+ ### BREAKING CHANGE
4
+
5
+ - DeltaTable(metadata_only=True) is replaced by
6
+ DeltaTable(scope="metadata"). The metadata_only parameter is removed.
7
+
8
+ ### Feat
9
+
10
+ - **api**: declare sync scope with a scope parameter
11
+ - declare foreign key pairings as an explicit mapping
12
+ - declare primary keys at table level
13
+ - canonicalize foreign key pair order in the domain
14
+ - derive clustering from Column.cluster_key in DeltaTable
15
+ - read observed clustering from DESCRIBE DETAIL clusteringColumns
16
+ - diff clustering by set and emit AlterClustering
17
+ - render clustering changes with an OPTIMIZE FULL hint
18
+ - compile CLUSTER BY for create and in-place clustering
19
+ - add AlterClustering action and SET_CLUSTERING phase
20
+ - carry clustered_by on table snapshots with invariants
21
+ - add CLUSTERING aspect and Column.cluster_key flag
22
+
23
+ ### Fix
24
+
25
+ - reject a bare string primary_key at declaration
26
+ - **notebooks**: migrate walkthrough to the scope parameter
27
+ - update runtime-import CI smoke test to table-level primary_key
28
+ - drop OPTIMIZE FULL hint from clustering removal; note clustering in metadata-only docs
29
+ - reconcile clustering before dropping columns
30
+ - **test**: make DESCRIBE DETAIL reader fakes support asDict()
31
+
32
+ ### Refactor
33
+
34
+ - **api**: clean up scope resolution and structure-gated validation
35
+ - wrap ForeignKey.columns in a read-only view; drop stale inference wording
36
+ - drop redundant clustering guard and trim docstrings
37
+ - declare clustering with table-level clustered_by, not a per-column flag
38
+
1
39
  ## v0.2.0 (2026-07-09)
2
40
 
3
41
  ### Feat
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: delta-engine
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Framework for defining, validating, and evolving tables in a consistent, safe, and controlled way.
5
5
  Project-URL: Homepage, https://github.com/Tomoscorbin/delta-engine
6
6
  Project-URL: Repository, https://github.com/Tomoscorbin/delta-engine
@@ -109,7 +109,7 @@ for the model, or jump to what you need:
109
109
  - [Safe-change rules](https://github.com/Tomoscorbin/delta-engine/blob/main/docs/reference-safe-change-rules.md) — changes the engine blocks at validation
110
110
  - [API reference](https://github.com/Tomoscorbin/delta-engine/blob/main/docs/reference-api.md)
111
111
 
112
- **Architecture** (for contributors)
112
+ **Architecture**
113
113
 
114
114
  - [Architecture](https://github.com/Tomoscorbin/delta-engine/blob/main/docs/explanation-architecture.md) — layers, ports and adapters, design decisions
115
115
  - [Implement a custom adapter](https://github.com/Tomoscorbin/delta-engine/blob/main/docs/how-to-implement-adapter.md) — the `CatalogStateReader` and `PlanExecutor` ports
@@ -83,7 +83,7 @@ for the model, or jump to what you need:
83
83
  - [Safe-change rules](https://github.com/Tomoscorbin/delta-engine/blob/main/docs/reference-safe-change-rules.md) — changes the engine blocks at validation
84
84
  - [API reference](https://github.com/Tomoscorbin/delta-engine/blob/main/docs/reference-api.md)
85
85
 
86
- **Architecture** (for contributors)
86
+ **Architecture**
87
87
 
88
88
  - [Architecture](https://github.com/Tomoscorbin/delta-engine/blob/main/docs/explanation-architecture.md) — layers, ports and adapters, design decisions
89
89
  - [Implement a custom adapter](https://github.com/Tomoscorbin/delta-engine/blob/main/docs/how-to-implement-adapter.md) — the `CatalogStateReader` and `PlanExecutor` ports
@@ -0,0 +1,38 @@
1
+ ## v0.3.0 (2026-07-10)
2
+
3
+ ### BREAKING CHANGE
4
+
5
+ - DeltaTable(metadata_only=True) is replaced by
6
+ DeltaTable(scope="metadata"). The metadata_only parameter is removed.
7
+
8
+ ### Feat
9
+
10
+ - **api**: declare sync scope with a scope parameter
11
+ - declare foreign key pairings as an explicit mapping
12
+ - declare primary keys at table level
13
+ - canonicalize foreign key pair order in the domain
14
+ - derive clustering from Column.cluster_key in DeltaTable
15
+ - read observed clustering from DESCRIBE DETAIL clusteringColumns
16
+ - diff clustering by set and emit AlterClustering
17
+ - render clustering changes with an OPTIMIZE FULL hint
18
+ - compile CLUSTER BY for create and in-place clustering
19
+ - add AlterClustering action and SET_CLUSTERING phase
20
+ - carry clustered_by on table snapshots with invariants
21
+ - add CLUSTERING aspect and Column.cluster_key flag
22
+
23
+ ### Fix
24
+
25
+ - reject a bare string primary_key at declaration
26
+ - **notebooks**: migrate walkthrough to the scope parameter
27
+ - update runtime-import CI smoke test to table-level primary_key
28
+ - drop OPTIMIZE FULL hint from clustering removal; note clustering in metadata-only docs
29
+ - reconcile clustering before dropping columns
30
+ - **test**: make DESCRIBE DETAIL reader fakes support asDict()
31
+
32
+ ### Refactor
33
+
34
+ - **api**: clean up scope resolution and structure-gated validation
35
+ - wrap ForeignKey.columns in a read-only view; drop stale inference wording
36
+ - drop redundant clustering guard and trim docstrings
37
+ - declare clustering with table-level clustered_by, not a per-column flag
38
+
@@ -65,7 +65,7 @@ through a sync.
65
65
  | `CatalogState` | The result of reading one table: `TablePresent`, `TableAbsent`, or `ReadFailed`. |
66
66
  | `TableDiff` | Typed facts describing how desired and observed state differ. It is either `TableMissing` or `TableDrift`. |
67
67
  | `Change` | One atomic difference inside a `TableDrift` (`ColumnAdded`, `TableTagUnset`, …). Each names the `TableAspect` it belongs to and can lower itself to actions. |
68
- | `TableAspect` | One managed aspect of a table: columns, table comment, properties, tags, partitioning, primary key, or foreign keys. Internal enum. |
68
+ | `TableAspect` | One managed aspect of a table: columns, table comment, properties, tags, partitioning, clustering, primary key, or foreign keys. Internal enum. |
69
69
  | `ValidationResult` | The application policy verdict for a diff. It says whether a drift is safe to plan in this run. |
70
70
  | `ActionPlan` | The ordered, table-local actions that should be executed if the table is allowed to run. |
71
71
  | `ResolveResult` | The foreign-key dependency order plus any FK-specific failures. |
@@ -364,9 +364,14 @@ every change in the drift belongs to a managed aspect, so
364
364
  `TableDrift.plan()` naturally produces only the managed actions, with no
365
365
  filtering logic needed.
366
366
 
367
- The public API exposes named modes only: `DeltaTable(metadata_only=True)` maps
368
- to the metadata aspects (comments, tags, key constraints). The `TableAspect`
369
- enum stays internal.
367
+ The public API exposes named scopes only: `DeltaTable`'s `scope` parameter
368
+ maps `"metadata"` to the metadata aspects (comments, tags, key constraints)
369
+ and `"tags"` to table and column tags only. The `TableAspect` enum stays
370
+ internal.
371
+
372
+ `CLUSTERING` is not one of the metadata aspects: liquid clustering keys
373
+ change how data files are laid out on storage, so a `metadata_only` sync
374
+ never reconciles them, the same as `COLUMN_STRUCTURE` and `PARTITIONING`.
370
375
 
371
376
  `diff_table(desired, observed)` produces a `TableDiff`:
372
377
 
@@ -382,6 +387,7 @@ aspects:
382
387
  - table properties
383
388
  - table tags
384
389
  - partitioning
390
+ - clustering
385
391
  - primary key
386
392
  - foreign keys
387
393
 
@@ -429,6 +435,11 @@ The phase ordering exists because backend DDL has dependencies:
429
435
  - Column nullability changes run before primary keys are set, because primary
430
436
  key columns must be non-nullable.
431
437
  - Foreign keys are set last, after the referenced primary key exists.
438
+ - Clustering keys are altered after columns are added (a new clustering key may
439
+ name a column this same sync is still adding) but before columns are dropped,
440
+ so a table is reclustered off a column before that column is removed — a sync
441
+ that both drops the live clustering-key column and reclusters elsewhere must
442
+ not drop it while it is still the active key.
432
443
 
433
444
  The domain plan describes intent. The adapter compiler decides how each action
434
445
  is rendered for its backend.
@@ -477,9 +488,9 @@ dependency's report, but it is not retroactively converted into
477
488
  `DeltaTable` is the public declaration object, but the engine plans with
478
489
  `DesiredTable`. The lowering boundary does several important things up front:
479
490
 
480
- - rejects property keys the engine does not manage (valued or `None`),
481
- and rejects `metadata_only=True` combined with `properties`
482
- - generates a primary-key constraint from columns marked `primary_key=True`
491
+ - rejects property keys the engine does not manage (valued or `None`) and
492
+ rejects invalid declared property values
493
+ - generates a primary-key constraint from the table-level `primary_key` argument
483
494
  - lowers public `ForeignKey` declarations into domain `ForeignKeyConstraint`
484
495
  values
485
496
  - validates structural invariants such as non-empty columns, unique column
@@ -495,6 +506,7 @@ customers = DeltaTable(
495
506
  schema="silver",
496
507
  name="customers",
497
508
  columns=[...],
509
+ primary_key=["id"],
498
510
  )
499
511
 
500
512
  orders = DeltaTable(
@@ -503,17 +515,16 @@ orders = DeltaTable(
503
515
  name="orders",
504
516
  columns=[...],
505
517
  foreign_keys=[
506
- ForeignKey(local_columns=["customer_id"], references=customers),
518
+ ForeignKey(columns={"customer_id": "id"}, references=customers),
507
519
  ],
508
520
  )
509
521
  ```
510
522
 
511
- This object reference lets the API infer the referenced columns from the
512
- referenced table's primary key. The declaration does not repeat those columns,
513
- so it cannot drift from the target table's primary-key declaration. The tradeoff
514
- is that the referenced table must be declared in Python scope. Within one module
515
- that usually means defining the parent before the child; across modules it means
516
- importing the referenced table.
523
+ This object reference lets the API validate the mapping against the
524
+ referenced table's actual primary key, and keeps the reference valid if the
525
+ target is renamed. The tradeoff is that the referenced table must be declared
526
+ in Python scope. Within one module that usually means defining the parent
527
+ before the child; across modules it means importing the referenced table.
517
528
 
518
529
  This source-code order does not determine execution order. The engine sorts
519
530
  prepared desired tables by qualified name for deterministic setup, then the
@@ -521,20 +532,31 @@ resolver topologically orders them by FK dependency before execution.
521
532
 
522
533
  References by dotted name are intentionally not supported in this iteration. If
523
534
  that becomes necessary, the API can be widened to accept a `QualifiedName` as an
524
- additional branch. That would be backward-compatible, but it would also need
525
- explicit referenced columns because a bare name carries no primary key object to
526
- inspect.
527
-
528
- Partitioning is shaped by a related decision. Primary-key membership is a
529
- per-column flag (`primary_key=True`), and a composite key simply takes the
530
- flagged columns in declaration order. Partitioning cannot reuse that shape,
531
- because partition order is significant _and_ independent of column order: the
532
- order of names in `partitioned_by` sets the physical directory nesting Delta
533
- writes, and that nesting can be different from the order columns appear in the
534
- table. A per-column flag has nowhere to express an ordering distinct from
535
- column declaration order, so partition columns are named in one ordered,
536
- table-level list instead. The differ compares that list positionally, which is
537
- why reordering it is drift, not a no-op.
535
+ additional branch. That would be backward-compatible: the referenced columns are
536
+ already explicit in the `columns` mapping, so a bare name would only lose the
537
+ primary-key object the engine validates the mapping against.
538
+
539
+ Partitioning is shaped by a related decision. `primary_key` and
540
+ `partitioned_by` are both table-level lists of column names, but "order"
541
+ means something different for each. A primary key's declaration order only
542
+ controls how the constraint is rendered identity and drift compare it as a
543
+ set, so `(a, b)` and `(b, a)` are the same key. Partition order is
544
+ significant instead: the order of names in `partitioned_by` sets the physical
545
+ directory nesting Delta writes, and that nesting can be different from the
546
+ order columns appear in the table. The differ compares that list positionally,
547
+ which is why reordering it is drift, not a no-op.
548
+
549
+ Clustering is the other physical layout, and it is declared the same way —
550
+ `clustered_by` is a table-level list on `DeltaTable`, the sibling of
551
+ `partitioned_by` (the two are mutually exclusive; a table has one layout
552
+ strategy). What differs is not the declaration shape but the _comparison_:
553
+ liquid clustering has no physical directory nesting, so key order carries no
554
+ meaning — Delta clusters by the key _set_. So the differ compares
555
+ `partitioned_by` positionally (reordering it is drift) but compares
556
+ `clustered_by` as a set (reordering the keys is a no-op). The general rule: a
557
+ physical layout is a table-level list (`partitioned_by`, `clustered_by`), and
558
+ whether order is significant is a property of the differ, not of the
559
+ declaration shape.
538
560
 
539
561
  ## Constraint names
540
562
 
@@ -544,7 +566,8 @@ For desired tables, the API layer generates names when a `DeltaTable` is lowered
544
566
  to a `DesiredTable`:
545
567
 
546
568
  - primary key: `{table}_pk`
547
- - foreign key: `{table}_{local_columns}_fk`
569
+ - foreign key: `{table}_{local_columns}_fk`, joining the local columns in
570
+ sorted order, so the name is independent of declaration order
548
571
 
549
572
  For observed tables, the reader adapter reads constraint names from the catalog.
550
573
  After that, names live on `PrimaryKeyConstraint` and `ForeignKeyConstraint`
@@ -604,22 +627,29 @@ dependency cost.
604
627
 
605
628
  ## Where to make changes
606
629
 
607
- | Change | Main location | Notes |
608
- | ------------------------------ | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
609
- | Add a new backend | `delta_engine.adapters` | Implement `CatalogStateReader` and `PlanExecutor`; keep backend exceptions inside the adapter. |
630
+ | Change | Main location | Notes |
631
+ | ------------------------------ | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
632
+ | Add a new backend | `delta_engine.adapters` | Implement `CatalogStateReader` and `PlanExecutor`; keep backend exceptions inside the adapter. |
610
633
  | Add a new change type | `delta_engine.domain.plan.changes` | Add a frozen dataclass with an `aspect` `ClassVar[TableAspect]` and an `actions()` method; add it to the `Change` union, then emit it from the relevant `_diff_*` helper in `delta_engine.domain.plan.diff`. If the change is currently unsupported, add a rule to `validation.py`. |
611
- | Add a new action type | `delta_engine.domain.plan` and adapter compiler | Define the action and phase in `actions.py`, emit it from the relevant change's `actions()` method, then compile it in the backend adapter. |
612
- | Add a safety rule | `delta_engine.application.validation` | Rules inspect the `TableDrift` changes and return `ValidationFailure` values. |
613
- | Add a data type | `delta_engine.domain.model.data_type` and adapter type mapping | The domain type is backend-free; SQL names and Spark parsing live in the Databricks adapter. |
614
- | Change public declarations | `delta_engine.api`, surfaced only through `delta_engine.schema` | Keep public ergonomics in `delta_engine.schema` and lower choices into domain snapshots before the engine phases begin. |
615
- | Change FK ordering or blocking | `delta_engine.application.dependency_resolution` | Cross-table dependency policy lives in the application layer, not in the domain plan or SQL compiler. |
616
- | Change report output | `delta_engine.application.report` and `delta_engine.application.rendering` | Keep display formatting out of domain objects. |
617
- | Change Databricks SQL | `delta_engine.adapters.databricks.sql` | Compile domain actions to backend statements at the adapter boundary. |
634
+ | Add a new action type | `delta_engine.domain.plan` and adapter compiler | Define the action and phase in `actions.py`, emit it from the relevant change's `actions()` method, then compile it in the backend adapter. |
635
+ | Add a safety rule | `delta_engine.application.validation` | Rules inspect the `TableDrift` changes and return `ValidationFailure` values. |
636
+ | Add a data type | `delta_engine.domain.model.data_type` and adapter type mapping | The domain type is backend-free; SQL names and Spark parsing live in the Databricks adapter. |
637
+ | Change public declarations | `delta_engine.api`, surfaced only through `delta_engine.schema` | Keep public ergonomics in `delta_engine.schema` and lower choices into domain snapshots before the engine phases begin. |
638
+ | Change FK ordering or blocking | `delta_engine.application.dependency_resolution` | Cross-table dependency policy lives in the application layer, not in the domain plan or SQL compiler. |
639
+ | Change report output | `delta_engine.application.report` and `delta_engine.application.rendering` | Keep display formatting out of domain objects. |
640
+ | Change Databricks SQL | `delta_engine.adapters.databricks.sql` | Compile domain actions to backend statements at the adapter boundary. |
618
641
 
619
642
  ## Architectural rules
620
643
 
621
644
  - Keep PySpark and Databricks imports inside `delta_engine.adapters`.
622
645
  - Keep the domain backend-free, immutable, and deterministic.
646
+ - Make immutability real, not conventional: frozen dataclasses copy their
647
+ collection fields in `__post_init__` — sequences into tuples, mappings into
648
+ read-only views (`MappingProxyType`), aspect sets into `frozenset` — so an
649
+ object cannot change after construction through a collection the caller
650
+ still holds. This applies at the public boundary too: `ForeignKey.columns`
651
+ and `Column.tags` copy what the user passed, so mutating the original
652
+ mapping later does not alter the declaration.
623
653
  - Put orchestration, safety policy, dependency resolution, and failure
624
654
  propagation in the application layer.
625
655
  - Put backend normalization at adapter boundaries, such as lowercasing catalog
@@ -52,15 +52,17 @@ unmanaged aspect has drifted. It never silently reconciles something a
52
52
  declaration didn't claim responsibility for, and it never silently ignores
53
53
  drift it isn't allowed to fix — the sync fails and tells you.
54
54
 
55
- There are two scopes:
55
+ There are three public scopes, selected by `DeltaTable`'s `scope` parameter:
56
56
 
57
- | Scope | Manages | Use for |
58
- | -------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
59
- | Full (the default) | Everything: columns, comments, properties, tags, partitioning, primary and foreign keys | Tables this declaration owns end to end |
60
- | `metadata_only=True` | Comments, tags, and key constraints only | Rolling out governance metadata with a hard guarantee that no schema change can slip in |
57
+ | Scope | Manages | Use for |
58
+ | ---------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
59
+ | `"full"` (the default) | Everything: columns, comments, properties, tags, partitioning, primary and foreign keys | Tables this declaration owns end to end |
60
+ | `"metadata"` | Comments, tags, and key constraints only | Rolling out governance metadata with a hard guarantee that no schema change can slip in |
61
+ | `"tags"` | Table and column tags only | Tag governance for tables owned elsewhere, e.g. by a streaming pipeline |
61
62
 
62
63
  See [how to deploy metadata only](how-to-deploy-metadata-only.md) for the
63
- restricted scope in practice.
64
+ metadata scope in practice, and [tags](how-to-configure-table.md#manage-tags-only)
65
+ for tag-only declarations.
64
66
 
65
67
  ## Cross-table dependency blocking
66
68
 
@@ -18,6 +18,7 @@ partitioned. This page is the practical reference for configuring each aspect.
18
18
  | Primary keys | The table's primary key | [Primary keys](#primary-keys), below |
19
19
  | Foreign keys | Cross-table references and sync ordering | [Foreign keys](#foreign-keys), below |
20
20
  | Partitioning | Partition columns, fixed at creation | [Partitioning](#partitioning), below |
21
+ | Clustering | Liquid clustering keys, reconciled in place | [Clustering](#clustering), below |
21
22
 
22
23
  ## Properties
23
24
 
@@ -100,7 +101,8 @@ orders = DeltaTable(
100
101
  catalog="prod",
101
102
  schema="sales",
102
103
  name="orders",
103
- columns=[Column("id", Integer(), nullable=False, primary_key=True)],
104
+ columns=[Column("id", Integer(), nullable=False)],
105
+ primary_key=["id"],
104
106
  properties={
105
107
  Property.CHANGE_DATA_FEED: "true", # ensure it is set
106
108
  Property.LOG_RETENTION_DURATION: None, # ensure it is absent
@@ -223,6 +225,36 @@ the next sync unless it is also declared.
223
225
 
224
226
  As with table tags, keys are **case-sensitive** (`PII` and `pii` are distinct).
225
227
 
228
+ ### Manage tags only
229
+
230
+ Use `scope="tags"` when the table is owned elsewhere — for example by a
231
+ streaming pipeline — but you still want delta-engine to reconcile Unity
232
+ Catalog tags. The declaration carries the same table shape as any other, but
233
+ only table tags and column tags are managed: columns, comments, properties,
234
+ partitioning, primary keys, and foreign keys are never changed.
235
+
236
+ ```python
237
+ from delta_engine.schema import Column, DeltaTable, String
238
+
239
+ events = DeltaTable(
240
+ catalog="dev",
241
+ schema="silver",
242
+ name="streaming_events",
243
+ columns=[
244
+ Column("id", String()),
245
+ Column("email", String(), tags={"pii": "true"}),
246
+ ],
247
+ tags={"domain": "events"},
248
+ scope="tags",
249
+ )
250
+ ```
251
+
252
+ The live table must already exist. If a non-tag aspect drifts from the
253
+ declaration, validation fails before any tag SQL runs; update the declaration
254
+ to match the live table or use the full scope. Properties are the exception:
255
+ a restricted scope never compares them, so live table properties cannot fail
256
+ the sync.
257
+
226
258
  ### Requirements and limits
227
259
 
228
260
  Column tags require Unity Catalog on Databricks Runtime 13.3 LTS or later and the
@@ -270,8 +302,9 @@ drift that the sync overwrites.
270
302
 
271
303
  ## Primary keys
272
304
 
273
- Declare a primary key by setting `primary_key=True` on one or more columns.
274
- Every primary key column must be non-nullable.
305
+ Declare a primary key by passing `primary_key` to `DeltaTable` a list of
306
+ column names, in the order the constraint is rendered. Every primary key
307
+ column must be non-nullable.
275
308
 
276
309
  ```python
277
310
  from delta_engine.schema import Column, DeltaTable, Integer, String
@@ -281,10 +314,11 @@ orders = DeltaTable(
281
314
  schema="silver",
282
315
  name="orders",
283
316
  columns=[
284
- Column("order_id", Integer(), nullable=False, primary_key=True),
317
+ Column("order_id", Integer(), nullable=False),
285
318
  Column("customer_id", Integer(), nullable=False),
286
319
  Column("status", String()),
287
320
  ],
321
+ primary_key=["order_id"],
288
322
  )
289
323
  ```
290
324
 
@@ -295,8 +329,8 @@ exposed on the table object.
295
329
 
296
330
  ### Composite primary keys
297
331
 
298
- Set `primary_key=True` on several columns. The constraint covers them in
299
- declaration order.
332
+ List several column names in `primary_key`. The constraint covers them in
333
+ that order.
300
334
 
301
335
  ```python
302
336
  order_items = DeltaTable(
@@ -304,10 +338,11 @@ order_items = DeltaTable(
304
338
  schema="silver",
305
339
  name="order_items",
306
340
  columns=[
307
- Column("order_id", Integer(), nullable=False, primary_key=True),
308
- Column("line_number", Integer(), nullable=False, primary_key=True),
341
+ Column("order_id", Integer(), nullable=False),
342
+ Column("line_number", Integer(), nullable=False),
309
343
  Column("product_id", Integer(), nullable=False),
310
344
  ],
345
+ primary_key=["order_id", "line_number"],
311
346
  )
312
347
  ```
313
348
 
@@ -315,8 +350,8 @@ order_items = DeltaTable(
315
350
 
316
351
  A primary key identifies a row, so a nullable key column is not a well-formed
317
352
  table definition — and Databricks rejects a nullable primary key at execution
318
- time regardless. The engine enforces this early: declaring `primary_key=True`
319
- on a nullable column raises `ValueError` when the `DeltaTable` is constructed,
353
+ time regardless. The engine enforces this early: naming a nullable column in
354
+ `primary_key` raises `ValueError` when the `DeltaTable` is constructed,
320
355
  before any sync runs.
321
356
 
322
357
  ### Constraints are informational
@@ -354,9 +389,8 @@ table with the original error. See
354
389
 
355
390
  ## Foreign keys
356
391
 
357
- Pass `foreign_keys` with one `ForeignKey` per constraint. Each names the local
358
- columns and the table they reference; the referenced columns are inferred from
359
- that table's primary key.
392
+ Pass `foreign_keys` with one `ForeignKey` per constraint. `columns` maps each
393
+ local column to the referenced-table primary-key column it references.
360
394
 
361
395
  ```python
362
396
  from delta_engine.schema import Column, DeltaTable, ForeignKey, Long, String
@@ -366,9 +400,10 @@ customers = DeltaTable(
366
400
  schema="silver",
367
401
  name="customers",
368
402
  columns=[
369
- Column("id", Long(), nullable=False, primary_key=True),
403
+ Column("id", Long(), nullable=False),
370
404
  Column("name", String()),
371
405
  ],
406
+ primary_key=["id"],
372
407
  )
373
408
 
374
409
  orders = DeltaTable(
@@ -376,22 +411,23 @@ orders = DeltaTable(
376
411
  schema="silver",
377
412
  name="orders",
378
413
  columns=[
379
- Column("order_id", Long(), nullable=False, primary_key=True),
414
+ Column("order_id", Long(), nullable=False),
380
415
  Column("customer_id", Long(), nullable=False),
381
416
  Column("status", String()),
382
417
  ],
418
+ primary_key=["order_id"],
383
419
  foreign_keys=[
384
- ForeignKey(local_columns=["customer_id"], references=customers),
420
+ ForeignKey(columns={"customer_id": "id"}, references=customers),
385
421
  ],
386
422
  )
387
423
  ```
388
424
 
389
425
  Referencing the target `DeltaTable` object — rather than a dotted table name —
390
- is what lets the engine infer the referenced columns from that table's primary
426
+ is what lets the engine validate the mapping against that table's actual primary
391
427
  key, and keeps the reference valid if the target is renamed. The constraint
392
- name is generated at lowering as `{table}_{local_columns}_fk`
393
- (`orders_customer_id_fk` above). The name cannot be chosen, and drift matching
394
- never depends on it — a foreign key created outside the engine under a
428
+ name is generated at lowering as `{table}_{local_columns}_fk`, joining the
429
+ local columns in sorted order (`orders_customer_id_fk` above). The name cannot
430
+ be chosen, and drift matching never depends on it — a foreign key created outside the engine under a
395
431
  different name still matches by content.
396
432
 
397
433
  Generated names join local columns with underscores, so two foreign keys over
@@ -418,20 +454,20 @@ employees = DeltaTable(
418
454
  schema="silver",
419
455
  name="employees",
420
456
  columns=[
421
- Column("id", Long(), nullable=False, primary_key=True),
457
+ Column("id", Long(), nullable=False),
422
458
  Column("manager_id", Long()),
423
459
  ],
460
+ primary_key=["id"],
424
461
  foreign_keys=[
425
- ForeignKey(local_columns=["manager_id"], references=Self),
462
+ ForeignKey(columns={"manager_id": "id"}, references=Self),
426
463
  ],
427
464
  )
428
465
  ```
429
466
 
430
467
  ### Composite foreign keys
431
468
 
432
- For a composite primary key, list `local_columns` in the referenced table's
433
- primary-key declaration order. The referenced columns are inferred one-to-one
434
- in that same order.
469
+ For a composite primary key, map each local column to the primary-key column
470
+ it references.
435
471
 
436
472
  ```python
437
473
  customer_accounts = DeltaTable(
@@ -439,9 +475,10 @@ customer_accounts = DeltaTable(
439
475
  schema="silver",
440
476
  name="customer_accounts",
441
477
  columns=[
442
- Column("tenant_id", Long(), nullable=False, primary_key=True),
443
- Column("id", Long(), nullable=False, primary_key=True),
478
+ Column("tenant_id", Long(), nullable=False),
479
+ Column("id", Long(), nullable=False),
444
480
  ],
481
+ primary_key=["tenant_id", "id"],
445
482
  )
446
483
 
447
484
  order_lines = DeltaTable(
@@ -449,20 +486,24 @@ order_lines = DeltaTable(
449
486
  schema="silver",
450
487
  name="order_lines",
451
488
  columns=[
452
- Column("order_line_id", Long(), nullable=False, primary_key=True),
489
+ Column("order_line_id", Long(), nullable=False),
453
490
  Column("tenant_id", Long(), nullable=False),
454
491
  Column("customer_id", Long(), nullable=False),
455
492
  ],
493
+ primary_key=["order_line_id"],
456
494
  foreign_keys=[
457
495
  ForeignKey(
458
- # aligns with customer_accounts PK (tenant_id, id)
459
- local_columns=["tenant_id", "customer_id"],
496
+ columns={"tenant_id": "tenant_id", "customer_id": "id"},
460
497
  references=customer_accounts,
461
498
  ),
462
499
  ],
463
500
  )
464
501
  ```
465
502
 
503
+ The mapping states which primary-key column each local column references;
504
+ its order never matters, and a mapping that does not cover the referenced
505
+ table's primary key exactly fails when the `DeltaTable` is constructed.
506
+
466
507
  ### Dependency ordering
467
508
 
468
509
  A foreign key can only be added once its referenced table exists with the
@@ -573,3 +614,57 @@ Partition columns also cannot be of complex type (`Array`, `Map`, `Struct`,
573
614
  rejected when the `DeltaTable` is constructed. See
574
615
  [safe-change rules](reference-safe-change-rules.md) for the full set of changes
575
616
  the engine rejects.
617
+
618
+ ## Clustering
619
+
620
+ Declare Delta liquid clustering keys with the `clustered_by` argument — a
621
+ table-level list of column names, the same shape as `partitioned_by`:
622
+
623
+ ```python
624
+ from delta_engine.schema import Column, DeltaTable, String
625
+
626
+ events = DeltaTable(
627
+ catalog="dev",
628
+ schema="silver",
629
+ name="events",
630
+ columns=[
631
+ Column("region", String()),
632
+ Column("event_type", String()),
633
+ ],
634
+ clustered_by=["region"],
635
+ )
636
+ ```
637
+
638
+ `DeltaTable.clustered_by` exposes the declared tuple of clustering column
639
+ names, in declaration order. Key order does not matter — Delta clusters by the
640
+ key set — so reordering the keys is never treated as drift.
641
+
642
+ A table cannot declare both `partitioned_by` and `clustered_by` — Delta
643
+ supports one physical layout strategy per table — and a declaration is
644
+ limited to four clustering keys. Both are rejected when the `DeltaTable` is
645
+ constructed. See
646
+ [limitations](reference-limitations.md) for the unsupported key types.
647
+
648
+ ### Clustering is reconciled in place
649
+
650
+ Unlike partitioning, clustering keys are not fixed at creation. The engine
651
+ changes the key set with `ALTER TABLE ... CLUSTER BY (...)` (or `CLUSTER BY
652
+ NONE` to remove clustering) whenever the declaration changes — no table
653
+ recreation required.
654
+
655
+ The `ALTER` is a metadata change: it sets the _target_ clustering keys but
656
+ does not rewrite existing data, so it stays cheap regardless of table size.
657
+ Liquid clustering still lays data out physically — it co-locates rows within
658
+ files rather than in partition directories — but existing files keep their
659
+ old clustering until a later `OPTIMIZE` (or `OPTIMIZE FULL` to recluster the
660
+ whole table) rewrites them. This is why partitioning is blocked while
661
+ clustering is not: changing partition columns would mean physically rewriting
662
+ every data file up front. See
663
+ [safe-change rules](reference-safe-change-rules.md) for the full contrast.
664
+
665
+ ### Drift
666
+
667
+ The engine compares clustering keys by _set_, not by order: declaring the
668
+ same keys in a different order is not drift and plans nothing. This mirrors
669
+ primary keys, and is unlike partitioning, where order is a physical layout
670
+ decision.