Declare every platform-managed column through the DataJoint type system - #1565
Open
dimitri-yatsenko wants to merge 10 commits into
Open
dimitri-yatsenko wants to merge 10 commits into
dimitri-yatsenko wants to merge 10 commits into
Conversation
`_prov` was declared by hand-written SQL in each adapter -- `json` for MySQL, `jsonb` for PostgreSQL -- which restates a mapping `core_type_to_sql` already owns. The `_singleton` injection a few lines away in declare.py was the better precedent all along: it asks the adapter for the SQL type and formats the column through `format_column_definition`. One `provenance.column_definition(adapter)` now does that, used by both declare.py and deploy.py. Three consequences: - `provenance_columns` disappears from all three adapters, the abstract method included. That method made any out-of-tree adapter uninstantiable -- raised in review of #1555 -- and deleting it is a better answer than a default. - A new backend inherits the column by implementing `core_type_to_sql`, which it has to implement anyway. There is no longer a per-adapter string to forget. - The declared type is recorded in the column comment, so `heading` reads it back as `original_type`: `json` on both backends, where the hand-written form left it None. That is what jobs.py and describe() consume, and what a reader for hidden attributes (#1562) will need to decode consistently. Also closes two more review points from #1555, both in the path this touches: - `add_prov_column` counted `tables_modified` and `columns_added` on a dry run. They now count work actually done, matching set_replica_identity; a dry run reports through `ddl` and `details`. - `add_prov_column` left the cached heading stale, so `_prov` stayed invisible to the insert path and every insert silently recorded nothing until the process reconnected. It now invalidates the heading, and the test no longer calls `_init_from_database()` by hand -- that call was what hid the defect. 681 passed, 14 skipped.
Completes the change for the remaining hidden attributes. `_singleton` already
went through `core_type_to_sql` + `format_column_definition`, and `_prov` does
as of the previous commit; the three `_job_*` columns were the last hand-written
SQL, restating per adapter a backend mapping the type system owns.
This corrects a real discrepancy, not only duplication. The hand-written
PostgreSQL column was a bare `timestamp`, which takes PostgreSQL's default
microsecond precision, while MySQL got `datetime(3)`. Confirmed against the
catalog:
with the fix ('_job_start_time', 'timestamp without time zone', 3)
hand-written form ('t', 'timestamp without time zone', 6)
`core_type_to_sql("datetime(3)")` yields `timestamp(3)`, so the two backends now
declare the same precision -- the one `reference/specs/job-metadata.md` has been
documenting all along. Closes #1566.
`job_metadata_columns` is removed from all three adapters, the abstract method
included, so an out-of-tree adapter needs neither it nor `provenance_columns`.
`original_type` is restored as a side effect (`datetime(3)`, `float32`,
`varchar(64)` on both backends). That is cosmetic -- nothing reads it for a
hidden attribute -- but it comes free with the path.
The adapter tests are rewritten against the new entry point rather than deleted;
they were the only place pinning this DDL, and the PostgreSQL one now asserts
`timestamp(3)` explicitly so the precision cannot regress silently.
Note for later: migrate.py:599 holds a fourth hand-written copy, used by the
retrofit path. It is MySQL-only already (its ALTER is backtick-quoted) and the
module is deprecated for removal in 2.4/2.5, so it is left alone here.
681 passed, 14 skipped.
_prov through the DataJoint type systemPlatform columns are now written the way a user writes an attribute, and
appended to the table definition before it is parsed. The ordinary machinery
then does everything -- backend type mapping, the `:type:` comment, and the
column-comment bookkeeping PostgreSQL needs for its out-of-line COMMENT ON.
No Python construction, and no adapter method.
_job_start_time = null : datetime(3) # when computation began
_job_duration = null : float32 # computation duration in seconds
_job_version = "" : varchar(64) # code version
_prov = null : json # extrinsic provenance ...
_singleton = 1 : bool # singleton primary key
Grammar and policy are separated to make that possible. The attribute grammar
accepts a leading underscore, so the framework can spell its own columns; a
*user* declaring one is refused by `_reject_user_hidden_attributes`, called from
declare() on the user's definition before anything is appended. That ordering is
the whole mechanism: the guard never sees the framework's lines.
Placement matters and is not uniform:
- Job metadata and provenance are secondary, so they follow every user
attribute. A `---` is inserted when the definition has none, because
otherwise a table whose attributes are all primary key takes a nullable
hidden column as a nullable key attribute and is rejected.
- `_singleton` is the exception: it *is* the primary key, so it goes into the
key section of a table that declares none of its own. Detection is by the
absence of attribute or foreign-key lines ahead of the separator, so a
leading table comment does not mask it.
The test for the underscore ban now exercises the user-facing path rather than
compile_attribute, which deliberately accepts these names. It gains coverage for
per-tier placement -- Entry gets `_prov`, Computed and Imported get job
metadata, Lookup, parts and job tables get neither.
deploy.add_prov_column no longer reaches into the in-process heading cache. A
Heading is memoized from the database, and refreshing it meant poking private
state and guessing which class to poke; a deploy operation runs before the
workers that write through it, as set_replica_identity does. The requirement is
documented instead, and the test reloads the way a deployment would.
681 passed, 14 skipped.
…iers
Review follow-ups, each removing something that was restating what the library
already knows.
`SINGLETON_DEFINITION`, `JOB_METADATA_DEFINITION` and `PROV_DEFINITION` now sit
together in declare.py. Declaration is what they are for, and declare.py already
decides which tier receives which -- keeping the "what" beside the "when" also
drops two deferred imports that existed only to fetch them.
`_append_platform_attributes` tests tiers with `Computed.tier_regexp` and
`Imported.tier_regexp`, as the provenance branch already did with
`Manual.tier_regexp`, instead of hand-rolled prefix arithmetic:
is_computed = table_name.startswith("__") and "__" not in table_name[2:]
is_imported = table_name.startswith("_") and not table_name.startswith("__")
That is the construction that let job tables acquire `_prov` once already, and
it miscategorises any tier added later. Each `tier_regexp` also excludes parts
by construction, since a part's name carries its master's and fails the
master's own pattern.
`provenance.column_definition` is gone. It was a wrapper around
`compile_attribute`, and deploy.add_prov_column now calls that directly -- the
same path that declares the column on a new table, so a retrofitted column is
identical to a freshly declared one. Verified on both backends:
retrofitted type='jsonb' original_type='json'
declared type='jsonb' original_type='json'
Reaching for it surfaced a real gap: the comment was discarded. PostgreSQL
stores column comments out of line, so a retrofitted column carried no `:type:`
marker and read back with no original_type, where a declared one had it.
add_prov_column now emits the COMMENT ON alongside the ALTER.
`_reject_user_hidden_attributes` drops four guards that could never fire: a
blank line, a comment, `---` and a foreign key all fail `startswith("_")`
already, so skipping them first changed nothing.
681 passed, 14 skipped.
The tier test was a closure over `table_name` defined inside `_append_platform_attributes`, with a docstring narrating how job tables once acquired `_prov` -- an incident the code no longer has. `is_tier(table_name, tier)` now lives in user_tables.py beside the tier classes it tests, takes both arguments explicitly, and carries one line of docstring. Adopted at every boolean tier test in the library, replacing either a bare `re.fullmatch(X.tier_regexp, name)` or hand-rolled prefix arithmetic: - declare.py -- which tier receives which platform attribute - deploy.py -- which tables add_prov_column touches - schemas.py -- master lookup and the Part test - user_tables.py -- _get_tier itself - migrate.py -- _is_autopopulated_table, which had carried its own copy of the prefix arithmetic, including the `"__" not in table_name[2:]` part-exclusion that each tier_regexp already does by construction What remains using `tier_regexp` directly is the definition of `is_tier` and one `groupdict()` in schemas.py, which extracts the match rather than testing it. deploy.py no longer needs `re` at all. 681 passed, 14 skipped.
DataJoint's parser leaves the key section at the first `---` and ignores every later one, so `_append_platform_attributes` need not find where the section boundary is before adding secondary attributes behind it. Only `_singleton` still needs the position, because it belongs in the key section rather than after it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`migrate.add_job_metadata_columns` was the last place a platform-managed column was built from a hand-written SQL string. It emitted backtick-quoted identifiers and `datetime(3)`, so on PostgreSQL it raised a syntax error before the wrong type could matter, and on MySQL it produced a column with no `:type:` marker -- the same column a declaration gives, minus its `original_type`. It now compiles `JOB_METADATA_DEFINITION` through `compile_attribute` and quotes through the adapter, as `deploy.add_prov_column` already does, with the PostgreSQL `COMMENT ON` alongside. New tests declare the same Computed table twice -- once with the columns, once without them and then migrated -- and assert the catalog cannot tell the two apart, on both backends. Without the fix, three of the six fail on PostgreSQL and the type comparison fails on MySQL. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_append_platform_attributes` read the key section to tell whether a table declared a primary key of its own, treating every non-blank, non-comment, non-`---` line as an attribute. An `index (...)` line is neither, so a definition carrying one in the key section was read as having a primary key, got no `_singleton`, and reached the server as `PRIMARY KEY ()`. Only the parse knows what a line contributed -- `index (...)` adds no attribute, `-> Parent` may add several -- so `prepare_declare` now adds the sentinel when the primary key it produced is empty. The text no longer has to be second-guessed, and `_append_platform_attributes` drops both the line classifier and the separator lookup, leaving it with the one question it can answer from the table name: which tier gets which secondary column. `alter()` is unaffected: it compares two `prepare_declare` results, and a singleton table's own definition and its `describe()` round trip both yield an empty primary key, so both sides gain the sentinel alike. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`declare()` ran three passes over the definition before it was parsed: scan the text for a user-declared underscore, splice the tier's hidden lines in behind a `---`, then parse the result. Each pass had to infer from the text what only the parse knows. `prepare_declare` now does all of it. The platform's attributes are compiled after the loop, through the same `compile_attribute` that handled every user line, and placed directly into the parse: `_singleton` first when the primary key came back empty, the tier's columns appended after it. Three things fall out: - The `---` splice is gone. A nullable column lands in the key section only when it is parsed there, and these are never parsed from the definition at all. - The loop iterates user lines only, so that is where a user-declared hidden attribute is refused -- on the name the grammar produced rather than on a text prefix. `_reject_user_hidden_attributes` and its separate pass go. - `_append_platform_attributes` goes with it; what remains of it is `_tier_attributes`, which answers only the question it can: which lines a tier receives. `alter()` passes no table name and so grows no tier attributes, which is what keeps its two definitions comparable on equal terms. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit moved the platform attributes into `prepare_declare` but gave them their own compile-and-record helper, duplicating what the loop's attribute branch already did. Both now call one `add_attribute`, and the platform block is three lines. The underscore check becomes a branch of the loop rather than a test on the compiled name. Blanks, comments, `---`, foreign keys and indexes are each dispatched above it, so by the time a line reaches it the only thing a leading underscore can be is a user-declared hidden attribute. `_tier_attributes` returns early instead of accumulating: a table is Computed, Imported or Manual, never two of them. Net 22 lines shorter than before the consolidation began. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This branch has not been deployed
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.
Follow-up to #1555, and a response to the review items left open there. Net 18 lines removed.
Why
_provwas declared by hand-written SQL, once per adapter:That restates a mapping the adapter already owns —
core_type_to_sql("json")returns exactlyjsonandjsonb. The better precedent was a few lines away in the same function: the_singletoninjection, which is also a declaration-time hidden column and goes through the type system.I followed
job_metadata_columnsinstead, which does the same hand-written thing. Wrong precedent.What changes
One function, used by both
declare.pyanddeploy.py:provenance_columnsis deleted from all three adapters, the@abstractmethodincluded.A new backend needs nothing added — it inherits the column by implementing
core_type_to_sql, which it must implement anyway.original_typeis now recorded. Verified on both backends:Note PostgreSQL: the SQL type is
jsonbbutoriginal_typeis the DataJoint namejson. The hand-written form left itNone.Correction to an earlier version of this description. I claimed this is what
jobs.py:210andtable.py:1400consume and what Public access to hidden attributes (_job_*, _prov): no reader exists #1562's reader will need. Both are wrong:jobs.py:210iteratestarget_pk, and hidden attributes are never in the primary key;table.py:1400is insidedescribe(), which iteratesheading.attributeswith hidden excluded. And decoding does not useoriginal_typeat all —json,uuid,numericanddtypeare set from the SQL type atheading.py:499-503, so a hidden attribute decodes correctly with or without the comment.The comment comes free with the type-system path and makes introspection consistent. It is not load-bearing. The arguments that are: deleting the abstract method, removing the per-adapter duplication, and the backend mapping living in one place.
Review items from #1555 closed here
@ttngu207 — three of the seven, each in the path this touches:
provenance_columnsas a new@abstractmethodmade any out-of-tree adapter uninstantiable. You suggested a defaultreturn []; deleting the method is better, and this does that.set_replica_identitynext door. They now count work actually done; a dry run reports throughddlanddetails. The test assertscolumns_added == 0on a preview.add_prov_columnleft the cached heading stale. You were right that the manual_init_from_database()in the test was hiding it:_provstayed invisible to the insert path and every insert silently recorded nothing until the process reconnected.add_prov_columnnow invalidates the heading, and the test calls no refresh of its own — it forces the lazy reload the same way_has_prov_attributedoes, so it exercises the real path.Still open from your review, not in this PR
git rev-parse— ~1.9 ms per insert and per_populate1, and I made it unconditional inautopopulate.pywhere the old path only paid it on success. It belongs in the populate hot path, not here; worth its own PR so the perf change is reviewable on its own.contextvarsdo not propagate intoThreadPoolExecutorworkers, so a fan-out insidemake()losescontextsilently. Documentation, not a code fix — queued for the spec.to_arrays()can't read the hidden job-metadata attributes thatjob-metadata.mddocuments #1553 — superseded by Public access to hidden attributes (_job_*, _prov): no reader exists #1562, which covers_job_*and_provtogether, with Mapping restriction naming a hidden attribute silently drops the predicate #1561 folded in.Verification
original_typechecked on both backends, since that is the behavior change rather than a refactor.test_declare.pyrun alongside, because_singletongoes through the sameformat_column_definitionpath this now shares.ruff,ruff-format,codespell,mypypass via pre-commit.Extended:
_job_*too (5e83a56)Following review discussion, this now covers every platform-managed column, not just
_prov._singletonalready used the type system;_provdoes as of the first commit; the three_job_*columns were the last hand-written SQL.It fixes a real defect, not only duplication. The hand-written PostgreSQL column was a bare
timestamp, which takes PostgreSQL's default microsecond precision, while MySQL gotdatetime(3). Confirmed against the catalog rather than inferred:core_type_to_sql("datetime(3)")yieldstimestamp(3), so both backends now declare the precision thatreference/specs/job-metadata.mdhas been documenting all along. Closes #1566 and #1567.job_metadata_columnsis deleted from all three adapters alongsideprovenance_columns, so an out-of-tree adapter needs neither.The two adapter tests are rewritten against the new entry point rather than deleted — they were the only place pinning this DDL, and the PostgreSQL one now asserts
timestamp(3)explicitly so the precision cannot regress silently.Extended: the parser half, and the retrofit path
Two later commits finish what the description above called out of scope.
The grammar now spells a hidden attribute; policy forbids a user writing one.
attribute_nametakes[a-z_]as its first character class, and the underscore check moved out ofcompile_attribute— which the framework itself calls — into_reject_user_hidden_attributes, run fromdeclare()before anything is appended. That is the single point a user's definition enters the system, so there is noallow_reservedflag to thread through and forget. The user-facing error is unchanged, andtests/unit/test_declare_hidden_attribute.pynow pins that it fires from a definition string rather than only fromcompile_attributedirectly.With the grammar open, the five platform columns live in
declare.pyas ordinary DataJoint lines and are spliced into the definition before it is parsed:migrate.add_job_metadata_columnsis fixed rather than left alone. The earlier description argued it was out of scope because the module is deprecated and theALTERis MySQL-only. That reasoning was backwards: being MySQL-only is the defect, not a reason to leave it. Backtick-quoted identifiers are a syntax error on PostgreSQL, so the retrofit had never run there at all, anddatetime(3)is not a PostgreSQL type. It now compilesJOB_METADATA_DEFINITIONthroughcompile_attributeand quotes through the adapter, asdeploy.add_prov_columnalready does, with theCOMMENT ONalongside.tests/integration/test_migrate_job_metadata.pydeclares the same Computed table twice — once with the columns, once without them and then migrated — and asserts the catalog cannot tell the two apart. Without the fix, the three PostgreSQL cases fail outright and the MySQL type comparison fails on the missing:type:marker.Verification
435 unit, 687 integration passed, 14 skipped across MySQL and PostgreSQL. Catalog precision checked on postgres:15;
original_type, retrofit equivalence, and continuedpopulate()recording checked on both backends.