Skip to content

Commit 0f95fac

Browse files
committed
refactor(pg-codegen): keep the exclusion inside select, as the GraphQL ORM does
1 parent 75c1e70 commit 0f95fac

3 files changed

Lines changed: 148 additions & 246 deletions

File tree

‎postgres/pg-codegen/__fixtures__/generated/client.ts‎

Lines changed: 69 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -34,44 +34,39 @@ export interface TableSpec<App> {
3434
}
3535

3636
/**
37-
* `select: { id: true, name: true }` — read those fields and nothing else.
38-
* Same shape and same inference as the GraphQL ORM's select.
39-
*/
40-
export type SelectShape<App> = { [K in keyof App]?: boolean };
41-
42-
/**
43-
* `omit: { databaseId: true }` — read the record except those fields, for the
44-
* field a record has in general but this binding's table does not (a scope's
45-
* copy of a table without the scope key column) or that a caller must not
46-
* carry (a secret's id, row bookkeeping). Prisma's `omit`, and the reason it
47-
* exists there too: naming everything else is not the same statement.
37+
* Select shape — same as the GraphQL ORM, stated either way round:
38+
*
39+
* - `{ id: true, name: true }` reads those fields and nothing else;
40+
* - `{ databaseId: false }` reads every field except that one, for the field a
41+
* record has in general but this binding's table does not (a scope's copy of
42+
* a table without the scope key column) or that a caller must not carry (a
43+
* secret's id, row bookkeeping).
4844
*
49-
* Mutually exclusive with `select` — a projection already says what it reads.
45+
* Naming a field `true` is a projection and wins outright: the excluded ones
46+
* are simply not in it.
5047
*/
51-
export type OmitShape<App> = { [K in keyof App]?: boolean };
48+
export type SelectShape<App> = { [K in keyof App]?: boolean };
5249

5350
type TrueKeys<App, S> = {
5451
[K in keyof S]-?: S[K] extends true ? K & keyof App : never;
5552
}[keyof S];
5653

57-
/** The record minus what `omit` names, which is the record when it names none. */
58-
type OmitResult<App, O> = O extends OmitShape<App>
59-
? [TrueKeys<App, O>] extends [never]
60-
? App
61-
: Omit<App, TrueKeys<App, O>>
62-
: App;
63-
64-
/** Rows narrow to what the args state: `select` picks, `omit` subtracts. */
65-
export type SelectResult<
66-
App,
67-
S extends SelectShape<App> | undefined,
68-
O extends OmitShape<App> | undefined = undefined
69-
> =
54+
type FalseKeys<App, S> = {
55+
[K in keyof S]-?: S[K] extends false ? K & keyof App : never;
56+
}[keyof S];
57+
58+
/**
59+
* Rows narrow to what the select states: the picked fields, else the record
60+
* minus the excluded ones. No `select` returns the full record.
61+
*/
62+
export type SelectResult<App, S extends SelectShape<App> | undefined> =
7063
S extends SelectShape<App>
7164
? [TrueKeys<App, S>] extends [never]
72-
? OmitResult<App, O>
65+
? [FalseKeys<App, S>] extends [never]
66+
? App
67+
: Omit<App, FalseKeys<App, S>>
7368
: Pick<App, TrueKeys<App, S>>
74-
: OmitResult<App, O>;
69+
: App;
7570

