@zeno-lib/db 0.3.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/clients-BlUEH7QJ.d.mts +58 -0
- package/dist/clients-CdPr3mCZ.mjs +197 -0
- package/dist/errors.d.mts +70 -0
- package/dist/errors.mjs +78 -0
- package/dist/function-names-BDc-AuFm.mjs +10 -0
- package/dist/function-names-hv2mj_x1.d.mts +5 -0
- package/dist/index.d.mts +2 -42
- package/dist/index.mjs +2 -167
- package/dist/next.d.mts +182 -0
- package/dist/next.mjs +167 -0
- package/dist/query.d.mts +103 -0
- package/dist/query.mjs +115 -0
- package/dist/schema.d.mts +20 -11
- package/dist/schema.mjs +11 -12
- package/package.json +31 -6
- package/src/action-result.test.ts +88 -0
- package/src/action-result.ts +109 -0
- package/src/casing.test.ts +43 -20
- package/src/clients.ts +60 -5
- package/src/database-url.test.ts +88 -0
- package/src/define-action.ts +137 -0
- package/src/errors.test.ts +95 -0
- package/src/errors.ts +133 -0
- package/src/function-names.ts +21 -0
- package/src/index.ts +2 -0
- package/src/next.test-d.ts +60 -0
- package/src/next.test.ts +296 -0
- package/src/next.ts +134 -0
- package/src/query.test-d.ts +20 -0
- package/src/query.test.ts +261 -0
- package/src/query.ts +252 -0
- package/src/schema-zod.test.ts +37 -0
- package/src/schema.test-d.ts +24 -0
- package/src/schema.ts +51 -21
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { defineRelations, type SQL } from "drizzle-orm"
|
|
2
|
+
import { PgDialect, text } from "drizzle-orm/pg-core"
|
|
3
|
+
import { drizzle } from "drizzle-orm/postgres-js"
|
|
4
|
+
import { describe, expect, it } from "vitest"
|
|
5
|
+
import {
|
|
6
|
+
definedValues,
|
|
7
|
+
excludedSet,
|
|
8
|
+
functionPermissionTables,
|
|
9
|
+
maybeOne,
|
|
10
|
+
one,
|
|
11
|
+
type QueryExecutor,
|
|
12
|
+
selectFunctionPermissions,
|
|
13
|
+
} from "./query.ts"
|
|
14
|
+
import { primaryId, schema, table, view } from "./schema.ts"
|
|
15
|
+
|
|
16
|
+
// No connection anywhere: `drizzle.mock()` builds statements for `.toSQL()`,
|
|
17
|
+
// and `PgDialect` renders a bare `SQL` the way the driver would receive it.
|
|
18
|
+
const db = drizzle.mock()
|
|
19
|
+
const dialect = new PgDialect()
|
|
20
|
+
const render = (query: SQL) => dialect.sqlToQuery(query)
|
|
21
|
+
|
|
22
|
+
const contacts = table("contacts", {
|
|
23
|
+
displayName: text(),
|
|
24
|
+
email: text(),
|
|
25
|
+
id: primaryId("sequential"),
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const dealsProperties = table("deals_properties", {
|
|
29
|
+
dealId: text().notNull(),
|
|
30
|
+
propertyId: text().notNull(),
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe("one", () => {
|
|
34
|
+
it("returns the only row", () => {
|
|
35
|
+
expect(one([{ id: 1 }])).toEqual({ id: 1 })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("throws on none and on several", () => {
|
|
39
|
+
expect(() => one([])).toThrow("Expected exactly one row, received 0")
|
|
40
|
+
expect(() => one([1, 2])).toThrow("Expected exactly one row, received 2")
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe("maybeOne", () => {
|
|
45
|
+
it("returns the row, or undefined for none", () => {
|
|
46
|
+
expect(maybeOne([{ id: 1 }])).toEqual({ id: 1 })
|
|
47
|
+
expect(maybeOne([])).toBeUndefined()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it("throws on several", () => {
|
|
51
|
+
expect(() => maybeOne([1, 2])).toThrow(
|
|
52
|
+
"Expected at most one row, received 2"
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe("definedValues", () => {
|
|
58
|
+
it("drops undefined keys and keeps null", () => {
|
|
59
|
+
expect(definedValues({ a: 1, b: undefined, c: null })).toEqual({
|
|
60
|
+
a: 1,
|
|
61
|
+
c: null,
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe("excludedSet", () => {
|
|
67
|
+
const upsert = (values: Record<string, unknown>, set: Record<string, SQL>) =>
|
|
68
|
+
db
|
|
69
|
+
.insert(contacts)
|
|
70
|
+
.values({ id: 1, ...values })
|
|
71
|
+
.onConflictDoUpdate({ set, target: contacts.id })
|
|
72
|
+
.toSQL().sql
|
|
73
|
+
|
|
74
|
+
it("writes back every supplied column by its database name, never the target", () => {
|
|
75
|
+
const values = { displayName: "Ada", email: "ada@example.com", id: 1 }
|
|
76
|
+
|
|
77
|
+
expect(upsert(values, excludedSet(contacts, values))).toContain(
|
|
78
|
+
'do update set "display_name" = excluded."display_name", "email" = excluded."email"'
|
|
79
|
+
)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("skips a key carrying undefined", () => {
|
|
83
|
+
const values = { displayName: undefined, email: "ada@example.com" }
|
|
84
|
+
const set = excludedSet(contacts, values)
|
|
85
|
+
|
|
86
|
+
expect(Object.keys(set)).toEqual(["email"])
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it("falls back to id = excluded.id when nothing else is supplied", () => {
|
|
90
|
+
const set = excludedSet(contacts, { displayName: undefined, id: 1 })
|
|
91
|
+
|
|
92
|
+
expect(upsert({}, set)).toContain('do update set "id" = excluded."id"')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("takes another conflict target", () => {
|
|
96
|
+
const values = { displayName: "Ada", email: "ada@example.com" }
|
|
97
|
+
const set = excludedSet(contacts, values, { target: "email" })
|
|
98
|
+
|
|
99
|
+
expect(Object.keys(set)).toEqual(["displayName"])
|
|
100
|
+
expect(render(one(Object.values(set))).sql).toBe('excluded."display_name"')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it("falls back to the first target column that exists", () => {
|
|
104
|
+
const set = excludedSet(
|
|
105
|
+
dealsProperties,
|
|
106
|
+
{ dealId: "d", propertyId: "p" },
|
|
107
|
+
{ target: ["dealId", "propertyId"] }
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
expect(Object.keys(set)).toEqual(["dealId"])
|
|
111
|
+
expect(render(one(Object.values(set))).sql).toBe('excluded."deal_id"')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it("stays empty for a table without the default id target", () => {
|
|
115
|
+
expect(excludedSet(dealsProperties, {})).toEqual({})
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const billing = schema("billing")
|
|
120
|
+
const invoices = billing.table("invoices", { id: primaryId("sequential") })
|
|
121
|
+
const privateNotes = schema("private").table("notes", {
|
|
122
|
+
id: primaryId("sequential"),
|
|
123
|
+
})
|
|
124
|
+
const contactNames = view("contact_names").as((qb) =>
|
|
125
|
+
qb.select({ displayName: contacts.displayName }).from(contacts)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
describe("functionPermissionTables", () => {
|
|
129
|
+
const schemaObject = {
|
|
130
|
+
billing,
|
|
131
|
+
contactNames,
|
|
132
|
+
contacts,
|
|
133
|
+
invoices,
|
|
134
|
+
privateNotes,
|
|
135
|
+
relationsHelper: () => undefined,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
it("includes only public tables by default", () => {
|
|
139
|
+
expect([...functionPermissionTables(schemaObject)]).toEqual([
|
|
140
|
+
["contacts", "public"],
|
|
141
|
+
])
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it("maps each table to its own schema", () => {
|
|
145
|
+
expect(
|
|
146
|
+
new Map(
|
|
147
|
+
functionPermissionTables(schemaObject, {
|
|
148
|
+
schemas: ["public", "billing"],
|
|
149
|
+
})
|
|
150
|
+
)
|
|
151
|
+
).toEqual(
|
|
152
|
+
new Map([
|
|
153
|
+
["contacts", "public"],
|
|
154
|
+
["invoices", "billing"],
|
|
155
|
+
])
|
|
156
|
+
)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it("accepts the result of defineRelations", () => {
|
|
160
|
+
const relations = defineRelations({ contacts, invoices })
|
|
161
|
+
|
|
162
|
+
expect([
|
|
163
|
+
...functionPermissionTables(relations, { schemas: ["billing"] }),
|
|
164
|
+
]).toEqual([["invoices", "billing"]])
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it("rejects a bare name that two included schemas share", () => {
|
|
168
|
+
const publicInvoices = table("invoices", { id: primaryId("sequential") })
|
|
169
|
+
|
|
170
|
+
expect(() =>
|
|
171
|
+
functionPermissionTables(
|
|
172
|
+
{ invoices, publicInvoices },
|
|
173
|
+
{ schemas: ["public", "billing"] }
|
|
174
|
+
)
|
|
175
|
+
).toThrow('Table "invoices" exists in both')
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
describe("selectFunctionPermissions", () => {
|
|
180
|
+
const tables = functionPermissionTables(
|
|
181
|
+
{ contacts, invoices },
|
|
182
|
+
{ schemas: ["public", "billing"] }
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
// Records the statement and answers with a canned row: no database.
|
|
186
|
+
const fakeDb = (row: Record<string, boolean | null>) => {
|
|
187
|
+
const queries: SQL[] = []
|
|
188
|
+
const executor: QueryExecutor = {
|
|
189
|
+
execute: (query) => {
|
|
190
|
+
queries.push(query)
|
|
191
|
+
return Promise.resolve([row])
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
return { executor, queries }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
it("calls all four functions in one statement, id to all but insert", async () => {
|
|
198
|
+
const { executor, queries } = fakeDb({
|
|
199
|
+
delete: false,
|
|
200
|
+
insert: true,
|
|
201
|
+
select: true,
|
|
202
|
+
update: null,
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
const permissions = await selectFunctionPermissions(executor, {
|
|
206
|
+
id: 7,
|
|
207
|
+
table: "invoices",
|
|
208
|
+
tables,
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
expect(permissions).toEqual({
|
|
212
|
+
delete: false,
|
|
213
|
+
insert: true,
|
|
214
|
+
select: true,
|
|
215
|
+
update: false,
|
|
216
|
+
})
|
|
217
|
+
const query = render(one(queries))
|
|
218
|
+
expect(query.sql).toBe(
|
|
219
|
+
'select "billing"."can_select_invoices"("id" => $1) as "select", "billing"."can_insert_invoices"() as "insert", "billing"."can_update_invoices"("id" => $2) as "update", "billing"."can_delete_invoices"("id" => $3) as "delete"'
|
|
220
|
+
)
|
|
221
|
+
expect(query.params).toEqual([7, 7, 7])
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it("calls every function bare without an id, honouring prefix and schema", async () => {
|
|
225
|
+
const { executor, queries } = fakeDb({
|
|
226
|
+
delete: true,
|
|
227
|
+
insert: true,
|
|
228
|
+
select: true,
|
|
229
|
+
update: true,
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
await selectFunctionPermissions(executor, {
|
|
233
|
+
functionSchema: "private",
|
|
234
|
+
prefix: "may",
|
|
235
|
+
table: "contacts",
|
|
236
|
+
tables,
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
expect(render(one(queries)).sql).toBe(
|
|
240
|
+
'select "private"."may_select_contacts"() as "select", "private"."may_insert_contacts"() as "insert", "private"."may_update_contacts"() as "update", "private"."may_delete_contacts"() as "delete"'
|
|
241
|
+
)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it("rejects a table outside the allowlist before building anything", async () => {
|
|
245
|
+
const { executor, queries } = fakeDb({})
|
|
246
|
+
|
|
247
|
+
await expect(
|
|
248
|
+
selectFunctionPermissions(executor, {
|
|
249
|
+
table: 'contacts"; drop table contacts; --',
|
|
250
|
+
tables,
|
|
251
|
+
})
|
|
252
|
+
).rejects.toThrow("No permission functions for table")
|
|
253
|
+
expect(queries).toHaveLength(0)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it("accepts a real Drizzle client", () => {
|
|
257
|
+
// Compile-time only: the structural type fits a postgres-js database.
|
|
258
|
+
const executor: QueryExecutor = db
|
|
259
|
+
expect(executor.execute).toBeTypeOf("function")
|
|
260
|
+
})
|
|
261
|
+
})
|
package/src/query.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// Query-side helpers: the PostgREST conveniences Drizzle has no equivalent for
|
|
2
|
+
// (`.single()`, `.maybeSingle()`, a default upsert `set`), plus the read side
|
|
3
|
+
// of `functionPolicies`.
|
|
4
|
+
import { getColumns, is, type SQL, sql } from "drizzle-orm"
|
|
5
|
+
import { getTableConfig, PgTable } from "drizzle-orm/pg-core"
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_FUNCTION_PREFIX,
|
|
8
|
+
FUNCTION_POLICY_OPERATIONS,
|
|
9
|
+
type FunctionPolicyOperation,
|
|
10
|
+
functionPolicyName,
|
|
11
|
+
} from "./function-names.ts"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* PostgREST's `.single()`: exactly one row, or an error.
|
|
15
|
+
*
|
|
16
|
+
* `findFirst()` returns `undefined` for no rows and silently drops the rest
|
|
17
|
+
* when there are several, and `.returning()` hands back an array.
|
|
18
|
+
*/
|
|
19
|
+
export const one = <T>(rows: readonly T[]): T => {
|
|
20
|
+
if (rows.length !== 1) {
|
|
21
|
+
throw new Error(`Expected exactly one row, received ${rows.length}`)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return rows[0] as T
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* PostgREST's `.maybeSingle()`: one row or none, erroring on more than one.
|
|
29
|
+
* Returns `undefined` rather than `null`, which is what `findFirst()` does.
|
|
30
|
+
*/
|
|
31
|
+
export const maybeOne = <T>(rows: readonly T[]): T | undefined => {
|
|
32
|
+
if (rows.length > 1) {
|
|
33
|
+
throw new Error(`Expected at most one row, received ${rows.length}`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return rows[0]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type ColumnKey<TTable extends PgTable> = Extract<
|
|
40
|
+
keyof TTable["_"]["columns"],
|
|
41
|
+
string
|
|
42
|
+
>
|
|
43
|
+
|
|
44
|
+
export type ExcludedSetOptions<TTable extends PgTable> = {
|
|
45
|
+
/**
|
|
46
|
+
* The conflict target's column keys, never written back from `excluded`.
|
|
47
|
+
* The first one that exists is also the empty-set fallback. Default `"id"`.
|
|
48
|
+
*/
|
|
49
|
+
target?: ColumnKey<TTable> | readonly ColumnKey<TTable>[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The `set` for `onConflictDoUpdate` that reproduces PostgREST's
|
|
54
|
+
* `.upsert(row, { onConflict })`: on conflict, every column the caller
|
|
55
|
+
* supplied is overwritten from the proposed row (`col = excluded.col`).
|
|
56
|
+
*
|
|
57
|
+
* - A key carrying `undefined` is skipped. Drizzle omits an undefined column
|
|
58
|
+
* from the INSERT, so `excluded.x` would be the column default, and on a
|
|
59
|
+
* conflict `x = excluded.x` would overwrite a stored value with it.
|
|
60
|
+
* - The conflict target (`id` unless `target` says otherwise) is excluded.
|
|
61
|
+
* - An empty result falls back to `<target> = excluded.<target>`. Drizzle
|
|
62
|
+
* throws `No values to set` while *building* the statement, so a payload
|
|
63
|
+
* holding nothing but the key would fail even on the plain insert path.
|
|
64
|
+
* Writing the key back to itself is a no-op that keeps `.returning()`
|
|
65
|
+
* yielding the row, which `onConflictDoNothing` does not.
|
|
66
|
+
*
|
|
67
|
+
* With no target column in the table (a junction keyed only by its foreign
|
|
68
|
+
* keys, with the default target) the fallback cannot apply and the result
|
|
69
|
+
* stays empty: such a table wants `onConflictDoNothing` anyway.
|
|
70
|
+
*
|
|
71
|
+
* Column names come from the schema, never from `values`.
|
|
72
|
+
*/
|
|
73
|
+
export const excludedSet = <TTable extends PgTable>(
|
|
74
|
+
table: TTable,
|
|
75
|
+
values: Record<string, unknown>,
|
|
76
|
+
{ target = "id" as ColumnKey<TTable> }: ExcludedSetOptions<TTable> = {}
|
|
77
|
+
): Record<string, SQL> => {
|
|
78
|
+
const columns: Record<string, { name: string }> = getColumns(table)
|
|
79
|
+
const targets: readonly string[] =
|
|
80
|
+
typeof target === "string" ? [target] : target
|
|
81
|
+
const set: Record<string, SQL> = {}
|
|
82
|
+
|
|
83
|
+
for (const [key, column] of Object.entries(columns)) {
|
|
84
|
+
if (key in values && values[key] !== undefined && !targets.includes(key)) {
|
|
85
|
+
set[key] = sql`excluded.${sql.identifier(column.name)}`
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (Object.keys(set).length > 0) {
|
|
90
|
+
return set
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const fallback = targets.find((key) => key in columns)
|
|
94
|
+
const fallbackColumn = fallback === undefined ? undefined : columns[fallback]
|
|
95
|
+
|
|
96
|
+
if (fallback !== undefined && fallbackColumn) {
|
|
97
|
+
set[fallback] = sql`excluded.${sql.identifier(fallbackColumn.name)}`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return set
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The same values with every `undefined` key dropped, so an insert and its
|
|
105
|
+
* `excludedSet` agree on which columns were supplied.
|
|
106
|
+
*/
|
|
107
|
+
export const definedValues = <T extends Record<string, unknown>>(
|
|
108
|
+
values: T
|
|
109
|
+
): T =>
|
|
110
|
+
Object.fromEntries(
|
|
111
|
+
Object.entries(values).filter(([, value]) => value !== undefined)
|
|
112
|
+
) as T
|
|
113
|
+
|
|
114
|
+
/** Table name to the schema its `<prefix>_<operation>_<table>` functions live in. */
|
|
115
|
+
export type FunctionPermissionTables = ReadonlyMap<string, string>
|
|
116
|
+
|
|
117
|
+
export type FunctionPermissionTablesOptions = {
|
|
118
|
+
/** Schemas whose tables are included. Default `["public"]`. */
|
|
119
|
+
schemas?: readonly string[]
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const DEFAULT_SCHEMA = "public"
|
|
123
|
+
|
|
124
|
+
// A schema module exports tables directly; `defineRelations` wraps each in
|
|
125
|
+
// `{ table, name, relations }`. Both are accepted so either object works.
|
|
126
|
+
const tableOf = (value: unknown): PgTable | undefined => {
|
|
127
|
+
if (is(value, PgTable)) {
|
|
128
|
+
return value
|
|
129
|
+
}
|
|
130
|
+
if (typeof value === "object" && value !== null && "table" in value) {
|
|
131
|
+
const { table } = value
|
|
132
|
+
return is(table, PgTable) ? table : undefined
|
|
133
|
+
}
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The allowlist `selectFunctionPermissions` resolves a table name against,
|
|
139
|
+
* built from a Drizzle schema object (`import * as schema`) or the result of
|
|
140
|
+
* `defineRelations`. Build it once at module scope. Views and non-table
|
|
141
|
+
* exports are ignored; a table name that appears in two included schemas
|
|
142
|
+
* throws, since a bare name could not tell them apart.
|
|
143
|
+
*/
|
|
144
|
+
export const functionPermissionTables = (
|
|
145
|
+
schemaObject: Record<string, unknown>,
|
|
146
|
+
{ schemas = [DEFAULT_SCHEMA] }: FunctionPermissionTablesOptions = {}
|
|
147
|
+
): FunctionPermissionTables => {
|
|
148
|
+
const tables = new Map<string, string>()
|
|
149
|
+
|
|
150
|
+
for (const value of Object.values(schemaObject)) {
|
|
151
|
+
const table = tableOf(value)
|
|
152
|
+
if (!table) {
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
const { name, schema = DEFAULT_SCHEMA } = getTableConfig(table)
|
|
156
|
+
if (!schemas.includes(schema)) {
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
const existing = tables.get(name)
|
|
160
|
+
if (existing !== undefined && existing !== schema) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`Table "${name}" exists in both "${existing}" and "${schema}"; include only one of those schemas`
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
tables.set(name, schema)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return tables
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export type FunctionPermissions = Record<FunctionPolicyOperation, boolean>
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Anything with Drizzle's `execute` returning rows: a postgres-js client from
|
|
175
|
+
* `@zeno-lib/db`, a `PgAsyncDatabase`, or a transaction.
|
|
176
|
+
*/
|
|
177
|
+
export type QueryExecutor = {
|
|
178
|
+
execute: (query: SQL) => PromiseLike<readonly Record<string, unknown>[]>
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export type SelectFunctionPermissionsOptions = {
|
|
182
|
+
/** From `functionPermissionTables`. */
|
|
183
|
+
tables: FunctionPermissionTables
|
|
184
|
+
/** The table asked about, checked against `tables`. */
|
|
185
|
+
table: string
|
|
186
|
+
/**
|
|
187
|
+
* The row, passed to every function but `insert` (there is no row yet) as
|
|
188
|
+
* a named argument. Omit to call all four with none.
|
|
189
|
+
*/
|
|
190
|
+
id?: number | string
|
|
191
|
+
/** Name of the functions' row parameter. Default `"id"`. */
|
|
192
|
+
argumentName?: string
|
|
193
|
+
/** Function prefix, as given to `functionPolicies`. Default `"can"`. */
|
|
194
|
+
prefix?: string
|
|
195
|
+
/** Schema the functions live in. Default: the table's own. */
|
|
196
|
+
functionSchema?: string
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The four `<prefix>_<operation>_<table>()` answers for one table, in one
|
|
201
|
+
* statement: the read side of `functionPolicies`, for showing or hiding UI.
|
|
202
|
+
*
|
|
203
|
+
* `table` is only ever a map key: every identifier that reaches the statement
|
|
204
|
+
* comes from `tables` and is quoted with `sql.identifier`. A function missing
|
|
205
|
+
* or with another signature raises `42883` (`SqlState.undefinedFunction`).
|
|
206
|
+
* A `null` answer, which a function gives for a row the caller cannot see,
|
|
207
|
+
* counts as `false`, as does anything else that is not `true`.
|
|
208
|
+
*/
|
|
209
|
+
export const selectFunctionPermissions = async (
|
|
210
|
+
db: QueryExecutor,
|
|
211
|
+
{
|
|
212
|
+
argumentName = "id",
|
|
213
|
+
functionSchema,
|
|
214
|
+
id,
|
|
215
|
+
prefix = DEFAULT_FUNCTION_PREFIX,
|
|
216
|
+
table,
|
|
217
|
+
tables,
|
|
218
|
+
}: SelectFunctionPermissionsOptions
|
|
219
|
+
): Promise<FunctionPermissions> => {
|
|
220
|
+
const tableSchema = tables.get(table)
|
|
221
|
+
|
|
222
|
+
if (tableSchema === undefined) {
|
|
223
|
+
throw new Error(`No permission functions for table "${table}"`)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const schema = functionSchema ?? tableSchema
|
|
227
|
+
|
|
228
|
+
const call = (operation: FunctionPolicyOperation) => {
|
|
229
|
+
const callee = sql`${sql.identifier(schema)}.${sql.identifier(functionPolicyName(prefix, operation, table))}`
|
|
230
|
+
return operation === "insert" || id === undefined
|
|
231
|
+
? sql`${callee}()`
|
|
232
|
+
: sql`${callee}(${sql.identifier(argumentName)} => ${id})`
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const row = one(
|
|
236
|
+
await db.execute(
|
|
237
|
+
sql`select ${sql.join(
|
|
238
|
+
FUNCTION_POLICY_OPERATIONS.map(
|
|
239
|
+
(operation) => sql`${call(operation)} as ${sql.identifier(operation)}`
|
|
240
|
+
),
|
|
241
|
+
sql`, `
|
|
242
|
+
)}`
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
delete: row.delete === true,
|
|
248
|
+
insert: row.insert === true,
|
|
249
|
+
select: row.select === true,
|
|
250
|
+
update: row.update === true,
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { defineTableSchema } from "@zeno-lib/schema"
|
|
2
|
+
import { text } from "drizzle-orm/pg-core"
|
|
3
|
+
import { describe, expect, it } from "vitest"
|
|
4
|
+
|
|
5
|
+
import { auditColumns, sequentialPrimaryId, table } from "./schema.ts"
|
|
6
|
+
|
|
7
|
+
// `@zeno-lib/schema` cannot import this package, so it recognises the audit
|
|
8
|
+
// columns by the keys these helpers emit. This pins that contract.
|
|
9
|
+
describe("schema helpers under defineTableSchema", () => {
|
|
10
|
+
const notes = table("notes", {
|
|
11
|
+
...auditColumns(),
|
|
12
|
+
body: text().notNull(),
|
|
13
|
+
id: sequentialPrimaryId(),
|
|
14
|
+
})
|
|
15
|
+
const schemas = defineTableSchema(notes)
|
|
16
|
+
|
|
17
|
+
it("keeps every audit column out of insert and update", () => {
|
|
18
|
+
const forged = {
|
|
19
|
+
body: "Hello",
|
|
20
|
+
createdAt: new Date(),
|
|
21
|
+
createdBy: "00000000-0000-4000-8000-000000000000",
|
|
22
|
+
updatedAt: new Date(),
|
|
23
|
+
updatedBy: "00000000-0000-4000-8000-000000000000",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
expect(schemas.insert.parse(forged)).toEqual({ body: "Hello" })
|
|
27
|
+
expect(schemas.update.parse(forged)).toEqual({ body: "Hello" })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it("accepts only positive sequential ids", () => {
|
|
31
|
+
expect(schemas.insert.safeParse({ body: "x", id: 0 }).success).toBe(false)
|
|
32
|
+
expect(schemas.insert.parse({ body: "x", id: 1 })).toEqual({
|
|
33
|
+
body: "x",
|
|
34
|
+
id: 1,
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
})
|
package/src/schema.test-d.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
primaryId,
|
|
8
8
|
sequentialPrimaryId,
|
|
9
9
|
table,
|
|
10
|
+
timestamps,
|
|
10
11
|
userId,
|
|
11
12
|
uuidPrimaryId,
|
|
12
13
|
} from "./schema.ts"
|
|
@@ -119,3 +120,26 @@ test("reference actions are rejected when there is no reference", () => {
|
|
|
119
120
|
// @ts-expect-error actions are meaningless without a foreign key
|
|
120
121
|
authUserId({ actions: { onDelete: "cascade" }, reference: null })
|
|
121
122
|
})
|
|
123
|
+
|
|
124
|
+
test("timestamps reads a Date by default and a string with mode: string", () => {
|
|
125
|
+
const dated = table("dated", { ...timestamps() })
|
|
126
|
+
const stringly = table("stringly", { ...timestamps({ mode: "string" }) })
|
|
127
|
+
const audited = table("audited_strings", {
|
|
128
|
+
...auditColumns({ mode: "string" }),
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
expectTypeOf<
|
|
132
|
+
(typeof dated)["$inferSelect"]["createdAt"]
|
|
133
|
+
>().toEqualTypeOf<Date>()
|
|
134
|
+
expectTypeOf<
|
|
135
|
+
(typeof stringly)["$inferSelect"]["updatedAt"]
|
|
136
|
+
>().toEqualTypeOf<string>()
|
|
137
|
+
expectTypeOf<
|
|
138
|
+
(typeof audited)["$inferSelect"]["createdAt"]
|
|
139
|
+
>().toEqualTypeOf<string>()
|
|
140
|
+
// Both still have a default, so neither is required on insert.
|
|
141
|
+
expectTypeOf<(typeof stringly)["$inferInsert"]>().toEqualTypeOf<{
|
|
142
|
+
createdAt?: string | undefined
|
|
143
|
+
updatedAt?: string | undefined
|
|
144
|
+
}>()
|
|
145
|
+
})
|