docs: spec generating a DeltaTable declaration from a live table - #309
Open
Tomoscorbin wants to merge 5 commits into
Open
docs: spec generating a DeltaTable declaration from a live table#309Tomoscorbin wants to merge 5 commits into
Tomoscorbin wants to merge 5 commits into
Conversation
Records the design for `delta-engine generate CATALOG.SCHEMA.TABLE`: raise an ObservedTable into a DeltaTable beside _lower_declaration, then render it as an importable module verified by the existing diff_table oracle. Also records why the nine translation sites are not consolidated into a serialisation layer — they share a verb rather than logic, and such a package has no legal position in the hexagonal layer graph.
Nine TDD tasks from DeltaTable.scope through to live verification, each with runnable test code and its own commit. Four facts were checked against the installed toolchain rather than assumed while writing: Click 8.4 removed CliRunner's mix_stderr (stderr is captured separately by default), ReadError carries its message via Exception rather than a .message attribute, tests/live auto-marks by directory and exposes live_catalog()/live_schema() as functions rather than fixtures, and Struct accepts a list so generated source needs no tuple syntax.
Task 1 added a DeltaTable.scope property so the renderer could emit scope= for a restricted declaration. Drop it: every ordinary table — the case the on-ramp exists for — already wants the "full" default, so the property would exist to be read back by one branch of one renderer. Streaming tables are the one case the default gets wrong. StreamingTableAnnotationsOnly is an eligibility check, so it runs unconditionally and judges claimed aspects rather than drift: a generated streaming-table module diffs clean and then fails validation. That is now carried the same way as the foreign-key trap — a stderr warning naming the check, a commented scope="annotations" line in the source, and a test pinning exactly that one failure. Also corrects the inversion table, which predated #310 and still said a streaming table is forced to scope="tags"; "annotations" is the widest scope validation admits, and keeps comment management.
The design claimed no foreign key can cross into a single-table module.
That is false for a key onto its own table: Self is public, and
ForeignKey.columns takes a Mapping, so ForeignKey(columns={...},
references=Self) is expressible today.
The practical consequence was the warning's suggested fix. For a
self-referencing key it named the module variable, which is not bound
until the constructor call it sits inside returns — advice the reader
cannot follow. It now suggests Self for that case.
v1 still warns rather than rendering the key; that stays the accepted
trap, and the closure remains the follow-on. Only the false claim and the
uncompilable suggestion are corrected.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Design only — no code. Records the design for
delta-engine generate CATALOG.SCHEMA.TABLE, which reads one live Unity Catalog table and prints an importableDeltaTabledeclaration.Why
The engine has no adoption on-ramp. Bringing an existing table under management means hand-transcribing its catalog state — every column, type, nullability, comment, tag, property, and key.
Transcription errors are punished asymmetrically. The engine owns the full column set and the full key set, so an omission is not a smaller declaration but a destructive one: a column left out becomes
DropColumn, a key left out becomesDropForeignKey. Some of this is caught —ColumnMappingRequiredForDropblocks a column drop without column mapping — but nothing blocks dropping a constraint. Hand transcription is therefore most dangerous on exactly the tables that most need adopting: the large, old, key-bearing ones.The design: raise, then render
The translation is half-built already.
_lower_declaration(api/delta_table.py:550) is exactlyDeltaTable -> DesiredTable; nothing goes the other way. And the public surface is close to round-trippable: everyDeltaTable.__init__parameter exceptscopehas a matching read-only property, andObservedColumn's fields are a subset ofDesiredColumn's.raise_declarationvalidates by construction — ifDeltaTable(...)builds, the emitted source imports.render_declarationaccepts anyDeltaTable, so it also normalises hand-written declaration modules.The correctness oracle already exists too: a generated declaration is correct if and only if
diff_table(generated.to_desired_table(), observed)is empty. That is the primary test, not a nice-to-have.Foreign keys: a knowingly accepted trap
ForeignKey.referencesholds aDeltaTableobject, not a name, so a single-table module cannot construct one. And omission is not free —_diff_foreign_keys(domain/plan/diff.py:518) has no unmanaged branch, so an undeclared key becomesDropForeignKey. No scope avoids this:METADATA_ASPECTSincludesFOREIGN_KEYS, and out-of-scope drift fails validation rather than being ignored.Decision: emit the keys as a commented block with a warning naming the constraint that planning would drop. The module stays importable and immediately plan-able; the cost is that planning it as written drops the keys until the user wires them up.
Three defences, and the spec is explicit that none of them stops a determined
>redirect: the comment block, a stderr warning (which is whygenerate_modulereturnssourceandwarningsseparately rather than a barestr), and a test pinning that a generated FK-bearing table produces exactly thoseDropForeignKeyactions and nothing else.The alternative that produces a clean-planning module — following the references and emitting the closure — is recorded as the natural follow-on alongside schema-wide discovery, where the closure is already being read.
Why there is no serialisation layer
Recorded because the question will recur: the codebase has nine translation sites and they look like they belong together.
Four wire formats, five target languages, and no two sites share both endpoints. The single true inverse pair is
render_data_type/data_type_from_json, already co-located in one file for that reason. What the nine share is a verb, not logic.A shared
serialisation/package also has no legal position. The layer contract (pyproject.toml:205) iscli -> databricks | schema | adapters | api -> application -> domain,exhaustive = true; a package importable by all of them sits at or belowdomain, which would put Databricks DDL rendering and AS JSON parsing below the domain — inverting the hexagon thatbackend-imports-stay-in-adaptersexists to protect. It would worsen locality too: changing Delta property management today touchesapplication/properties.pyand its callers, with policy and meaning together.to_x/from_xmethods on domain classes are worse — they weld Databricks vocabulary onto a deliberately format-neutral domain (the Iceberg direction), and they invert the expression-problem trade-off:DataTypeis a closed set of 17 while the operations are what grows, so methods mean every new format edits all 17 classes.sql/types.py:12-19already reasons about exactly this criterion.What the repo actually does is visible in
diff_entries.py: extract the shared interpretation, leave each consumer its own emission. Codegen shares only a verb withrender_data_type, but shares an interpretation with_lower_declaration— which is why the raise lands beside the lowering.Two numbers, verified rather than estimated
The vocabulary-pin test depends on them:
schema.__all__has 23 names, andDataTypehas 17 concrete subclasses — matchingrender_data_type's 17matcharms exactly, so that renderer is currently exhaustive.Location
The brainstorming skill writes specs to
docs/superpowers/specs/, which is gitignored here (.gitignore:47, zero files tracked). Placed indocs/todo/instead, where this project's committed design docs live and wheretodo.mdlinks them, with a matching backlog entry.Verification
Docs-only; no
src/ortests/change, so the code gates are unexercised and I make no claim about them.docs/todo/is excluded from the Sphinx build (docs/conf.py:81), and the only pre-commit hook is commitizen oncommit-msg, so no linter applies to this file.What was checked: every
file:linecitation in the spec was read rather than recalled, and the two counts above were computed against the installed package.Implementation plan
docs/todo/2026-07-30-catalog-to-declaration-codegen-plan.md— nine TDD tasks, each with runnable test code and its own commit.DeltaTable.scopescope="metadata"declaration intoscope="full"render_data_type_source+ the exhaustiveness pinrender_declarationraise_declarationgenerate_modulebuild_sql_readergeneratecommandreference-cli.md, README, design-doc correctionFour things checked rather than assumed
Writing runnable test code surfaced four facts that would each have cost an implementation cycle:
CliRunner(mix_stderr=...)— the signature is now(charset, env), and stdout/stderr are captured separately by default. Four call sites in the draft plan would have failed withTypeError.ReadErrorhas no.message— it passes the message toException.__init__and keeps only.exception_type. The CLI error path usesstr(error).tests/live/auto-marks by directory viapytest_collection_modifyitems, so nopytestmarkis needed;live_catalog()/live_schema()are plain functions rather than fixtures, andqualified_table()returns a dotted string that cannot be passed tofetch_state.Structaccepts a list and normalises to a tuple in__post_init__, so generated source can emitStruct([StructField(...)])without tuple syntax.One deviation from the design
The command goes in
cli/app.py, not a newcli/generate.py. That is where the Typer app and its shared_anticipated_errors/_engine_logginghelpers live, andtests/cli/conftest.pyalready monkeypatches names oncli_app; a separate module would force a second patch target for no benefit. Task 8 corrects the design doc's file table so the two do not disagree.Not carried over
The design lists a golden-file test. The plan discharges it differently and says so: Task 3 asserts the complete rendered text character-for-character and Task 5 pins byte-identical regeneration, so a format change already fails a diff-reviewable assertion. Task 5 additionally runs
ruff check --isolatedover real generated output, since generated code a formatter immediately rewrites is a defect.