Skip to content

feat: normalize genres to relational 1-to-many like tags (#169) - #173

Merged
mforce merged 10 commits into
mainfrom
feat/169-relational-genres
Aug 26, 2026
Merged

feat: normalize genres to relational 1-to-many like tags (#169)#173
mforce merged 10 commits into
mainfrom
feat/169-relational-genres

Conversation

@mforce

@mforce mforce commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Summary

Normalizes Movie.Genres and MusicAlbum.Genres from comma-separated strings to a relational 1-to-many relation like tags, adds genres to games, supports genre bulk updates, and updates filtering/client behavior.

Implements #169.

Changes

  • Domain: new shared Genre entity + ICollection<Genre> Genres navs on Movie, MusicAlbum, Game; both ICollectionEntry and ICollectionEntryDto expose Genres.
  • Schema-only migration (AddGenres): creates Genres (unique (OwnerId, Name)) + three join tables; drops the legacy Movies.Genres/MusicAlbums.Genres string columns. No data backfill — existing comma-separated genre values are discarded (owner decision 2026-08-24, "start fresh / ok wiping").
  • API: GenreResolver (lowercase/trim/dedupe, owner-scoped, mirroring TagResolver); genre resolution wired into the async create/update handler (after scalar Apply); string[]? Genres DTOs; exact-membership list filter (not substring); bulk genres replace-set with message-pinned 400.
  • Client: genre chips via TagInput on Movie/Album/Game forms + details; bulk-modal genres field; genres types as string[]; exact filter.
  • Tests: resolver unit test; shared-base bulk genre tests (set/clear/malformed-pinned/owner-scoped-404) running across all 3 media types; exact-membership filter test with negative case; test-support refactor.

Acceptance criteria

  1. Shared Genre entity + three join tables (Movie/MusicAlbum/Game). ✔
  2. Migration is schema-only — creates tables, drops old string columns, no backfill (owner decision 2026-08-24). ✔
  3. Games support genres. ✔
  4. Single-write and bulk writes share normalization (lowercase/trim/dedupe, owner-scoped). ✔
  5. Exact-membership filtering replaces substring matching. ✔
  6. Client uses chips across all three types. ✔
  7. Server + client CI commands pass. ✔ (local: Collectify.Tests 687/687, client 27 files/189 tests, build ✓, check:enums ✓; PostgresTests are Docker-only, pass in CI)
  8. Test-count ledger does not decrease (base 674 → 687, +13, 0 deleted). ✔

Verification (Phase 11, driver-run at HEAD c5f8d3a)

  • Server build clean (0 Warning(s) 0 Error(s)).
  • Collectify.Tests: 687/687 passed.
  • Client: npm test 27 files/189 passed; npm run build ✓; npm run check:enums parity ✓.
  • Collectify.PostgresTests: 9 failures are all Docker-connect (no local daemon); pass in CI — environment-only, unrelated to this change.
  • Mutations (driver-run, RED confirmed, restored, rebuilt green): M2 lowercasing single-write RED; M3 malformed-message pinning RED ×3; M4 bulk genres continue-guard RED ×3; M5 exact-filter negative case RED; M6 bulk lowercasing RED ×3. M1 (dropping GenreResolver .Distinct()) survived benignly.Distinct() is redundant because LINQ .Except() already dedupes and the (OwnerId,Name) unique index backstops; dedup behavior is correct. Not a correctness defect.

Attribution

  • Implementer (Claude Sonnet, dispatched via runbook): all product code + tests, committed in four commits.
  • Driver published the PR (sanctioned Phase 10b variant): the implementer completed all four commits but was SIGKILLed (OOM) before the push+PR step; the driver pushed the branch and opened this PR. Phase 11 verification was performed independently by the driver.

@mforce

mforce commented Aug 24, 2026

Copy link
Copy Markdown
Owner Author

@codex please review this PR (head c5f8d3a, branch feat/169-relational-genres) for #169 — normalize Movie/Music/Game genres to a relational 1-to-many like tags (shared Genre entity + 3 join tables, schema-only migration, exact-membership filter, bulk genres replace-set). Please return MERGE-BLOCKING or FOLLOW-UP verdicts per finding.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c5f8d3a9be

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +21 to +22
migrationBuilder.CreateTable(
name: "Genres",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge MERGE-BLOCKING: Add the PostgreSQL genre migration

In PostgreSQL deployments, CollectifyDbContextExtensions selects the separate Collectify.PostgresMigrations assembly, but this change adds AddGenres only under Collectify.Infrastructure. Consequently MigrateAsync() applies no genre schema change there, while every collection query now references Genres and its join tables, causing collection endpoints to fail with undefined-table errors after deployment. Add the equivalent migration and updated snapshot to the PostgreSQL migration project.

AGENTS.md reference: AGENTS.md:L200-L200

Useful? React with 👍 / 👎.

Comment on lines +19 to +23
var normalized = names
.Where(n => !string.IsNullOrWhiteSpace(n))
.Select(n => n.Trim().ToLowerInvariant())
.Distinct()
.ToList();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge FOLLOW-UP: Reject genre names longer than the schema limit

When a create, update, or bulk request contains a genre longer than 100 characters, this normalization passes it unchanged even though CollectifyDbContext configures Genre.Name with HasMaxLength(100). PostgreSQL therefore rejects the write during SaveChangesAsync and returns a 500, while SQLite accepts data that violates the declared model constraint; validate the normalized names and return a 400 before creating Genre rows.

Useful? React with 👍 / 👎.

@mforce

mforce commented Aug 25, 2026

Copy link
Copy Markdown
Owner Author

Disposition of Codex findings (head c5f8d3a):

P1 — missing Postgres genre migration: Owner decision 2026-08-24 — do NOT add a new Postgres migration. Postgres is treated as never-deployed (existing deployments are SQLite-only); the relational-genre schema will be folded into the existing InitialPostgres migration when Postgres is first stood up, so PG-the-initial-migration will reflect it. Resolving as agreed scope, not adding a PG migration in this PR.

P2 — genre name longer than schema limit (100): Deferred as a fast-follow (Pre-existing TagResolver has the same behavior; will file an issue).

@mforce

mforce commented Aug 25, 2026

Copy link
Copy Markdown
Owner Author

Round-2: head moved to e237c23 (test-only fix commits addressing the round-1 review).

  • 43c12c1: hardened the shared bulk-genre tests (atomic non-write assert, replace-vs-merge discrimination, null-clears check) + added a Games exact-filter test.
  • e237c23: corrected the atomic assertion to read actual genre-link rows via a per-type GenreLinkCountAsync helper (the round-1 version read a navigation that is never loaded, so it was vacuous) — verified load-bearing via a faithful product mutation (all 3 subtypes went RED) then reverted.

Product code is unchanged since your round-1 review; only the four allow-listed test files changed. Please re-review PR #173 at head e237c23.

For reference, your round-1 findings are dispositioned:

  • P1 missing Postgres genre migration: owner decided NO new PG migration (PG treated as never-deployed; SQLite-only); genre schema folds into InitialPostgres when PG is first stood up.
  • P2 genre-name length guard: deferred as a fast-follow issue (matches existing TagResolver behavior).

@mforce

mforce commented Aug 25, 2026

Copy link
Copy Markdown
Owner Author

Round-3: head moved to cc10240 (test-only fix commit addressing a round-2 review finding).

cc10240: rewrote BulkUpdate_Genres_Null_Clears to assert DB genre-link counts via GenreLinkCountAsync (1 after set, 0 after clear) instead of the HTTP response body, and added a mixed-case ?genre=SCI-FI exact-filter assertion to Games. Driver-verified load-bearing via mutation (silencing the SET write reddened all 3 subtypes; restored green). Suite 688/688.

Product code is unchanged since your round-1 review; only the allow-listed test files changed. Please re-review PR #173 at head cc10240.

@mforce

mforce commented Aug 25, 2026

Copy link
Copy Markdown
Owner Author

Round-4 (confirming): head moved to 4f32847 (test-only, +5 lines) addressing the round-3 follow-up.

4f32847: added two DB-persistence asserts (GenreLinkCountAsync == 2) to BulkUpdate_Genres_SetsLowercasedDistinct_SetAcrossAll, closing the last response-body-only genre assertion — same standard already applied to the atomic and null-clear tests. Driver-verified: build clean, suite 688/688, both specified mutation variants went RED then restored.

Product code is unchanged since your round-1 review; only allow-listed test files changed. Please re-review at head 4f32847.

…shot, regenerate PG manifests

The runtime EF model gained Genre + GameGenre/GenreMovie/GenreMusicAlbum but
the Collectify.PostgresMigrations snapshot still had only the legacy
comma-separated Genres string columns, so db.Database.MigrateAsync() hit a
model mismatch and the app never came up against Postgres (CI smoke gate
failure). Since no Postgres deployment has ever run these migrations, fold
the genre schema into the first migration (InitialPostgres) in place rather
than adding a 6th migration, keeping the 5-migration lineage and all
historical Postgres tests (bootstrap, lineage, MultiDigitalStores) intact.

Regenerates the 12 catalog-manifest.json fixtures (and provenance.json) that
PostgresMigrationLineageTests/PostgresProviderSelectionTests byte-compare
against, since editing InitialPostgres changes the schema at every pinned
migration prefix.
@mforce

mforce commented Aug 26, 2026

Copy link
Copy Markdown
Owner Author

@codex — re-requesting review on new head bbf4b96 (was c5f8d3a). Disposition of your two findings from review 5012634046:

P1 (MERGE-BLOCKING): Add the PostgreSQL genre migration — RESOLVED. Per owner decision (2026-08-24): no new additive PG migration; PG is treated as never-deployed, so the genre schema (Genre + GameGenre/GenreMovie/GenreMusicAlbum + unique (OwnerId,Name) index) was folded into the FIRST Postgres migration 20260528000000_InitialPostgres, and the PG model snapshot + Designer were synced to the runtime model. Verified: has-pending-model-changes = No changes on the Npgsql provider; full Docker-backed Postgres suite 13/13 (incl. all 5 MigrationPrefix_MatchesHistoricalCatalogManifest cases); SQLite 688/688; and the Image build + Trivy scan CI gate that your P1 was causing now PASSES.

P2 (FOLLOW-UP): Reject genre names >100 chars — ACKNOWLEDGED, still OPEN. Being filed as a fast-follow issue (normalized genre-name length guard → 400). Not a blocker for this PR.

Please re-review head bbf4b96 and give your verdict on the unchanged head.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bbf4b96b2e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

{
var ownerId = users.GetUserId(ctx.User)!;
var q = cfg.Set(db).AsNoTracking().Include(e => e.Tags).Where(e => e.OwnerId == ownerId);
var q = cfg.Set(db).AsNoTracking().Include(e => e.Tags).Include(e => e.Genres).Where(e => e.OwnerId == ownerId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Split the dual collection include

When list entries have both tags and genres, these sibling collection includes execute as one joined query, so each item materializes roughly tag count × genre count database rows before EF de-duplicates them. Since this endpoint takes up to 500 items, ordinary collections with several values in each relation can make list requests disproportionately slow and memory-heavy; use a split query or project the two collections separately.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disposition: valid performance finding, not a blocker (P2/FOLLOW-UP). Filed as #175 - split the dual Include(Tags)+Include(Genres) list query (cartesian row blowup). Fast-follow. @codex note on new head 044a612.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Testing

  • git diff --check
  • ⚠️ dotnet test tests/Collectify.Tests/Collectify.Tests.csproj --locked-mode --no-restore --filter 'FullyQualifiedName~MoviesEndpointsTests|FullyQualifiedName~MusicEndpointsTests|FullyQualifiedName~GamesEndpointsTests' (the .NET SDK is unavailable in this environment)
  • git status --short --branch (working tree clean)

View task →

public DateOnly? LastPlayedOn { get; set; }

public ICollection<Tag> Tags { get; set; } = new List<Tag>();
public ICollection<Genre> Genres { get; set; } = new List<Genre>();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Populate the new genre relation during IGDB backfill

When a Steam-imported game is enriched by IgdbBackfillRunner, the provider's genres still go through the unchanged ComposeDescription workaround, whose comment explicitly assumes that games have no dedicated genre field. As a result, backfilled games receive genre text in Description but this new collection remains empty, so genre chips and ?genre= filtering do not reflect metadata already returned by IGDB; update the backfill merge to resolve and attach owner-scoped genres.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disposition: valid finding, not a blocker (P2/FOLLOW-UP). Filed as #174 - IGDB backfill should populate the relational Genres collection instead of writing genre text to Description. Fast-follow. @codex note on new head 044a612.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Testing

  • mise exec dotnet@10.0.107 -- dotnet test tests/Collectify.Tests/Collectify.Tests.csproj --filter FullyQualifiedName~IgdbBackfillRunnerTests — 22 passed.
  • git diff --check
  • git status --short --branch — clean working tree on work.

View task →

…cker)

Adds a Collectify.PostgresTests fact asserting context.Database.HasPendingModelChanges()
is false against the pinned postgres:17-alpine Testcontainers image -- the same in-process
diff `dotnet ef migrations has-pending-model-changes` runs, comparing the runtime model to
the last migration's TargetModel. RED-proofed against both annotation-only model drift
(while column/manifest tests stayed green) and a Genre-entity deletion from the final PG
ModelSnapshot.cs.
@mforce

mforce commented Aug 26, 2026

Copy link
Copy Markdown
Owner Author

@codex — new head 044a612 (Fix-7, addressing your round-2 blocker). The PG snapshot/model-parity guard is now a COMMITTED test: PostgresSnapshotParityTests.RuntimeModel_HasNoPendingChanges_AgainstLatestMigrationSnapshot asserts context.Database.HasPendingModelChanges() is false on the Npgsql provider vs the last migration TargetModel (catches non-DDL drift too), RED-proven against both annotation-only and DDL drift. Your 2 prior P2s are dispositioned (filed #174, #175). Please re-review head 044a612.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 044a612600

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +401 to +402
migrationBuilder.CreateTable(
name: "GameGenre",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add a new PostgreSQL upgrade migration

When upgrading a PostgreSQL database that already records 20260528000000_InitialPostgres, EF will never execute this newly inserted CreateTable, and the unchanged five-ID lineage contains no later genre migration. Fresh evidence at head 044a612 is that the genre DDL was folded exclusively into that already-applied initial migration; the new parity test only compares the runtime model with the rewritten snapshot and therefore cannot detect this upgrade path. Such installations retain the legacy columns but lack Genres and its join tables, so collection queries fail after deployment; add a forward migration instead of rewriting the initial one.

AGENTS.md reference: AGENTS.md:L200-L200

Useful? React with 👍 / 👎.

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