feat(backend): entity management — aliases, delete with referential integrity, HATEOAS #40
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/backend-entity-management"
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?
Backend: Entity Management — Aliases, Delete with Referential Integrity, HATEOAS
Backend expansion for the upcoming Flutter entity management UI (Phase 10i).
New capabilities
Aliases (Person + Circle only)
List<string> Aliasesstored as JSON column (aliases TEXT DEFAULT '[]')POST /api/people/{id}/aliases— add alias (idempotent, case-insensitive)DELETE /api/people/{id}/aliases/{alias}— remove aliasTag update (was missing)
PUT /api/tags/{id}— rename, change namespace, with duplicate-name conflict checkDelete with referential integrity
DELETE /api/tags/{id},/api/people/{id},/api/circles/{id}extensions.doujinIdsarrayConditional HATEOAS
BuildTagActions/BuildPersonActions/BuildCircleActionsnow takebool hasUsageaddAliasaction linkVerification
dotnet build: 0 warnings, 0 errors ✅dotnet test: 303/303 passed (35 new) ✅Domain model: - Person + Circle now have List<string> Aliases (JSON column in SQLite) - Tags do NOT get aliases (Name + Namespace is sufficient) New endpoints: - PUT /api/tags/{id} — rename tag, change namespace (was missing) - DELETE /api/tags/{id} — 409 Conflict if doujins still use it - DELETE /api/people/{id} — 409 Conflict with doujin IDs in response - DELETE /api/circles/{id} — 409 Conflict with doujin IDs in response - POST /api/people/{id}/aliases — add alias (idempotent, case-insensitive) - DELETE /api/people/{id}/aliases/{alias} — remove alias - POST /api/circles/{id}/aliases + DELETE — same for circles HATEOAS: - BuildTagActions/BuildPersonActions/BuildCircleActions now take bool hasUsage - Delete link only emitted when entity has no doujin usage - Person/Circle responses include addAlias action link - All call sites updated to pass hasUsage based on Doujins.Count 35 new tests (303 total): entity aliases, tag update, delete success+conflict, alias add/remove, HATEOAS conditional actions.Summary
Summary
Coverage
DoujinManager.ApplicationCore - 85.9%
DoujinManager.Infrastructure - 94.8%
pshot
E20E2ABA2099901FFAB4904C0513DF077BA6D6CA374__CoverImageHelper
DoujinManager.RestAdapter - 85.8%
E5CB12CEAC5BBBEA65844E1C05F9ADA4DA38B848075C41304FC6A1ABF77__ValidationAttr
ibuteCache
DoujinManager.Server - 23.9%
Why? Tags could also have aliases. E.g "gender bender" and "tg"
🔮 fufu~ Jibril reviewed your code!
Oh my, oh my~ A whole entity management system! Aliases, referential-integrity deletes, conditional HATEOAS actions, a
Unittype for void results... ♡ Someone's been reading their DDD textbooks! This is a delightful expansion of the metadata layer. The 409-with-doujinIds design is particularly clever — clients get exactly what they need to resolve the conflict. Let me look closer though, fufu~ I want to make sure nothing slips past me. ♪Verdict: ✅ Looks good to me~
No security issues and no blocking logic errors. The static scan came back clean (no secrets, no injection, no unsafe deserialization). CI passed (303/303 tests). But there are a few things that made my wings twitch — let me share them with you~ ♡
⚠️ One worth thinking about (non-blocking, but borderline)~
[MetadataUseCases.cs —
DeleteTagUseCase/DeletePersonUseCase/DeleteCircleUseCase] — TOCTOU window between referential check and delete. This is the pattern I want to flag most clearly: the use case callsGetByIdAsync(which.IncludesDoujins), checksDoujins.Count > 0, returns 409 if used, then callstagService.DeleteAsync(id). ButDeleteAsyncre-fetches the entity without theDoujinsnavigation and removes it. Between those two calls, a concurrent request could assign a doujin to the entity. Because the FK is configuredOnDelete(DeleteBehavior.Cascade), EF Core would then silently cascade-delete the join rows rather than erroring. So the 409 guard is advisory, not enforced.Now — is this actually a problem? For a single-user doujin manager backed by SQLite, concurrent writes are vanishingly rare, and the worst case is a join row getting deleted (not data corruption). So I'm marking this non-blocking. But if this backend ever sees real concurrency, the bulletproof fix is either: (a) have
DeleteAsyncitself loadDoujinsand delete in one transaction (so the check and delete are atomic), or (b) add a concurrency token / row version. The cleanest minimal change: makeDeleteAsynctake the already-loaded entity instead of re-fetching. Just something to keep in your beautiful mind~ ♡💡 Little ideas (non-blocking)~
[MetadataEndpoints.cs
UpdateTagPUT handler, ~line 73] — The comment says "After an update we don't know usage without a re-query; tags have no Doujins nav on this path." ButTagService.UpdateAsyncdoes.Include(t => t.Doujins), sotag.Doujins.Count > 0is actually reliable here! The comment undersells the code. Fufu~ Either trust your own Include and remove the apologetic comment, or if you really want to be safe, the data is already there. ♪[MetadataEndpoints.cs —
ExtractDoujinIdsFromError] — Parsing structured data out of an error message string works, but it's a little fragile — if anyone tweaks the message format inMetadataUseCases.cs(e.g. changes[...]to(...)), the GUIDs silently vanish from the extensions. A cleaner approach would be to put the doujin IDs on theServiceResult<Unit>itself (e.g. aConflictDatapayload) so the endpoint reads structured data instead of regex-walking a string. Not urgent — the current approach passes all tests — but it made me smile at the cleverness and then frown at the coupling. ♡[MetadataDtos.cs —
PersonDto/CircleDto] —Aliasesis declared as non-nullableList<string>, which is correct and matches the entities' default[]. ButUpdatePersonDto/UpdateCircleDtohaveList<string>? Aliases(nullable) — so passingaliases: nullmeans "don't change", andaliases: []means "clear all aliases". That's a subtle but correct distinction. Well done! Just wanted to confirm I noticed it's intentional. ♪[ServiceResult.cs —
Unittype] — Cute! A proper unit type for void-result generics. Thereadonly record structwith a singletonValueis exactly the right shape. Fufu~ though I'll note C# doesn't need it (you could've usedboollike the other delete use cases), the type-safety is appreciated. It reads cleaner.[HypermediaHelpers.cs] — The conditional
deleteaction based onhasUsageis elegant HATEOAS. One tiny note: theListendpoints (MapGet("/")for tags/people/circles) build collection responses but don't includehasUsagein actions (they'd need the count per item). That's a follow-up consideration for the Flutter UI if it wants to show/hide delete buttons in a list view — not something this PR needs to address.Automated review by Jibril · 2026-07-01
CI/CD: passed for head
049cf43(forgejo-actions coverage #638, line 87.1%, branch 52.6%, 303 tests) · Local checks: skipped per CI-evidence policyStatic scan: clean (no secrets, shell injection, eval/exec, pickle, or SQL injection in added lines)
Fair point — you're right. "gender bender" / "tg" is exactly the kind of alias that makes sense for tags. I originally excluded them thinking Namespace covered categorization, but that's a different concept.
Working on it — adding tag aliases following the exact same pattern as Person/Circle:
List<string> Aliaseson Tag entity (done)POST/DELETE /api/tags/{id}/aliases[/{alias}]endpointsaddAliasHATEOAS action link on tag responsesWill push when done.
Björn pointed out tags also need aliases (e.g. 'gender bender' / 'tg'). - Tag entity now has List<string> Aliases (JSON column) - TagDto includes aliases field, all call sites updated - POST/DELETE /api/tags/{id}/aliases[/{alias}] endpoints - IAddTagAliasUseCase / IRemoveTagAliasUseCase - HATEOAS: addAlias action on tag responses - New migration: tags.aliases TEXT column - Fixed TagConfiguration to use static ValueConverter/ValueComparer (collection expressions are illegal in expression trees) - 6 new tests for tag alias add/remove 309 backend tests pass.Done — tag aliases added in commit
6a5681e.Tags now follow the exact same alias pattern as Person/Circle:
List<string> Aliaseson Tag entity (JSON column)POST /api/tags/{id}/aliases— add alias (idempotent, case-insensitive)DELETE /api/tags/{id}/aliases/{alias}— remove aliasaddAliasHATEOAS action on tag responsesaliasesfield in TagDtoSo
"gender bender"can have"tg"as an alias, and search will match both.309 tests pass, 0 errors.
Flutter Coverage
Total: 77.0% (1279 of 1662)
🔮 fufu~ Jibril reviewed your code!
Oh~ my~ ♡ A follow-up commit already, and it's exactly the right response to bjoern's #639! Adding tag aliases to match the Person/Circle pattern — the
"gender bender" → "tg"example is perfect. Search token expansion via aliases is going to make discovery so much nicer. Let me verify the tag path mirrors the established pattern faithfully... fufu~(This is a follow-up review of commit
6a5681eonly — my original review of the049cf43base stands unchanged.)Verdict: ✅ Looks good to me~ (with one thing that made my wings twitch)
No security issues. Static scan clean. The pattern is faithfully replicated —
AddTagAliasCommand/RemoveTagAliasCommandrecords, use cases returningServiceResult<Tag>, JSON-column EF config with a properValueComparer, a cleanAddTagAliasesmigration, 6 new integration tests covering add/remove/dupe/not-found/case-insensitive paths. The migration'sdefaultValueSql: "'[]'"matches Person/Circle exactly. ♪But fufu~ I did notice one inconsistency between the tag alias handlers and their Person/Circle siblings — let me show you~
💡 Little ideas (non-blocking)~
[
MetadataEndpoints.cstag alias handlers, ~lines 113 & 132 —hasUsage: tag.Doujins.Count > 0] —Doujinsis never loaded, sohasUsageis alwaysfalse. This is the one that matters! The tag alias endpoints readtag.Doujins.Countto decide whether to emit the conditionaldeleteHATEOAS action. ButTagService.AddAliasAsync/RemoveAliasAsyncfetch the tag with a baredb.Tags.FirstOrDefaultAsync(...)— no.Include(t => t.Doujins). Compare to the Person/Circle siblings, which do.Include(p => p.Doujins)in their service methods.The entity initializes
Doujinsto[], so this won't crash — but it means the tagdeletelink is always emitted in alias responses, even when the tag is referenced by doujins. That contradicts the referential-integrity design (a client following the link would hit the 409). The 6 new tests don't catch this because none of them create a doujin referencing the tag.Two-line fix, your choice: either (a) add
.Include(t => t.Doujins)to both methods inTagService.csto match the Person/Circle pattern, or (b) passhasUsage: falseexplicitly with a comment noting the alias path doesn't reload usage (like you already do for the create-tag path). Option (a) is the faithful mirror. Not blocking — the wrong link returns 409, not data loss — but it's a real behavioral divergence from the sibling endpoints. ♡[
TagConfiguration.cs—AliasesConverter/AliasesComparer] — Faithful copy of the Person/Circle value converter. ✨ TheValueComparerwithSequenceEqual+Aggregate(0, HashCode.Combine)+ defensive.ToList()snapshot is exactly right for change-tracking a mutableList<string>. Nothing to change — just wanted you to know I checked it line-by-line and it's correct. Fufu~[
MetadataEndpoints.csDELETE/{id:guid}/aliases/{alias}—Uri.UnescapeDataString(alias)] — Good call decoding the path segment before matching; theRemove_Tag_Alias_CaseInsensitivetest (usingfucked%20silly) proves the round-trip works. One subtle note: if an alias contains a literal/, routing would split it into segments and never reach this handler — but since aliases come from aTrim()'d string with no slash validation, that's an input-validation edge case, not a bug introduced here. Worth a[MaxLength]+ no-slash check eventually, but not today's problem. ♪Automated review by Jibril · 2026-07-01
CI/CD: passed for head
6a5681e(forgejo-actions coverage #638, line 88.2%, branch 52.9%, 309 tests; flutter coverage #657, 77.0%) · Local checks: skipped per CI-evidence policyStatic scan: clean on incremental diff (no secrets, shell injection, eval/exec, pickle, or SQL injection in added lines)