Skip to content

Commit 4e2cb50

Browse files
authored
Merge pull request #1735 from constructive-io/feat/pg-client-select-exclusions
feat(pg-codegen): state a select as exclusions, not only a key list
2 parents c9f55eb + 0f95fac commit 4e2cb50

3 files changed

Lines changed: 79 additions & 8 deletions

File tree

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

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,18 +33,38 @@ export interface TableSpec<App> {
3333
jsonFields?: readonly string[];
3434
}
3535

36-
/** Select shape: `{ id: true, name: true }` — same as the GraphQL ORM. */
36+
/**
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).
44+
*
45+
* Naming a field `true` is a projection and wins outright: the excluded ones
46+
* are simply not in it.
47+
*/
3748
export type SelectShape<App> = { [K in keyof App]?: boolean };
3849

3950
type TrueKeys<App, S> = {
4051
[K in keyof S]-?: S[K] extends true ? K & keyof App : never;
4152
}[keyof S];
4253

43-
/** Rows narrow to the selected fields; no `select` returns the full record. */
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+
*/
4462
export type SelectResult<App, S extends SelectShape<App> | undefined> =
4563
S extends SelectShape<App>
4664
? [TrueKeys<App, S>] extends [never]
47-
? App
65+
? [FalseKeys<App, S>] extends [never]
66+
? App
67+
: Omit<App, FalseKeys<App, S>>
4868
: Pick<App, TrueKeys<App, S>>
4969
: App;
5070

@@ -280,7 +300,10 @@ export class TableClient<App> {
280300
const all = Object.keys(this.spec.columnByField) as (keyof App & string)[];
281301
if (!select) return all;
282302
const picked = all.filter(field => select[field] === true);
283-
return picked.length > 0 ? picked : all;
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);
284307
}
285308

286309
private decodeRow<S extends SelectShape<App> | undefined>(

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

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

87+
it('excludes a field the binding must not read, and keeps the rest', async () => {
88+
const created = await db.users.create({
89+
data: { username: 'nadia', email: 'nadia@example.com' },
90+
// A column this caller must not carry — the same shape a scope's copy of a
91+
// table without the scope key column needs.
92+
select: { email: false }
93+
});
94+
expect(created).toEqual({ id: created.id, username: 'nadia', createdAt: created.createdAt });
95+
96+
const found = await db.users.findFirstOrThrow({
97+
where: { id: created.id },
98+
select: { email: false, createdAt: false }
99+
});
100+
expect(found).toEqual({ id: created.id, username: 'nadia' });
101+
// @ts-expect-error an excluded field is absent from the result type, not just the row
102+
const _absent: unknown = found.email;
103+
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' });
110+
});
111+
87112
it('combines and/or/not filters', async () => {
88113
await db.users.create({ data: { username: 'frank' } });
89114
await db.users.create({ data: { username: 'grace' } });

‎postgres/pg-codegen/src/emit/templates/client.ts‎

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,18 +33,38 @@ export interface TableSpec<App> {
3333
jsonFields?: readonly string[];
3434
}
3535

36-
/** Select shape: `{ id: true, name: true }` — same as the GraphQL ORM. */
36+
/**
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).
44+
*
45+
* Naming a field `true` is a projection and wins outright: the excluded ones
46+
* are simply not in it.
47+
*/
3748
export type SelectShape<App> = { [K in keyof App]?: boolean };
3849

3950
type TrueKeys<App, S> = {
4051
[K in keyof S]-?: S[K] extends true ? K & keyof App : never;
4152
}[keyof S];
4253

43-
/** Rows narrow to the selected fields; no `select` returns the full record. */
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+
*/
4462
export type SelectResult<App, S extends SelectShape<App> | undefined> =
4563
S extends SelectShape<App>
4664
? [TrueKeys<App, S>] extends [never]
47-
? App
65+
? [FalseKeys<App, S>] extends [never]
66+
? App
67+
: Omit<App, FalseKeys<App, S>>
4868
: Pick<App, TrueKeys<App, S>>
4969
: App;
5070

@@ -280,7 +300,10 @@ export class TableClient<App> {
280300
const all = Object.keys(this.spec.columnByField) as (keyof App & string)[];
281301
if (!select) return all;
282302
const picked = all.filter(field => select[field] === true);
283-
return picked.length > 0 ? picked : all;
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);
284307
}
285308

286309
private decodeRow<S extends SelectShape<App> | undefined>(

0 commit comments

Comments
 (0)