Skip to content

Commit 81d3322

Browse files
committed
Add support for schema downgrade migrations
This change introduces the ability to downgrade metadata schemas to recent versions, allowing for more flexible schema version management. Key changes: - Allow migration to version 0.6.10 in addition to the current version - Implement SIMPLE_DOWNGRADES mechanism for safe field removal during downgrade - Remove restriction preventing downgrade to lower schema versions - Add validation to prevent data loss when downgrading with populated fields - Add comprehensive tests for downgrade functionality with releaseNotes field The downgrade mechanism ensures data integrity by raising an error if a field being removed during downgrade contains a non-empty value. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> Conflicts: dandischema/metadata.py -- both wanted to always set schema version to migrated-to dandischema/tests/test_metadata.py -- just added tests conflicted placement
1 parent 7063e44 commit 81d3322

File tree

3 files changed

+68
-40
lines changed

3 files changed

+68
-40
lines changed

dandischema/consts.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,9 @@
1919
DANDI_SCHEMA_VERSION,
2020
]
2121

22-
# ATM we allow only for a single target version which is current
23-
# migrate has a guard now for this since it cannot migrate to anything but current
24-
# version
25-
ALLOWED_TARGET_SCHEMAS = [DANDI_SCHEMA_VERSION]
22+
# We establish migrations (back) to only a few recent versions.
23+
# When adding changes, please consider whether a migration path should be added.
24+
ALLOWED_TARGET_SCHEMAS = ["0.6.10", DANDI_SCHEMA_VERSION]
2625

2726
# This allows multiple schemas for validation, whereas target schemas focus on
2827
# migration.

