Skip to content

feat(#10): pre-flight check for external-id (xmlid) collisions - #224

Merged
bosd merged 2 commits into
masterfrom
fix/xmlid-collision-preflight
Aug 1, 2026
Merged

feat(#10): pre-flight check for external-id (xmlid) collisions#224
bosd merged 2 commits into
masterfrom
fix/xmlid-collision-preflight

Conversation

@bosd

@bosd bosd commented Aug 1, 2026

Copy link
Copy Markdown
Member

Review follow-up #10 (design agreed in discussion).

Problem

to_xmlid sanitizes each id for Odoo by mapping spaces/commas/pipes/newlines to _. So two different source ids collapse to the same external id — a b and a,b both become a_b. Since Odoo's load() upserts on the external id, those rows are silently merged into one record (the later row overwrites the earlier). Real, silent data loss — and it was undetected.

Fix

A new pre-flight check (xmlid_collision_check) scans the id column and, when distinct ids collide, aborts before anything is written and lists the offending ids. Opt out with --allow-xmlid-collisions (then it warns and proceeds).

Design calls from the discussion:

  • Pre-flight, fail-fast (no half-merged data).
  • Hard-error by default + opt-out flag.
  • Blank ids are left alone — each is an independent create (no xmlid, no merge). But a blank-id row that also carries relational (Pass-2) data is warned about, because Pass 2 can't link relations to a record with no external id.
  • No positional guessing for blank-id relations: fluvo's parallel batches / --groupby / self-referential sort / retry+bisect make row order unstable, so "line N of Pass 1 = line N of Pass 2" would silently link the wrong records. We surface the problem instead of guessing.

Tests

TestXmlidCollisionCheck: collision-abort, --allow-xmlid-collisions opt-out, no-collision pass, blank-id-with-relations warns, blank-id-without-relations is fine. Full suite: 1435 passed; mypy + ruff clean; no pydoclint-baseline churn. Docs: a warning admonition under "How External IDs Work".

Noted follow-up (from the discussion)

Narrow what actually gets deferred: a many2many/one2many that points at records which already exist can go inline in the Pass-1 load() — no deferral, no external id required. Only relations pointing at other rows in the same import truly need an id + Pass 2. That would eliminate a lot of blank-id-with-relations cases entirely. I can file this as a tracked issue if you'd like.

bosd added 2 commits August 1, 2026 14:23
`to_xmlid` maps spaces/commas/pipes/newlines to `_`, so two distinct source ids
(e.g. "a b" and "a,b") can collapse to the same Odoo external id. Because load()
upserts on the xmlid, such rows are silently merged into one record — the later
row overwrites the earlier. This was undetected.

Add a pre-flight check that scans the id column and aborts *before* anything is
written when distinct ids collide (listing the offending ids), unless the new
`--allow-xmlid-collisions` flag is passed (then it warns and proceeds).

Blank ids are left alone (each is an independent create, no merge), but a blank-id
row that also carries relational (Pass-2) data is warned about — Pass 2 can't link
relations to a record with no external id. Per review discussion, fluvo does NOT
positionally guess the link (parallel batches / --groupby / sort / retries make
row order unstable), it surfaces the problem instead.

Tests cover collision-abort, opt-out, no-collision, and both blank-id cases. Docs:
a warning admonition under the External ID section.
…isions arg

Adding the parameter changed the exact arg-list text in run_import's baselined
DOC103 entry; insert allow_xmlid_collisions to match.
@bosd
bosd merged commit 410c3ad into master Aug 1, 2026
25 checks passed
@bosd
bosd deleted the fix/xmlid-collision-preflight branch August 1, 2026 14:46
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.

1 participant