[DT-4061] Promote study registration asset lists to first-class fields - #3055
[DT-4061] Promote study registration asset lists to first-class fields#3055otchet-broad wants to merge 1 commit into
Conversation
37de009 to
f7668a1
Compare
d169eed to
cd0fa78
Compare
kevinmarete
left a comment
There was a problem hiding this comment.
The promotion skips a key when its value is not an array, but the strip changeset removes that key regardless. For example, an existing unvalidated assets value containing "models": "custom-value" will lose that data. Please retain wrong-shaped values unless a value was successfully promoted or an authoritative first-class property already exists, and add a migration test for a JSON object containing a promoted-name key with a non-array value.
f7668a1 to
a7f8085
Compare
cd0fa78 to
6a3d672
Compare
a7f8085 to
d41a878
Compare
6a3d672 to
223f8ef
Compare
d41a878 to
a1c1f9e
Compare
223f8ef to
415c6ee
Compare
kevinmarete
left a comment
There was a problem hiding this comment.
Here is a finding:
The runtime compatibility handling can discard legacy values that the migration deliberately preserves.
For example, the migration retains an unvalidated value such as "models": "custom-value" because it cannot promote it to an array. However, StudyAssets.assemble() removes the legacy models key and restores it only when it resolves to a nonempty list, so the value disappears from registration and search responses. On the next registration write, promotedValue() ignores the non-collection value while stripPromoted() removes it, deleting the only stored copy.
Please preserve wrong-shaped promoted-name values through both reads and writes, or explicitly migrate/reject them without silently losing data. Add a round-trip test covering a legacy assets object with a non-array value under a promoted key.
a1c1f9e to
4d19126
Compare
415c6ee to
4c1dcff
Compare
DatasetRegistrationSchemaV1.yaml declared `assets` as client-managed metadata "preserved and returned as-is by the backend; not backend-validated". Backend endpoints and dashboard counts came to depend on individual keys inside it, so that contract no longer held. This promotes the eight lists the backend reads - models, workspaces, presentations, publications, clinicalTrials, intellectualProperties, biospecimens, funding - to top-level registration fields, each stored in its own study_property row, with schemas for the item types. Compatibility is the point of the StudyAssets helper. Reads fall back to the legacy object for a study not yet rewritten, and writes are still accepted through it, so a client that has not moved to the top-level fields keeps working. Registration reads and the search index both still return the full assets object - every promoted list plus whatever unpromoted keys remain - so no consumer sees a shape change. Two details worth review attention. A promoted property that parses is authoritative even when empty, so a submitter who removed the last asset of a type does not get it restored from a stale legacy copy. And "provided" means present, not non-empty: registration reads return each list both top-level and inside `assets`, so an edit clearing the top-level list arrives beside the pre-edit legacy copy, and treating [] as "not provided" would resurrect what was just removed. Migration promotes each array-valued key out of `assets` into its own row, then strips a key only once its value is safely stored elsewhere - either this migration promoted it, or a first-class property already existed. A key whose value is the wrong shape was never promoted, so it stays put: `assets` was unvalidated and free to hold anything, and dropping such a value would lose the only copy of it. An empty list is likewise not promoted and so is retained, which nothing reads either way. Any `assets` row left empty is deleted. The promotion compares against the empty array rather than measuring with jsonb_array_length. The planner may evaluate that before the typeof guard, and the length function raises "cannot get array length of a scalar" on a string or number value - which the migration test now covers with a real one. Note for reviewers who ran the earlier version of this changeset against a local database: the checksum changed, so Liquibase will need a clear or a fresh database. The changeset ids are unchanged. Also corrects IntellectualProperty.filingDate from boolean to string. The runtime path now preserves what the migration preserves. The migration keeps a legacy value under a promoted name that is not a list, because the promotion cannot take it - but assemble() removed the legacy key and restored it only when it resolved to a nonempty list, so the value disappeared from registration and search reads, and stripPromoted() then removed it on the next write while promotedValue() ignored it, deleting the only stored copy. assemble() now leaves such a value exactly as stored, and stripPromoted() removes a promoted key only when its value is a list, which is the same shape test promotedValue() applies. A round-trip test covers a legacy assets object with a non-array value under a promoted key, and fails against the previous behavior. The one combination still not representable is a study holding both a promoted property and a non-list legacy value under the same name: one key cannot carry both. The promoted list wins, being the shape clients expect and the newer intent, and the legacy value is left on disk rather than stripped. The migration does not produce that combination. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4d19126 to
7f7d63e
Compare
4c1dcff to
945bae9
Compare
kevinmarete
left a comment
There was a problem hiding this comment.
One finding:
promotedValue() performs a case-sensitive lookup with assets.get(key), while stripPromoted() removes list-valued keys using equalsIgnoreCase. Consequently, a legacy value such as "Models": [...] is not promoted but is removed from assets, losing the data during a registration update. Please use the same case-insensitive lookup in both paths and add a round-trip test for a differently cased promoted key.
Base: branch 5 · 20 files, +1227/-16 · Migration: yes (2 changesets, data rewrite) · UI impact: designed to be none — verify
The riskiest PR in the stack, and the only one that rewrites existing data.
DatasetRegistrationSchemaV1.yamldeclaredassetsas client-managedmetadata "preserved and returned as-is by the backend; not
backend-validated". Backend endpoints and dashboard counts came to depend on
individual keys inside it, so that contract no longer held. This promotes the
eight lists the backend reads —
models,workspaces,presentations,publications,clinicalTrials,intellectualProperties,biospecimens,funding— to top-level registration fields, each stored in its ownstudy_propertyrow, with schemas for the item types.Compatibility is the point of the
StudyAssetshelper. Reads fall back tothe legacy object for a study not yet rewritten, and writes are still
accepted through it, so a client that has not moved to the top-level fields
keeps working. Registration reads and the search index both still return the
full
assetsobject — every promoted list plus whatever unpromoted keysremain — so no consumer sees a shape change.
Two details deserve review attention. A promoted property that parses is
authoritative even when empty, so a submitter who removed the last asset of a
type does not get it restored from a stale legacy copy. And "provided" means
present, not non-empty: registration reads return each list both top-level
and inside
assets, so an edit clearing the top-level list arrives besidethe pre-edit legacy copy, and treating
[]as "not provided" would resurrectwhat was just removed.
Migration promotes each array-valued key out of
assetsinto its own row,then strips the promoted keys and drops any
assetsrow left empty. Guardedto skip non-object and non-array values and to skip a study that already has
the target row.
Rollback hazard: once the strip changeset runs, code from before this
branch can no longer see these lists — they no longer live in
assets.Rolling back the deploy requires restoring the data.
Also corrects
IntellectualProperty.filingDatefrom boolean to string.Depends on: nothing in branches 1-5 — no code dependency on any of them.
Placed last deliberately, because it is the only data rewrite in the stack and
the hardest to roll back. It can be reordered earlier if that suits review.
Where this sits in the DT-4061 stack
otchet-dt-3990-study-ratings-pi-detailswas too large to review meaningfully(95 files, +8168), so it was split into eight PRs. This PR targets
otchet-dt-4061-study-recommendations,not
develop, so its diff shows only its own work.otchet-dt-4061-study-visibility-authzdevelopotchet-dt-4061-study-patch-ownershipotchet-dt-4061-study-visibility-authzotchet-dt-4061-study-pi-detailsotchet-dt-4061-study-visibility-authzotchet-dt-4061-study-commentsotchet-dt-4061-study-pi-detailsotchet-dt-4061-study-dar-metricsotchet-dt-4061-study-commentsotchet-dt-4061-study-recommendationsotchet-dt-4061-study-dar-metricsotchet-dt-4061-study-asset-fields← this PRotchet-dt-4061-study-recommendationsotchet-dt-4061-study-asset-endpointsotchet-dt-4061-study-asset-fieldsBranch 1b is a sibling rather than a link in the chain: nothing depends on it,
so it can be held or dropped without blocking the others. Land the numbered
chain in order — merging out of order, or squash-merging, will require rebasing
the descendants.
The split is verified lossless: branch 7 plus 1b reproduces the original branch
exactly, apart from five
scripts/verify-study-*.shhelper scripts that weredropped at the author's request.
./mvnw test-compilepasses on each branchindependently.
🤖 Generated with Claude Code