Bug: Dokument mit case-kollidierendem Tag lässt sich nicht speichern (findByNameIgnoreCase NonUniqueResultException) #730
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Saving any edit to a document fails with HTTP 500 ("Ein unerwarteter Fehler ist aufgetreten") when the document carries a tag whose name collides case-insensitively with another tag in the tree.
TagService.findOrCreate(name)resolves viatagRepository.findByNameIgnoreCase(name), which expects a unique row but matches two, throwingNonUniqueResultException.This is independent of #726 (the tag-resolution path is untouched by that PR; it reproduces on
main).Reproduction (observed on staging)
4423782d-e8b9-4bcb-bde5-0777dc53908c(carries tagweihnachten).Root cause
DocumentService.updateDocumentre-resolves the document's tags on every save: the edit form round-trips tag names (tags.map(t => t.name).join(','),frontend/src/routes/documents/[id]/edit/+page.server.ts:89), andresolveTagscallsTagService.findOrCreate(name)per name:The lookup is case-insensitive over the whole
tagtable, but the canonical tag tree legitimately contains names that differ only by case — a parent and its same-named lowercase child, or two siblings. These are not accidental duplicates and must not be merged/deduped (each has a distinctsource_ref/ tree position and real document attachments):GeburtGeburtgeburtGeburt/geburtHochzeitHochzeithochzeitHochzeit/hochzeitWeihnachtenWeihnachtenweihnachtenWeihnachten/weihnachtenGlückwünsche/glückwünscheGlückwünsche/glückwünscheReisepläne/reisepläneReise/Reisepläne/Reise/reisepläneSo a global case-insensitive uniqueness constraint would be wrong — the collision is a lookup problem, not a data problem.
source_ref(tag_path) is the stable identity (ADR-025 /upsertBySourceRef);nameis a human-editable label.findByNameIgnoreCasewas always a leaky identity that only works while names are globally unique case-insensitively — an invariant the canonical tree explicitly violates.Impact
Every document carrying one of these 10 tags is currently un-editable (any field — date, sender, transcription — fails on save, because the whole tag set is re-resolved). On staging that's 36 + 51 + 69 + 12 + 12 + 2 ≈ 180 document-tag attachments across common tags. No end-user workaround. The user-facing symptom is the opaque generic error with no hint that a tag is the cause.
Fix (code only — do NOT touch the data)
Make name→tag resolution unambiguous and non-throwing in
TagService.findOrCreate. Two repository methods with distinct names (Spring Data can't disambiguate by return type alone):Optional<Tag> findByName(String name)— exact-case derived query.List<Tag> findAllByNameIgnoreCase(String name)— the plural list. Delete the existingOptional<Tag> findByNameIgnoreCaseso the throwing call can't be reintroduced.Resolution order:
id(stable column, present on every row). Document the ordering in a one-line comment so "deterministic" has a concrete meaning.findOrCreateas the single source of truth — do not push resolution logic up intoDocumentService.resolveTags. The single-doc edit, bulk-edit, and upload-batch paths all funnel throughresolveTags→findOrCreate, so this one change fixes all three.findOrCreateand the repo method: case-colliding tag names across the tree are valid (parentGeburt+ childgeburt); do NOT add aunique(lower(name))constraint.Decision (accepted — option A): free-text tag entry semantics under case collision are accepted as-is for this issue. With "exact-case wins → single CI → create", typing the bare word
weihnachtenin new-doc/bulk-edit binds to the deep child (Weihnachten/weihnachten); typingWeihnachtenbinds to the parent container. This is correct for the edit round-trip and acceptable for free-text authoring. Disambiguating the typeahead/chips (showing tree path so an author can tell a container from its same-named child) is tracked as a separate follow-up, alongside the deeper Option 2 below — NOT in this issue.Out of scope (follow-up issue): round-tripping tag IDs rather than names so resolution can't be ambiguous at all. Cleaner long-term shape (removes the name-as-key smell) but a larger change with frontend surface. File separately; the lookup fix above is the minimal correct unblock.
Acceptance criteria
Test strategy
Unit (
TagServiceTest, mocked repo) — all four resolution branches as separate tests:" weihnachten "still trims then lands on the childTagServiceTestlines 58/69/81 — they referencefindByNameIgnoreCasereturningOptionaland won't compile after the method change.Integration (Testcontainers
postgres:16):Weihnachten+ childweihnachten; tag a doc with the child;PUT /api/documents/{id}changing the date → 200 (not 500), and assert the doc keeps exactly the child tag — assert tag set size and that the surviving tag's id is the child's, not the parent's. "No 500" alone is too weak.Glückwünsche/glückwünsche) — this is the test that actually catches regressions: a mocked-repo unit test never exercises PostgresLOWER()folding ofü/ä; only the real DB provesfindAllByNameIgnoreCase("glückwünsche")returns both rows. A plain-ASCII test would stay green while the bug reappears for umlaut tags.resolveTags→findOrCreate. One edit-path integration test plus a comment noting the shared code path is acceptable; better, add a thin bulk-edit assertion so a future refactor that bypassesfindOrCreateis caught.Verification / observability
GlobalExceptionHandlermapsIncorrectResultSizeDataAccessExceptionto a generic 500 body without leaking the Hibernate stack trace or SQL in the response payload (server-log/Sentry trace is fine; the wire response must stay opaque).NonUniqueResultExceptionGlitchTip group id, save the4423782d…repro doc, confirm no new occurrences land. A read-only query enumerating docs carrying any of the 10 colliding tags gives a post-deploy spot-check list (save one per tag → 200).weihnachten-tagged doc, change the date, save, confirm the chip still readsweihnachtenand no generic error flashes.Notes
unique(lower(name))index — it's wrong (collisions are valid canonical nodes) and would fail to apply against the existing 10 rows, turning a clean code deploy into a failed Flyway migration that blocks startup. Code-only, zero-migration, fully reversible (roll back the JAR).findByNameContainingIgnoreCasereturnsList(safe). ConfirmPersonService.findOrCreateByAliascan't hit the same non-unique throw on user-influenced alias names — if it can, file a sibling issue (do not fix here).Implemented ✅
Branch
fix/issue-730-tag-case-collision(offmain).Commits
d000170ffix(tag): resolve case-colliding tag names without throwing — deletedOptional<Tag> findByNameIgnoreCase(its only production caller wasfindOrCreate), addedOptional<Tag> findByName+List<Tag> findAllByNameIgnoreCase, and rewroteTagService.findOrCreateto: exact-case → lowest-id case-insensitive (deterministic, never throws) → create. Load-bearing comments at bothfindOrCreateand the repo methods warn against aunique(lower(name))constraint. Updated the 3 existingTagServiceTestmocks and added the four resolution-branch tests (exact-case wins; single CI; multiple CI → lowest id asserted across two calls; create-when-absent; whitespace+collision).a58378e8test(tag): pin case-colliding tag resolution on real Postgres — Testcontainerspostgres:16-alpine(TagCaseCollisionIntegrationTest): saving a doc tagged with the childweihnachtensucceeds and keeps exactly the child (id-asserted, not the parent); theGlückwünsche/glückwünscheumlaut pair resolves deterministically without throwing (the regression a plain-ASCII test would miss — only the real DB provesLOWER()foldsü); bulk-edit funnels throughresolveTags → findOrCreate.Acceptance criteria
findOrCreatenever throws on CI duplicates; returns the same (lowest-id) tag on every call.Verification notes
GlobalExceptionHandlerkeeps the wire response opaque: after the fix this path no longer throwsIncorrectResultSizeDataAccessException, and the existing generic handler maps any stray one toINTERNAL_ERROR("An unexpected error occurred") with no Hibernate/SQL leak. No new handler added (verification item, not a fix)../mvnw clean package -DskipTests✅ ·TagServiceTest(45) ✅ ·TagCaseCollisionIntegrationTest(3) ✅ ·DocumentServiceTest(187, theresolveTagsconsumer) ✅.Sibling sweep → follow-ups filed
PersonService.findOrCreateByAliashas the identicalOptional<Person> findByAliasIgnoreCasenon-unique-throw risk on case-colliding aliases (plusfindByFirstNameIgnoreCaseAndLastNameIgnoreCase). Latent (import path), filed not fixed here.Not yet done
Next: open the PR (or run review).
marcel referenced this issue2026-06-06 11:15:19 +02:00
marcel referenced this issue2026-06-06 11:15:30 +02:00
marcel referenced this issue2026-06-06 11:15:37 +02:00
marcel referenced this issue2026-06-06 11:15:45 +02:00
marcel referenced this issue2026-06-06 11:15:52 +02:00
marcel referenced this issue2026-06-06 11:16:04 +02:00
marcel referenced this issue2026-06-06 12:08:53 +02:00
marcel referenced this issue2026-06-06 12:19:22 +02:00
marcel referenced this issue2026-06-06 12:19:22 +02:00
marcel referenced this issue2026-06-06 12:20:02 +02:00