dandischema/metadata.py

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -358,24 +358,18 @@ def migrate(
358358
schema version of the provided instance
359359
"""
360360

361-
# ATM, we only support the latest schema version as a target. See definition of
362-
# `ALLOWED_TARGET_SCHEMAS` for details
363-
if len(ALLOWED_TARGET_SCHEMAS) > 1:
364-
msg = f"Only migration to current version, {DANDI_SCHEMA_VERSION}, is supported"
365-
raise NotImplementedError(msg)
366-
367361
# --------------------------------------------------------------
368362
# Validate DANDI schema version provided in the metadata instance
369363
# --------------------------------------------------------------
370364
# DANDI schema version of the provided instance
371-
obj_ver = obj.get("schemaVersion")
372-
if obj_ver is None:
365+
obj_version = obj.get("schemaVersion")
366+
if obj_version is None:
373367
msg = (
374-
"The provided Dandiset metadata instance does not have a "
368+
"The provided metadata instance does not have a "
375369
"`schemaVersion` field for specifying the DANDI schema version."
376370
)
377371
raise ValueError(msg)
378-
if not isinstance(obj_ver, str):
372+
if not isinstance(obj_version, str):
379373
msg = (
380374
"The provided Dandiset metadata instance has a non-string "
381375
"`schemaVersion` field for specifying the DANDI schema version."
@@ -384,17 +378,17 @@ def migrate(
384378
# Check if `obj_ver` is a valid DANDI schema version
385379
try:
386380
# DANDI schema version of the provided instance in tuple form
387-
obj_ver_tuple = version2tuple(obj_ver)
381+
obj_version_tuple = version2tuple(obj_version)
388382
except ValueError as e:
389383
msg = (
390384
"The provided Dandiset metadata instance has an invalid "
391385
"`schemaVersion` field for specifying the DANDI schema version."
392386
)
393387
raise ValueError(msg) from e
394-
if obj_ver not in ALLOWED_INPUT_SCHEMAS:
388+
if obj_version not in ALLOWED_INPUT_SCHEMAS:
395389
msg = (
396390
f"The DANDI schema version of the provided Dandiset metadata instance, "
397-
f"{obj_ver!r}, is not one of the supported versions for input "
391+
f"{obj_version!r}, is not one of the supported versions for input "
398392
f"Dandiset metadata instances. The supported versions are "
399393
f"{ALLOWED_INPUT_SCHEMAS}."
400394
)
@@ -407,7 +401,7 @@ def migrate(
407401
# Check if `to_version` is a valid DANDI schema version
408402
try:
409403
# The target DANDI schema version in tuple form
410-
target_ver_tuple = version2tuple(to_version)
404+
to_version_tuple = version2tuple(to_version)
411405
except ValueError as e:
412406
msg = (
413407
"The provided target version, {to_version!r}, is not a valid DANDI schema "
@@ -424,22 +418,17 @@ def migrate(
424418
raise ValueError(msg)
425419
# ----------------------------------------------------------------
426420

427-
# Ensure the target DANDI schema version is at least the DANDI schema version
428-
# of the provided instance
429-
if obj_ver_tuple > target_ver_tuple:
430-
raise ValueError(f"Cannot migrate from {obj_ver} to lower {to_version}.")
431-
432421
# Optionally validate the instance against the DANDI schema it specifies
433422
# before migration
434423
if not skip_validation:
435-
_validate_obj_json(obj, _get_jsonschema_validator(obj_ver, "Dandiset"))
424+
_validate_obj_json(obj, _get_jsonschema_validator(obj_version, "Dandiset"))
436425

437426
obj_migrated = deepcopy(obj)
438427

439-
if obj_ver_tuple == target_ver_tuple:
428+
if obj_version_tuple == to_version_tuple:
440429
return obj_migrated
441430

442-
if obj_ver_tuple < version2tuple("0.6.0") <= target_ver_tuple:
431+
if obj_version_tuple < version2tuple("0.6.0") <= to_version_tuple:
443432
for val in obj_migrated.get("about", []):
444433
if "schemaKey" not in val:
445434
if "identifier" in val and "UBERON" in val["identifier"]:
@@ -459,6 +448,24 @@ def migrate(
459448
if "schemaKey" not in obj_migrated:
460449
obj_migrated["schemaKey"] = "Dandiset"
461450

451+
# Downgrades
452+
453+
# Simple downgrades that just require removing fields, which is totally fine
454+
# if they are empty
455+
SIMPLE_DOWNGRADES = [
456+
# version added, fields to remove
457+
("0.6.11", ["releaseNotes"]),
458+
]
459+
for ver_added, fields in SIMPLE_DOWNGRADES:
460+
# additional guards are via ALLOWED_TARGET_SCHEMAS
461+
if (to_version_tuple < version2tuple(ver_added) <= obj_version_tuple):
462+
for field in fields:
463+
if field in obj_migrated:
464+
if val := obj_migrated.get(field):
465+
raise ValueError(f"Cannot downgrade to {to_version} from "
466+
f"{obj_version} with {field}={val!r} present")
467+
del obj_migrated[field]
468+
462469
# Always update schemaVersion when migrating
463470
obj_migrated["schemaVersion"] = to_version
464471
return obj_migrated

dandischema/tests/test_metadata.py

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -424,20 +424,6 @@ def test_migrate_value_errors(obj: Dict[str, Any], target: Any, msg: str) -> Non
424424
migrate(obj, to_version=target, skip_validation=True)
425425

426426

427-
def test_migrate_value_errors_lesser_target(monkeypatch: pytest.MonkeyPatch) -> None:
428-
"""
429-
Test cases when `migrate()` is expected to raise a `ValueError` exception
430-
when the target schema version is lesser than the schema version of the metadata
431-
instance
432-
"""
433-
from dandischema import metadata
434-
435-
monkeypatch.setattr(metadata, "ALLOWED_TARGET_SCHEMAS", ["0.6.0"])
436-
437-
with pytest.raises(ValueError, match="Cannot migrate from .* to lower"):
438-
migrate({"schemaVersion": "0.6.7"}, to_version="0.6.0", skip_validation=True)
439-
440-
441427
@skipif_no_network
442428
@skipif_no_test_dandiset_metadata_dir
443429
# Skip for instance name not being DANDI because JSON schema version at `0.4.4`, the
@@ -509,6 +495,42 @@ def test_migrate_schemaversion_update() -> None:
509495
)
510496

511497

498+
@pytest.mark.ai_generated
499+
def test_migrate_downgrade_releasenotes() -> None:
500+
"""Test downgrade from 0.6.11 to 0.6.10 handling releaseNotes field"""
501+
from .utils import _basic_publishmeta
502+
503+
# Create a basic PublishedDandiset metadata in 0.6.11 format
504+
meta_dict = {
505+
"schemaVersion": "0.6.11",
506+
}
507+
meta_dict.update(_basic_publishmeta(dandi_id="999999"))
508+
509+
# Test 1: Downgrade without releaseNotes (should succeed)
510+
downgraded = migrate(meta_dict, to_version="0.6.10", skip_validation=True)
511+
assert downgraded["schemaVersion"] == "0.6.10"
512+
assert "releaseNotes" not in downgraded
513+
514+
# Test 2: Downgrade with empty releaseNotes (should succeed)
515+
meta_dict["releaseNotes"] = ""
516+
downgraded = migrate(meta_dict, to_version="0.6.10", skip_validation=True)
517+
assert downgraded["schemaVersion"] == "0.6.10"
518+
assert "releaseNotes" not in downgraded
519+
520+
# Test 3: Downgrade with non-empty releaseNotes (should fail)
521+
meta_dict["releaseNotes"] = "Releasing during testing"
522+
with pytest.raises(ValueError, match="Cannot downgrade to 0.6.10 from"):
523+
migrate(meta_dict, to_version="0.6.10", skip_validation=True)
524+
525+
# Test 4: No-op migration (already at target version)
526+
meta_dict_0610 = meta_dict.copy()
527+
meta_dict_0610["schemaVersion"] = "0.6.10"
528+
meta_dict_0610.pop("releaseNotes")
529+
migrated = migrate(meta_dict_0610, to_version="0.6.10", skip_validation=True)
530+
assert migrated == meta_dict_0610
531+
assert migrated is not meta_dict_0610 # but we do create a copy
532+
533+
512534
@pytest.mark.parametrize(
513535
"files, summary",
514536
[

0 commit comments

Comments
 (0)