Closes: #391 (Phase 2) - post_migrate auto-heal for mixin column drift#481
Conversation
…t COT creation Add CustomObjectType._collect_base_columns() and _store_base_column_snapshot() to record the concrete columns contributed by the CustomObject mixin hierarchy (id, created, last_updated, and any future additions) at table-creation time. The snapshot is stored in schema_document["base_columns"] alongside any existing keys, preserving schema-exporter output from #386. Add migration 0009_backfill_base_columns to populate the snapshot for all existing COT tables using DB introspection, subtracting user-defined field names to isolate base columns. This is the foundation for Phase 2 (post_migrate auto-heal handler that detects and applies mixin schema drift when NetBox is upgraded). Note: migration 0009_alter_customobjecttype_version on branch 392-validate-semver-fields will need renumbering to 0010 before that PR merges.
…se_columns after creation
Add netbox_custom_objects/mixin_migration.py with:
- _expected_base_fields(): resolves base columns from live CustomObject model
- _can_auto_add(): determines if a missing column can be safely ADD COLUMNed
- heal_cot(): detects drift for one COT, adds safe columns, warns on unsafe
ones, never auto-drops; refreshes schema_document snapshot on success
- heal_all_cots(): iterates all COTs, returns {total, healed, warnings}
Wire _heal_mixin_columns() post_migrate signal handler in __init__.py:ready()
so the heal runs automatically after every 'manage.py migrate'. A module-level
_heal_ran flag deduplicates the call (post_migrate fires once per installed
app). makemigrations and collectstatic are excluded.
Add management command 'upgrade_custom_objects' with --dry-run and --cot flags
for explicit inspection and repair outside the migrate flow.
Tests cover: _can_auto_add() unit tests, _expected_base_fields() with/without
user fields, heal_cot() adding nullable columns, warning on non-nullable, not
dropping removed columns, snapshot refresh, dry_run correctness,
heal_all_cots() summary counts, and all management command flags.
61b9fe2 to
f446e28
Compare
- Snapshot refresh: derive updated base_columns by merging new fields into the existing snapshot dict rather than re-running _store_base_column_snapshot(model), which re-derives from model._meta and misses columns added directly to the DB by this heal pass (schema_editor.add_field does not update _meta). - removed-column test: actually add ghost_col to the DB table so the 'if col_name in actual_names' guard in heal_cot() fires correctly.
|
@claude /review |
|
Claude finished @bctiemann's task in 2m 11s —— View job Code Review — PR #481: Phase 2 post_migrate auto-heal for mixin column drift
OverviewThis is a well-structured implementation of automatic mixin column drift detection and repair. The design is sound: post-migrate signal wiring, a module-level deduplication flag, a management command escape hatch, and clear safety rules about what can/cannot be auto-fixed. Overall the code is clean and the test coverage is thorough. A few issues are worth addressing before merge. Issues FoundBug: Field name vs. column name mismatch
# Current: uses f.name (Python attribute name)
return {f.name: f for f in model._meta.concrete_fields if ...}
# Should use f.attname or f.column for FK fields and db_column overrides:
return {f.column: f for f in model._meta.concrete_fields if ...}Also, the user-field exclusion at line 41 checks Missing implementation: type-change detection
Design concern: logging vs. stdout in the management command
Consider having Minor:
|
| Issue | Severity |
|---|---|
f.name vs f.column in drift comparison |
Medium — would produce incorrect results for db_column-overriding fields |
| Missing type-change detection | Low — documented in docstring but unimplemented |
| Logger vs. stdout mixing in command | Low — UX/DX concern |
Double get_model() call |
Negligible |
The core architecture is solid. The f.name vs f.column issue is the most important to address before merge.
- Key by f.column (not f.name) in _expected_base_fields for correct FK field comparison - Accept optional model arg in _expected_base_fields to avoid duplicate get_model() - Add type-change detection: warn when a present base column field class differs from snapshot - Route per-column output to self.stdout by having command iterate COTs directly - Add test for type-change detection warning Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
All review items addressed. |
- Sort _collect_base_columns return value by name to match the alphabetical ordering used in the backfill migration, so both paths produce consistent snapshots - field_class is now always present in backfill entries (was conditionally absent for columns not in base_field_info); falls back to 'UnknownField' for any unrecognised column - Derive id field class from CustomObject._meta.pk.__class__.__name__ instead of hardcoding 'AutoField', handling BigAutoField installs - Use USER_TABLE_DATABASE_NAME_PREFIX constant in migration instead of duplicating the 'custom_objects_' literal - Update test to assert field_class is always present in backfilled entries Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
arthanson
left a comment
There was a problem hiding this comment.
need to merge feature and fix merge conflicts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
test_base_columns.py: update hardcoded migration reference from
0009_backfill_base_columns to 0010_backfill_base_columns. Migration 0009
is now alter_customobjecttype_version; the backfill was renumbered to 0010.
executor.py: Phase 1 always sets schema_document={"base_columns":[...]}
on COT creation, making it truthy. The finalisation condition
`not cot.schema_document` was meant to catch "never set", but now
incorrectly skips _update_schema_document for no-change applies on newly
created COTs. Switch to checking for the presence of schema_version so
that a COT whose schema_document was populated only by the base-column
snapshot still gets a proper executor-managed document on first apply.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CustomObject is abstract, so CustomObject._meta.pk is always None. The fallback "AutoField" fired unconditionally, causing backfilled COTs to record a different field_class for id than COTs created after this PR (which correctly record "BigAutoField" via _collect_base_columns on the concrete model). This would produce false drift alerts in Phase 2. Derive pk_class from DEFAULT_AUTO_FIELD instead, which matches what _collect_base_columns produces on concrete models. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
_collect_base_columns stored "name": f.name, but the Phase 2 heal function keys its expected-column dict by f.column (the actual DB column name). For all current base fields f.name == f.column, so this worked silently — but any FK field added to the mixin hierarchy (f.name='site', f.column='site_id') would cause the heal-time lookup to miss the snapshot entry entirely. Fix _collect_base_columns to store f.column and update the backfill migration to: - key base_field_info by f.column (was f.name) so the field_class lookup against DB-introspected column names is consistent - build user_column_names as both name and name+'_id' so Object-type user fields (whose DB column is field_name+'_id') are correctly excluded from the base-column snapshot Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The workaround note in heal_cot that said base_columns stores f.name is no longer accurate after the fix to _collect_base_columns. Update to reflect that snapshot entries are now keyed by f.column. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Resolves conflicts in models.py and 0010_backfill_base_columns.py by keeping the f.column fix from 391-base-column-snapshot (the correct version) over the pre-fix f.name version that landed in feature via the original PR #480 merge. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- __init__.py: document that _heal_ran is set before the try block so a first-firing failure permanently disables auto-retry in the same process; operators can use upgrade_custom_objects manually instead - upgrade_custom_objects: deduplicate CustomObjectType import into handle(); gate "no drift detected" stdout on verbosity >= 2 to avoid flooding output for deployments with many COTs - test_mixin_migration: collapse dead if/else in test_type_change_detected_as_warning; add test_add_field_failure_recorded_as_warning covering the add_failed error path; update command tests to use verbosity=2 for no-drift assertions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary
Implements automatic detection and repair of mixin column drift for Custom Object Type tables (Phase 2 of #391). When NetBox is upgraded and a mixin (e.g.
ChangeLoggingMixin) gains a new concrete column, existing COT tables would previously be missing that column silently. This PR closes that gap.Depends on PR #480 (Phase 1 -- base column snapshot).
New module:
mixin_migration.py_expected_base_fields(cot)-- resolves the full set of base columns from the liveCustomObjectmodel, excluding user-defined fields_can_auto_add(field)-- determines whether a missing column can be safely added (ADD COLUMN) without breaking existing rows (nullable or has a default)heal_cot(cot, verbosity, dry_run)-- detects drift for a single COT, adds safe columns viaschema_editor.add_field(), warns on unsafe ones (NOT NULL/no default, type changes, removals), never auto-drops, refreshesschema_document["base_columns"]after successful additionsheal_all_cots(verbosity, dry_run)-- iterates all COTs, returns{total, healed, warnings}summarySignal wiring (
__init__.py)_heal_mixin_columns()is connected topost_migrateinready(). It fires automatically after everymanage.py migraterun. A module-level_heal_ranflag ensures the work happens only once per process even thoughpost_migratefires once per installed app. Excluded duringmakemigrationsandcollectstatic.Management command:
upgrade_custom_objectsExplicit escape hatch for inspection and manual repair:
Safety rules
Test plan
CanAutoAddTestCase-- nullable, defaulted, both, neitherExpectedBaseFieldsTestCase-- returns id/created/last_updated, excludes user fields, returns real Django Field instancesHealCotTestCase-- no-drift returns empty; nullable column is added to DB; non-nullable warns; snapshot updated after addition; removed column warns but not dropped; dry_run reports without touching DB; dry_run does not update snapshotHealAllCotsTestCase-- total matches COT count, healed=0 when no drift, correct summary keysUpgradeCustomObjectsCommandTestCase-- runs without error, --dry-run flag, --cot by name, --cot by ID, unknown COT raises CommandErrorGenerated with Claude Code