Skip to content

Declare every platform-managed column through the DataJoint type system - #1565

Open
dimitri-yatsenko wants to merge 10 commits into
masterfrom
refactor/prov-column-definition
Open

dimitri-yatsenko wants to merge 10 commits into
masterfrom
refactor/prov-column-definition

Conversation

@dimitri-yatsenko

@dimitri-yatsenko dimitri-yatsenko commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

Follow-up to #1555, and a response to the review items left open there. Net 18 lines removed.

Why

_prov was declared by hand-written SQL, once per adapter:

# mysql.py     ->  "`_prov` json DEFAULT NULL"
# postgres.py  ->  '"_prov" jsonb DEFAULT NULL'

That restates a mapping the adapter already owns — core_type_to_sql("json") returns exactly json and jsonb. The better precedent was a few lines away in the same function: the _singleton injection, which is also a declaration-time hidden column and goes through the type system.

I followed job_metadata_columns instead, which does the same hand-written thing. Wrong precedent.

What changes

One function, used by both declare.py and deploy.py:

def column_definition(adapter):
    return adapter.format_column_definition(
        name=PROV_ATTRIBUTE,
        sql_type=adapter.core_type_to_sql("json"),
        nullable=True,
        default="DEFAULT NULL",
        comment=PROV_COMMENT,
    )
  • provenance_columns is deleted from all three adapters, the @abstractmethod included.

  • A new backend needs nothing added — it inherits the column by implementing core_type_to_sql, which it must implement anyway.

  • original_type is now recorded. Verified on both backends:

    mysql      : type='json'   original_type='json'  json=True
    postgresql : type='jsonb'  original_type='json'  json=True
    

    Note PostgreSQL: the SQL type is jsonb but original_type is the DataJoint name json. The hand-written form left it None.

    Correction to an earlier version of this description. I claimed this is what jobs.py:210 and table.py:1400 consume and what Public access to hidden attributes (_job_*, _prov): no reader exists #1562's reader will need. Both are wrong: jobs.py:210 iterates target_pk, and hidden attributes are never in the primary key; table.py:1400 is inside describe(), which iterates heading.attributes with hidden excluded. And decoding does not use original_type at all — json, uuid, numeric and dtype are set from the SQL type at heading.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_columns as a new @abstractmethod made any out-of-tree adapter uninstantiable. You suggested a default return []; deleting the method is better, and this does that.
  • Dry-run counters incremented outside the guard, unlike set_replica_identity next door. They now count work actually done; a dry run reports through ddl and details. The test asserts columns_added == 0 on a preview.
  • add_prov_column left the cached heading stale. You were right that the manual _init_from_database() in the test was hiding it: _prov stayed invisible to the insert path and every insert silently recorded nothing until the process reconnected. add_prov_column now invalidates the heading, and the test calls no refresh of its own — it forces the lazy reload the same way _has_prov_attribute does, so it exercises the real path.

Still open from your review, not in this PR

Verification

  • Full suite 681 passed, 14 skipped, 0 failed across MySQL and PostgreSQL.
  • original_type checked on both backends, since that is the behavior change rather than a refactor.
  • test_declare.py run alongside, because _singleton goes through the same format_column_definition path this now shares.
  • ruff, ruff-format, codespell, mypy pass via pre-commit.

Extended: _job_* too (5e83a56)

Following review discussion, this now covers every platform-managed column, not just _prov. _singleton already used the type system; _prov does 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 got datetime(3). Confirmed against the catalog rather than inferred:

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 both backends now declare the precision that reference/specs/job-metadata.md has been documenting all along. Closes #1566 and #1567.

job_metadata_columns is deleted from all three adapters alongside provenance_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_name takes [a-z_] as its first character class, and the underscore check moved out of compile_attribute — which the framework itself calls — into _reject_user_hidden_attributes, run from declare() before anything is appended. That is the single point a user's definition enters the system, so there is no allow_reserved flag to thread through and forget. The user-facing error is unchanged, and tests/unit/test_declare_hidden_attribute.py now pins that it fires from a definition string rather than only from compile_attribute directly.

With the grammar open, the five platform columns live in declare.py as ordinary DataJoint lines and are spliced into the definition before it is parsed:

_singleton      = 1    : bool          # singleton primary key
_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 for a row that entered from outside

migrate.add_job_metadata_columns is fixed rather than left alone. The earlier description argued it was out of scope because the module is deprecated and the ALTER is 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, and datetime(3) is not a PostgreSQL type. It now compiles JOB_METADATA_DEFINITION through compile_attribute and quotes through the adapter, as deploy.add_prov_column already does, with the COMMENT ON alongside.

tests/integration/test_migrate_job_metadata.py declares 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 continued populate() recording checked on both backends.

`_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.
@dimitri-yatsenko dimitri-yatsenko changed the title Declare _prov through the DataJoint type system Declare every platform-managed column through the DataJoint type system Oct 1, 2026
dimitri-yatsenko and others added 8 commits October 1, 2026 14:23
Platform 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

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

_job_start_time precision differs by backend: datetime(3) on MySQL, microsecond timestamp on PostgreSQL

1 participant