From f007180315f5c0fbc8b8634df1dd2b67df611ba0 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sat, 15 Aug 2026 05:26:16 +0000 Subject: [PATCH] fix(pg-codegen): read unqualified on an empty where, refuse an empty write filter --- .../__fixtures__/generated/client.ts | 32 ++++++++++++++++--- postgres/pg-codegen/__tests__/client.test.ts | 22 +++++++++++++ .../pg-codegen/src/emit/templates/client.ts | 32 ++++++++++++++++--- 3 files changed, 78 insertions(+), 8 deletions(-) diff --git a/postgres/pg-codegen/__fixtures__/generated/client.ts b/postgres/pg-codegen/__fixtures__/generated/client.ts index e6075ab7a..741bd1478 100644 --- a/postgres/pg-codegen/__fixtures__/generated/client.ts +++ b/postgres/pg-codegen/__fixtures__/generated/client.ts @@ -182,7 +182,8 @@ export class TableClient { ): Promise[]> { const fields = this.selectedFields(args.select); const query = this.baseQuery().select(fields.map(field => this.column(field))); - if (args.where) query.where(this.filter(args.where)); + const predicate = this.predicate(args.where); + if (predicate) query.where(predicate); this.applyOrderBy(query, args.orderBy); if (args.limit !== undefined) query.limit(args.limit); if (args.offset !== undefined) query.offset(args.offset); @@ -208,7 +209,8 @@ export class TableClient { async count(where?: Where): Promise { const query = this.baseQuery().select([]).selectExpr('count', fn('count', [lit(1)])); - if (where) query.where(this.filter(where)); + const predicate = this.predicate(where); + if (predicate) query.where(predicate); const { text, values } = query.build(); const { rows } = await this.db.query(text, values); const row = rows[0] as { count: string | number }; @@ -233,7 +235,7 @@ export class TableClient { const fields = this.selectedFields(args.select); const query = this.baseQuery() .update(this.encodeData(args.data)) - .where(this.filter(args.where)) + .where(this.required(args.where, 'update')) .returning(fields.map(field => this.column(field))); const { text, values } = query.build(); const { rows } = await this.db.query(text, values); @@ -254,7 +256,7 @@ export class TableClient { const fields = this.selectedFields(args.select); const query = this.baseQuery() .delete() - .where(this.filter(args.where)) + .where(this.required(args.where, 'delete')) .returning(fields.map(field => this.column(field))); const { text, values } = query.build(); const { rows } = await this.db.query(text, values); @@ -293,6 +295,28 @@ export class TableClient { return decoded as SelectResult; } + /** + * The predicate to apply, or nothing to apply: a caller that spreads a + * conditional key column (a scope that records none) states an empty filter, + * and an unqualified read is what it asked for. + */ + private predicate(where: Where | undefined): Filter | undefined { + if (!where) return undefined; + const filter = this.filter(where); + return Object.keys(filter).length > 0 ? filter : undefined; + } + + /** A write says which rows: an empty filter would mean the whole table. */ + private required(where: Where, operation: string): Filter { + const predicate = this.predicate(where); + if (!predicate) { + throw new Error( + `${this.spec.table}.${operation}: refusing an empty where filter, which would match every row` + ); + } + return predicate; + } + private filter(where: Where): Filter { const filter: Filter = {}; for (const [key, value] of Object.entries(where)) { diff --git a/postgres/pg-codegen/__tests__/client.test.ts b/postgres/pg-codegen/__tests__/client.test.ts index f745ff62a..c8dcf5027 100644 --- a/postgres/pg-codegen/__tests__/client.test.ts +++ b/postgres/pg-codegen/__tests__/client.test.ts @@ -120,6 +120,28 @@ it('findFirstOrThrow raises on no match', async () => { await expect(db.users.findFirstOrThrow({ where: { id: 999999 } })).rejects.toThrow(RowNotFoundError); }); +it('reads unqualified when a conditional filter spreads to nothing', async () => { + await db.users.create({ data: { username: 'karl' } }); + const keyColumn: string | null = null; + + const rows = await db.users.findMany({ + where: { ...(keyColumn ? { username: keyColumn } : {}) }, + select: { username: true } + }); + expect(rows.map(u => u.username)).toEqual(['karl']); + expect(await db.users.count({})).toBe(1); +}); + +it('refuses a write whose filter would match every row', async () => { + await db.users.create({ data: { username: 'lena' } }); + + await expect(db.users.update({ where: {}, data: { email: 'x@example.com' } })).rejects.toThrow( + /refusing an empty where filter/ + ); + await expect(db.users.delete({ where: {} })).rejects.toThrow(/refusing an empty where filter/); + expect(await db.users.count()).toBe(1); +}); + it('rebinds to another connection with $with', async () => { const rebound = db.$with(pg.client); await rebound.users.create({ data: { username: 'judy' } }); diff --git a/postgres/pg-codegen/src/emit/templates/client.ts b/postgres/pg-codegen/src/emit/templates/client.ts index 87bb4a0bb..a6d69d35c 100644 --- a/postgres/pg-codegen/src/emit/templates/client.ts +++ b/postgres/pg-codegen/src/emit/templates/client.ts @@ -182,7 +182,8 @@ export class TableClient { ): Promise[]> { const fields = this.selectedFields(args.select); const query = this.baseQuery().select(fields.map(field => this.column(field))); - if (args.where) query.where(this.filter(args.where)); + const predicate = this.predicate(args.where); + if (predicate) query.where(predicate); this.applyOrderBy(query, args.orderBy); if (args.limit !== undefined) query.limit(args.limit); if (args.offset !== undefined) query.offset(args.offset); @@ -208,7 +209,8 @@ export class TableClient { async count(where?: Where): Promise { const query = this.baseQuery().select([]).selectExpr('count', fn('count', [lit(1)])); - if (where) query.where(this.filter(where)); + const predicate = this.predicate(where); + if (predicate) query.where(predicate); const { text, values } = query.build(); const { rows } = await this.db.query(text, values); const row = rows[0] as { count: string | number }; @@ -233,7 +235,7 @@ export class TableClient { const fields = this.selectedFields(args.select); const query = this.baseQuery() .update(this.encodeData(args.data)) - .where(this.filter(args.where)) + .where(this.required(args.where, 'update')) .returning(fields.map(field => this.column(field))); const { text, values } = query.build(); const { rows } = await this.db.query(text, values); @@ -254,7 +256,7 @@ export class TableClient { const fields = this.selectedFields(args.select); const query = this.baseQuery() .delete() - .where(this.filter(args.where)) + .where(this.required(args.where, 'delete')) .returning(fields.map(field => this.column(field))); const { text, values } = query.build(); const { rows } = await this.db.query(text, values); @@ -293,6 +295,28 @@ export class TableClient { return decoded as SelectResult; } + /** + * The predicate to apply, or nothing to apply: a caller that spreads a + * conditional key column (a scope that records none) states an empty filter, + * and an unqualified read is what it asked for. + */ + private predicate(where: Where | undefined): Filter | undefined { + if (!where) return undefined; + const filter = this.filter(where); + return Object.keys(filter).length > 0 ? filter : undefined; + } + + /** A write says which rows: an empty filter would mean the whole table. */ + private required(where: Where, operation: string): Filter { + const predicate = this.predicate(where); + if (!predicate) { + throw new Error( + `${this.spec.table}.${operation}: refusing an empty where filter, which would match every row` + ); + } + return predicate; + } + private filter(where: Where): Filter { const filter: Filter = {}; for (const [key, value] of Object.entries(where)) {