7671
/** Per-field operators from the shared query-spec grammar. */
7772
export interface FieldOps<V> {
@@ -148,62 +143,34 @@ export type OrderDirection = 'ASC' | 'DESC';
148143
/** Order spec keyed by camelCase field names, e.g. `{ createdAt: 'DESC' }`. */
149144
export type OrderBy<App> = { [K in keyof App]?: OrderDirection };
150145

151-
/**
152-
* What a call reads. `select` and `omit` are alternatives, not layers, and
153-
* stating both is refused at the call rather than silently resolved.
154-
*/
155-
export interface Projection<
156-
App,
157-
S extends SelectShape<App> | undefined,
158-
O extends OmitShape<App> | undefined
159-
> {
160-
select?: S;
161-
omit?: O;
162-
}
163-
164-
export interface FindManyArgs<
165-
App,
166-
S extends SelectShape<App> | undefined,
167-
O extends OmitShape<App> | undefined
168-
> extends Projection<App, S, O> {
146+
export interface FindManyArgs<App, S extends SelectShape<App> | undefined> {
169147
where?: Where<App>;
148+
select?: S;
170149
orderBy?: OrderBy<App> | OrderBy<App>[];
171150
limit?: number;
172151
offset?: number;
173152
}
174153

175-
export interface FindFirstArgs<
176-
App,
177-
S extends SelectShape<App> | undefined,
178-
O extends OmitShape<App> | undefined
179-
> extends Projection<App, S, O> {
154+
export interface FindFirstArgs<App, S extends SelectShape<App> | undefined> {
180155
where?: Where<App>;
156+
select?: S;
181157
orderBy?: OrderBy<App> | OrderBy<App>[];
182158
}
183159

184-
export interface CreateArgs<
185-
App,
186-
S extends SelectShape<App> | undefined,
187-
O extends OmitShape<App> | undefined
188-
> extends Projection<App, S, O> {
160+
export interface CreateArgs<App, S extends SelectShape<App> | undefined> {
189161
data: Data<App>;
162+
select?: S;
190163
}
191164

192-
export interface UpdateArgs<
193-
App,
194-
S extends SelectShape<App> | undefined,
195-
O extends OmitShape<App> | undefined
196-
> extends Projection<App, S, O> {
165+
export interface UpdateArgs<App, S extends SelectShape<App> | undefined> {
197166
where: Where<App>;
198167
data: Data<App>;
168+
select?: S;
199169
}
200170

201-
export interface DeleteArgs<
202-
App,
203-
S extends SelectShape<App> | undefined,
204-
O extends OmitShape<App> | undefined
205-
> extends Projection<App, S, O> {
171+
export interface DeleteArgs<App, S extends SelectShape<App> | undefined> {
206172
where: Where<App>;
173+
select?: S;
207174
}
208175

209176
/** Thrown by `findFirstOrThrow` / `updateOrThrow` when no row matches. */
@@ -230,11 +197,10 @@ export class TableClient<App> {
230197
private readonly db: Queryable
231198
) {}
232199

233-
async findMany<
234-
S extends SelectShape<App> | undefined = undefined,
235-
O extends OmitShape<App> | undefined = undefined
236-
>(args: FindManyArgs<App, S, O> = {}): Promise<SelectResult<App, S, O>[]> {
237-
const fields = this.selectedFields(args);
200+
async findMany<S extends SelectShape<App> | undefined = undefined>(
201+
args: FindManyArgs<App, S> = {}
202+
): Promise<SelectResult<App, S>[]> {
203+
const fields = this.selectedFields(args.select);
238204
const query = this.baseQuery().select(fields.map(field => this.column(field)));
239205
const predicate = this.predicate(args.where);
240206
if (predicate) query.where(predicate);
@@ -246,18 +212,16 @@ export class TableClient<App> {
246212
return rows.map(row => this.decodeRow(row, fields));
247213
}
248214

249-
async findFirst<
250-
S extends SelectShape<App> | undefined = undefined,
251-
O extends OmitShape<App> | undefined = undefined
252-
>(args: FindFirstArgs<App, S, O> = {}): Promise<SelectResult<App, S, O> | null> {
253-
const rows = await this.findMany<S, O>({ ...args, limit: 1 });
215+
async findFirst<S extends SelectShape<App> | undefined = undefined>(
216+
args: FindFirstArgs<App, S> = {}
217+
): Promise<SelectResult<App, S> | null> {
218+
const rows = await this.findMany({ ...args, limit: 1 });
254219
return rows.length > 0 ? rows[0] : null;
255220
}
256221

257-
async findFirstOrThrow<
258-
S extends SelectShape<App> | undefined = undefined,
259-
O extends OmitShape<App> | undefined = undefined
260-
>(args: FindFirstArgs<App, S, O> = {}): Promise<SelectResult<App, S, O>> {
222+
async findFirstOrThrow<S extends SelectShape<App> | undefined = undefined>(
223+
args: FindFirstArgs<App, S> = {}
224+
): Promise<SelectResult<App, S>> {
261225
const row = await this.findFirst(args);
262226
if (row === null) throw new RowNotFoundError(this.spec.table, 'findFirstOrThrow');
263227
return row;
@@ -273,11 +237,10 @@ export class TableClient<App> {
273237
return Number(row.count);
274238
}
275239

276-
async create<
277-
S extends SelectShape<App> | undefined = undefined,
278-
O extends OmitShape<App> | undefined = undefined
279-
>(args: CreateArgs<App, S, O>): Promise<SelectResult<App, S, O>> {
280-
const fields = this.selectedFields(args);
240+
async create<S extends SelectShape<App> | undefined = undefined>(
241+
args: CreateArgs<App, S>
242+
): Promise<SelectResult<App, S>> {
243+
const fields = this.selectedFields(args.select);
281244
const query = this.baseQuery()
282245
.insert(this.encodeData(args.data))
283246
.returning(fields.map(field => this.column(field)));
@@ -286,11 +249,10 @@ export class TableClient<App> {
286249
return this.decodeRow(rows[0], fields);
287250
}
288251

289-
async update<
290-
S extends SelectShape<App> | undefined = undefined,
291-
O extends OmitShape<App> | undefined = undefined
292-
>(args: UpdateArgs<App, S, O>): Promise<SelectResult<App, S, O>[]> {
293-
const fields = this.selectedFields(args);
252+
async update<S extends SelectShape<App> | undefined = undefined>(
253+
args: UpdateArgs<App, S>
254+
): Promise<SelectResult<App, S>[]> {
255+
const fields = this.selectedFields(args.select);
294256
const query = this.baseQuery()
295257
.update(this.encodeData(args.data))
296258
.where(this.required(args.where, 'update'))
@@ -300,20 +262,18 @@ export class TableClient<App> {
300262
return rows.map(row => this.decodeRow(row, fields));
301263
}
302264

303-
async updateOrThrow<
304-
S extends SelectShape<App> | undefined = undefined,
305-
O extends OmitShape<App> | undefined = undefined
306-
>(args: UpdateArgs<App, S, O>): Promise<SelectResult<App, S, O>> {
265+
async updateOrThrow<S extends SelectShape<App> | undefined = undefined>(
266+
args: UpdateArgs<App, S>
267+
): Promise<SelectResult<App, S>> {
307268
const rows = await this.update(args);
308269
if (rows.length === 0) throw new RowNotFoundError(this.spec.table, 'updateOrThrow');
309270
return rows[0];
310271
}
311272

312-
async delete<
313-
S extends SelectShape<App> | undefined = undefined,
314-
O extends OmitShape<App> | undefined = undefined
315-
>(args: DeleteArgs<App, S, O>): Promise<SelectResult<App, S, O>[]> {
316-
const fields = this.selectedFields(args);
273+
async delete<S extends SelectShape<App> | undefined = undefined>(
274+
args: DeleteArgs<App, S>
275+
): Promise<SelectResult<App, S>[]> {
276+
const fields = this.selectedFields(args.select);
317277
const query = this.baseQuery()
318278
.delete()
319279
.where(this.required(args.where, 'delete'))
@@ -336,34 +296,26 @@ export class TableClient<App> {
336296
return this.spec.columnByField[field] ?? field;
337297
}
338298

339-
private selectedFields(args: {
340-
select?: SelectShape<App>;
341-
omit?: OmitShape<App>;
342-
}): (keyof App & string)[] {
299+
private selectedFields(select: SelectShape<App> | undefined): (keyof App & string)[] {
343300
const all = Object.keys(this.spec.columnByField) as (keyof App & string)[];
344-
if (args.select && args.omit) {
345-
throw new Error(
346-
`${this.spec.table}: state either 'select' or 'omit' — a projection already says what it reads.`
347-
);
348-
}
349-
if (args.select) {
350-
const picked = all.filter(field => args.select![field] === true);
351-
if (picked.length > 0) return picked;
352-
}
353-
if (args.omit) return all.filter(field => args.omit![field] !== true);
354-
return all;
301+
if (!select) return all;
302+
const picked = all.filter(field => select[field] === true);
303+
if (picked.length > 0) return picked;
304+
// Nothing picked: the select either excludes fields or says nothing at all,
305+
// and both read what is left.
306+
return all.filter(field => select[field] !== false);
355307
}
356308

357-
private decodeRow<S extends SelectShape<App> | undefined, O extends OmitShape<App> | undefined>(
309+
private decodeRow<S extends SelectShape<App> | undefined>(
358310
row: unknown,
359311
fields: (keyof App & string)[]
360-
): SelectResult<App, S, O> {
312+
): SelectResult<App, S> {
361313
const raw = row as Record<string, unknown>;
362314
const decoded: Record<string, unknown> = {};
363315
for (const field of fields) {
364316
decoded[field] = this.spec.fields[field](raw[this.column(field)]);
365317
}
366-
return decoded as SelectResult<App, S, O>;
318+
return decoded as SelectResult<App, S>;
367319
}
368320

369321
/**

‎postgres/pg-codegen/__tests__/client.test.ts‎

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -84,31 +84,29 @@ it('treats bare values as equalTo and null as isNull', async () => {
8484
expect(noEmail.map(u => u.username)).toEqual(['mallory']);
8585
});
8686

87-
it('omits a field the binding must not read, and keeps the rest', async () => {
87+
it('excludes a field the binding must not read, and keeps the rest', async () => {
8888
const created = await db.users.create({
8989
data: { username: 'nadia', email: 'nadia@example.com' },
9090
// A column this caller must not carry — the same shape a scope's copy of a
9191
// table without the scope key column needs.
92-
omit: { email: true }
92+
select: { email: false }
9393
});
9494
expect(created).toEqual({ id: created.id, username: 'nadia', createdAt: created.createdAt });
9595

9696
const found = await db.users.findFirstOrThrow({
9797
where: { id: created.id },
98-
omit: { email: true, createdAt: true }
98+
select: { email: false, createdAt: false }
9999
});
100100
expect(found).toEqual({ id: created.id, username: 'nadia' });
101-
// @ts-expect-error an omitted field is absent from the result type, not just the row
101+
// @ts-expect-error an excluded field is absent from the result type, not just the row
102102
const _absent: unknown = found.email;
103103

104-
// Stating both is a contradiction, not a layering — refused, never resolved.
105-
await expect(
106-
db.users.findFirstOrThrow({
107-
where: { id: created.id },
108-
select: { username: true },
109-
omit: { email: true }
110-
})
111-
).rejects.toThrow(/either 'select' or 'omit'/);
104+
// A field named `true` is a projection: the exclusions are simply not in it.
105+
const projected = await db.users.findFirstOrThrow({
106+
where: { id: created.id },
107+
select: { username: true, email: false }
108+
});
109+
expect(projected).toEqual({ username: 'nadia' });
112110
});
113111

114112
it('combines and/or/not filters', async () => {

0 commit comments

Comments
 (0)