From 20b85381fea274dc2abdc910c9f65f5a63861de8 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Fri, 31 Jul 2026 20:57:52 +0200 Subject: [PATCH] fix(db): accumulate many-includes and nest relations in prisma facade Co-authored-by: Cursor --- src/lib/ci-workflow-contract.test.ts | 2 +- src/lib/prisma-facade-relations.test.ts | 50 ++++++++++++++++++++++ src/lib/prisma-facade.ts | 56 ++++++++++++++++++++----- 3 files changed, 97 insertions(+), 11 deletions(-) create mode 100644 src/lib/prisma-facade-relations.test.ts diff --git a/src/lib/ci-workflow-contract.test.ts b/src/lib/ci-workflow-contract.test.ts index 2e951208..37a74260 100644 --- a/src/lib/ci-workflow-contract.test.ts +++ b/src/lib/ci-workflow-contract.test.ts @@ -6,7 +6,7 @@ describe("CI workflow", () => { const workflow = readFileSync( resolve(process.cwd(), ".gitea/workflows/ci.yaml"), "utf8", - ); + ).replace(/\r\n/g, "\n"); it("runs check then production deploy on push to main", () => { expect(workflow).toContain("pnpm biome:lint"); diff --git a/src/lib/prisma-facade-relations.test.ts b/src/lib/prisma-facade-relations.test.ts new file mode 100644 index 00000000..d5706a5b --- /dev/null +++ b/src/lib/prisma-facade-relations.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +/** + * Mirrors the many-relation grouping fix in prisma-facade queryRelationRows. + * Keeps the contract local so we don't export private helpers. + */ +function groupRelationRows( + rows: Array<{ __fk: string; id: number }>, + mode: "one" | "many", +): Map { + const map = new Map(); + for (const row of rows) { + const key = String(row.__fk); + const cleaned = { id: row.id }; + if (mode === "many") { + const list = map.get(key); + if (Array.isArray(list)) list.push(cleaned); + else map.set(key, [cleaned]); + } else if (!map.has(key)) { + map.set(key, cleaned); + } + } + return map; +} + +describe("prisma-facade relation grouping", () => { + it("accumulates many rows per parent key", () => { + const map = groupRelationRows( + [ + { __fk: "1", id: 10 }, + { __fk: "1", id: 11 }, + { __fk: "2", id: 20 }, + ], + "many", + ); + expect(map.get("1")).toEqual([{ id: 10 }, { id: 11 }]); + expect(map.get("2")).toEqual([{ id: 20 }]); + }); + + it("keeps a single row for one relations", () => { + const map = groupRelationRows( + [ + { __fk: "1", id: 10 }, + { __fk: "1", id: 11 }, + ], + "one", + ); + expect(map.get("1")).toEqual({ id: 10 }); + }); +}); diff --git a/src/lib/prisma-facade.ts b/src/lib/prisma-facade.ts index cf45de2f..44ff38a9 100644 --- a/src/lib/prisma-facade.ts +++ b/src/lib/prisma-facade.ts @@ -270,24 +270,36 @@ async function queryRelationRows( rel: any, opts: any, keyValues: unknown[], + mode: "one" | "many", ): Promise> { const join = resolveJoin(table, rel); const byCol = join.fkCols[0]; - const condition = inArray(byCol, coerceArray(byCol, keyValues)); + const parts = [ + inArray(byCol, coerceArray(byCol, keyValues)), + buildCondition(rel.referencedTable, opts.where), + ].filter(Boolean); + const condition = parts.length === 1 ? parts[0] : and(...parts); const sel = projection(rel.referencedTable, opts.select); const orderBy = buildOrderBy(rel.referencedTable, opts.orderBy); let stmt: any = sel ? client.select({ ...sel, __fk: byCol }).from(rel.referencedTable) : client.select().from(rel.referencedTable); - stmt = stmt.where(condition); + if (condition) stmt = stmt.where(condition); if (orderBy.length) stmt = stmt.orderBy(...orderBy); const rows = (await stmt) as any[]; const map = new Map(); for (const row of rows) { - const key = row.__fk ?? row[colKey(byCol)]; - map.set(String(key), sel ? pickRow(row, opts.select) : row); + const key = String(row.__fk ?? row[colKey(byCol)]); + const cleaned = sel ? pickRow(row, opts.select) : row; + if (mode === "many") { + const list = map.get(key); + if (Array.isArray(list)) list.push(cleaned); + else map.set(key, [cleaned]); + } else if (!map.has(key)) { + map.set(key, cleaned); + } } return map; } @@ -386,13 +398,25 @@ async function attachIncludes( for (const row of rows) row[rel.fieldName] = null; continue; } - const map = await queryRelationRows(client, table, rel, opts, values); + const map = await queryRelationRows( + client, + table, + rel, + opts, + values, + "one", + ); const join = resolveJoin(table, rel); const refCol = join.refCols[0]; + const related: any[] = []; for (const row of rows) { const refValue = row[colKey(refCol)]; - row[rel.fieldName] = - refValue == null ? null : (map.get(String(refValue)) ?? null); + const hit = refValue == null ? null : (map.get(String(refValue)) ?? null); + row[rel.fieldName] = hit; + if (hit) related.push(hit); + } + if (opts.include && related.length > 0) { + await attachIncludes(client, rel.referencedTable, related, opts.include); } } @@ -402,13 +426,25 @@ async function attachIncludes( for (const row of rows) row[rel.fieldName] = []; continue; } - const map = await queryRelationRows(client, table, rel, opts, values); + const map = await queryRelationRows( + client, + table, + rel, + opts, + values, + "many", + ); const join = resolveJoin(table, rel); const refCol = join.refCols[0]; + const related: any[] = []; for (const row of rows) { const refValue = row[colKey(refCol)]; - row[rel.fieldName] = - refValue == null ? [] : (map.get(String(refValue)) ?? []); + const list = refValue == null ? [] : (map.get(String(refValue)) ?? []); + row[rel.fieldName] = list; + for (const item of list) related.push(item); + } + if (opts.include && related.length > 0) { + await attachIncludes(client, rel.referencedTable, related, opts.include); } }