@zeno-lib/db 0.1.0 → 0.2.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/package.json +5 -3
- package/src/auth-schema.test.ts +59 -0
- package/src/auth-schema.ts +94 -0
- package/src/casing.test.ts +512 -28
- package/src/config.ts +51 -2
- package/src/schema.test-d.ts +121 -0
- package/src/schema.ts +429 -43
- package/src/triggers.test.ts +131 -0
- package/src/triggers.ts +171 -0
package/src/casing.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getTableColumns, sql } from "drizzle-orm"
|
|
1
|
+
import { getTableColumns, type SQL, sql } from "drizzle-orm"
|
|
2
2
|
import {
|
|
3
3
|
getTableConfig,
|
|
4
4
|
isPgEnum,
|
|
@@ -6,11 +6,11 @@ import {
|
|
|
6
6
|
isPgSchema,
|
|
7
7
|
isPgSequence,
|
|
8
8
|
isPgView,
|
|
9
|
+
PgDialect,
|
|
9
10
|
pgEnum,
|
|
10
11
|
pgMaterializedView,
|
|
11
12
|
pgPolicy,
|
|
12
13
|
pgRole,
|
|
13
|
-
pgSchema,
|
|
14
14
|
pgSequence,
|
|
15
15
|
pgTableCreator,
|
|
16
16
|
pgView,
|
|
@@ -19,23 +19,29 @@ import {
|
|
|
19
19
|
} from "drizzle-orm/pg-core"
|
|
20
20
|
import { camelCase, snakeCase } from "drizzle-orm/pg-core/casing"
|
|
21
21
|
import { describe, expect, it } from "vitest"
|
|
22
|
+
import { authUsers } from "./auth-schema.ts"
|
|
22
23
|
import { createAdminClient } from "./clients.ts"
|
|
23
|
-
import { defineDrizzleConfig } from "./config.ts"
|
|
24
|
+
import { defineDrizzleConfig, supabaseManagedRoles } from "./config.ts"
|
|
24
25
|
import {
|
|
25
26
|
allPolicy,
|
|
27
|
+
assignedPrimaryId,
|
|
26
28
|
auditColumns,
|
|
29
|
+
authenticatedAllPolicy,
|
|
30
|
+
authenticatedDeletePolicy,
|
|
31
|
+
authenticatedInsertPolicy,
|
|
27
32
|
authenticatedOwnerDeletePolicy,
|
|
28
33
|
authenticatedOwnerInsertPolicy,
|
|
29
34
|
authenticatedOwnerSelectPolicy,
|
|
30
35
|
authenticatedOwnerUpdatePolicy,
|
|
31
36
|
authenticatedRole,
|
|
37
|
+
authenticatedSelectPolicy,
|
|
38
|
+
authenticatedUpdatePolicy,
|
|
32
39
|
authorship,
|
|
33
|
-
authUid,
|
|
34
40
|
authUserId,
|
|
35
|
-
authUsers,
|
|
36
41
|
createdBy,
|
|
37
42
|
deletePolicy,
|
|
38
43
|
enum as enum_,
|
|
44
|
+
functionPolicies,
|
|
39
45
|
insertPolicy,
|
|
40
46
|
isEnum,
|
|
41
47
|
isMaterializedView,
|
|
@@ -49,12 +55,15 @@ import {
|
|
|
49
55
|
schema,
|
|
50
56
|
selectPolicy,
|
|
51
57
|
sequence,
|
|
58
|
+
sequentialPrimaryId,
|
|
52
59
|
table,
|
|
53
60
|
tableCreator,
|
|
54
61
|
timestamps,
|
|
55
62
|
unsecureTable,
|
|
56
63
|
updatedBy,
|
|
57
64
|
updatePolicy,
|
|
65
|
+
userId,
|
|
66
|
+
uuidPrimaryId,
|
|
58
67
|
view,
|
|
59
68
|
} from "./schema.ts"
|
|
60
69
|
|
|
@@ -63,6 +72,33 @@ describe("default casing", () => {
|
|
|
63
72
|
expect(defineDrizzleConfig()).not.toHaveProperty("casing")
|
|
64
73
|
})
|
|
65
74
|
|
|
75
|
+
it("excludes every Supabase-managed role from the role diff", () => {
|
|
76
|
+
const roles = defineDrizzleConfig().entities?.roles
|
|
77
|
+
|
|
78
|
+
expect(roles).toMatchObject({ provider: "supabase" })
|
|
79
|
+
expect(typeof roles === "object" ? roles.exclude : []).toEqual(
|
|
80
|
+
expect.arrayContaining([...supabaseManagedRoles])
|
|
81
|
+
)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("keeps a caller's own excluded roles alongside the Supabase ones", () => {
|
|
85
|
+
const roles = defineDrizzleConfig({
|
|
86
|
+
entities: { roles: { exclude: ["my_reporting_role"] } },
|
|
87
|
+
}).entities?.roles
|
|
88
|
+
const exclude = typeof roles === "object" ? (roles.exclude ?? []) : []
|
|
89
|
+
|
|
90
|
+
expect(exclude).toContain("my_reporting_role")
|
|
91
|
+
expect(exclude).toContain("supabase_replication_admin")
|
|
92
|
+
expect(roles).toMatchObject({ provider: "supabase" })
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("diffs only the public schema unless told otherwise", () => {
|
|
96
|
+
expect(defineDrizzleConfig().schemaFilter).toEqual(["public"])
|
|
97
|
+
expect(
|
|
98
|
+
defineDrizzleConfig({ schemaFilter: ["public", "billing"] }).schemaFilter
|
|
99
|
+
).toEqual(["public", "billing"])
|
|
100
|
+
})
|
|
101
|
+
|
|
66
102
|
it("works with Drizzle's snake_case table builders", async () => {
|
|
67
103
|
const posts = snakeCase.table("posts", {
|
|
68
104
|
displayName: text(),
|
|
@@ -127,38 +163,254 @@ describe("default casing", () => {
|
|
|
127
163
|
expect(materializedView).toBe(pgMaterializedView)
|
|
128
164
|
expect(policy).toBe(pgPolicy)
|
|
129
165
|
expect(role).toBe(pgRole)
|
|
130
|
-
expect(schema).toBe(pgSchema)
|
|
131
166
|
expect(sequence).toBe(pgSequence)
|
|
132
167
|
expect(tableCreator).toBe(pgTableCreator)
|
|
133
168
|
expect(view).toBe(pgView)
|
|
134
169
|
})
|
|
135
170
|
|
|
136
|
-
it("exports
|
|
171
|
+
it("exports a Supabase auth-user column helper", () => {
|
|
137
172
|
const posts = table("posts", {
|
|
138
173
|
id: primaryId("uuid"),
|
|
139
174
|
ownerId: authUserId(),
|
|
140
|
-
sequentialId: primaryId("sequential"),
|
|
141
175
|
})
|
|
142
176
|
const columns = getTableColumns(posts)
|
|
143
177
|
const config = getTableConfig(posts)
|
|
178
|
+
const foreignKey = config.foreignKeys[0]
|
|
179
|
+
|
|
180
|
+
expect(columns.ownerId.getSQLType()).toBe("uuid")
|
|
181
|
+
// Nullable so deleting the user blanks the author instead of failing.
|
|
182
|
+
expect(columns.ownerId.notNull).toBe(false)
|
|
183
|
+
expect(config.foreignKeys).toHaveLength(1)
|
|
184
|
+
expect(foreignKey?.reference().foreignTable).toBe(authUsers)
|
|
185
|
+
expect(foreignKey?.onDelete).toBe("set null")
|
|
186
|
+
expect(foreignKey?.onUpdate).toBe("cascade")
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it("restricts the delete when an author column is required", () => {
|
|
190
|
+
const posts = table("posts", { ownerId: authUserId({ notNull: true }) })
|
|
191
|
+
const foreignKey = getTableConfig(posts).foreignKeys[0]
|
|
192
|
+
|
|
193
|
+
expect(getTableColumns(posts).ownerId.notNull).toBe(true)
|
|
194
|
+
// `set null` against a NOT NULL column is a foreign key that can never
|
|
195
|
+
// fire, so a required author restricts instead.
|
|
196
|
+
expect(foreignKey?.onDelete).toBe("restrict")
|
|
197
|
+
expect(foreignKey?.onUpdate).toBe("cascade")
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it("takes an explicit column name and reference actions", () => {
|
|
201
|
+
const posts = table("posts", {
|
|
202
|
+
ownerId: authUserId({
|
|
203
|
+
actions: { onDelete: "cascade", onUpdate: "no action" },
|
|
204
|
+
name: "owner_id",
|
|
205
|
+
}),
|
|
206
|
+
})
|
|
207
|
+
const foreignKey = getTableConfig(posts).foreignKeys[0]
|
|
208
|
+
|
|
209
|
+
expect(getTableColumns(posts).ownerId.name).toBe("owner_id")
|
|
210
|
+
expect(foreignKey?.onDelete).toBe("cascade")
|
|
211
|
+
expect(foreignKey?.onUpdate).toBe("no action")
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it("drops the foreign key entirely when the reference is null", () => {
|
|
215
|
+
const posts = table("posts", {
|
|
216
|
+
ownerId: authUserId({ reference: null }),
|
|
217
|
+
...authorship({ reference: null }),
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
expect(getTableConfig(posts).foreignKeys).toHaveLength(0)
|
|
221
|
+
expect(getTableColumns(posts).ownerId.getSQLType()).toBe("uuid")
|
|
222
|
+
expect(getTableColumns(posts).createdBy.name).toBe("created_by")
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it("points author columns at a public profiles mirror", () => {
|
|
226
|
+
const profiles = table("profiles", {
|
|
227
|
+
id: uuidPrimaryId({ defaultRandom: false }),
|
|
228
|
+
})
|
|
229
|
+
const posts = table("posts", {
|
|
230
|
+
ownerId: userId(() => profiles.id),
|
|
231
|
+
...authorship({ reference: () => profiles.id }),
|
|
232
|
+
})
|
|
233
|
+
const foreignKeys = getTableConfig(posts).foreignKeys
|
|
234
|
+
|
|
235
|
+
expect(foreignKeys).toHaveLength(3)
|
|
236
|
+
for (const foreignKey of foreignKeys) {
|
|
237
|
+
expect(foreignKey.reference().foreignTable).toBe(profiles)
|
|
238
|
+
expect(foreignKey.onDelete).toBe("set null")
|
|
239
|
+
}
|
|
240
|
+
expect(getTableColumns(posts).ownerId.notNull).toBe(false)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it("builds the same column through userId as through authUserId", () => {
|
|
244
|
+
const viaAuth = table("a", { ownerId: authUserId({ notNull: true }) })
|
|
245
|
+
const viaUserId = table("b", {
|
|
246
|
+
ownerId: userId(() => authUsers.id, { notNull: true }),
|
|
247
|
+
})
|
|
248
|
+
const authForeignKey = getTableConfig(viaAuth).foreignKeys[0]
|
|
249
|
+
const userForeignKey = getTableConfig(viaUserId).foreignKeys[0]
|
|
144
250
|
|
|
251
|
+
expect(getTableColumns(viaUserId).ownerId.notNull).toBe(
|
|
252
|
+
getTableColumns(viaAuth).ownerId.notNull
|
|
253
|
+
)
|
|
254
|
+
expect(userForeignKey?.reference().foreignTable).toBe(
|
|
255
|
+
authForeignKey?.reference().foreignTable
|
|
256
|
+
)
|
|
257
|
+
expect(userForeignKey?.onDelete).toBe(authForeignKey?.onDelete)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
// Each helper gets its own table: all three name the column "id" by default,
|
|
261
|
+
// and Drizzle's setName returns early once a name is set, so two of them in
|
|
262
|
+
// one table would silently share the name.
|
|
263
|
+
it("builds a random-UUID primary key", () => {
|
|
264
|
+
const columns = getTableColumns(table("posts", { id: primaryId("uuid") }))
|
|
265
|
+
|
|
266
|
+
expect(columns.id.name).toBe("id")
|
|
145
267
|
expect(columns.id.getSQLType()).toBe("uuid")
|
|
146
268
|
expect(columns.id.primary).toBe(true)
|
|
147
269
|
expect(columns.id.default).toBeDefined()
|
|
148
|
-
expect(columns.
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
270
|
+
expect(columns.id.generatedIdentity).toBeUndefined()
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it("builds a UUID primary key without a default", () => {
|
|
274
|
+
const columns = getTableColumns(
|
|
275
|
+
table("profiles", { id: uuidPrimaryId({ defaultRandom: false }) })
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
expect(columns.id.getSQLType()).toBe("uuid")
|
|
279
|
+
expect(columns.id.primary).toBe(true)
|
|
280
|
+
expect(columns.id.hasDefault).toBe(false)
|
|
281
|
+
expect(columns.id.default).toBeUndefined()
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it("defaults a sequential primary key to Supabase's bigint by-default shape", () => {
|
|
285
|
+
const columns = getTableColumns(
|
|
286
|
+
table("posts", { id: primaryId("sequential") })
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
expect(columns.id.name).toBe("id")
|
|
290
|
+
expect(columns.id.getSQLType()).toBe("bigint")
|
|
291
|
+
expect(columns.id.primary).toBe(true)
|
|
292
|
+
expect(columns.id.generatedIdentity?.type).toBe("byDefault")
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it("builds always-generated, integer, and bigint-mode sequential keys", () => {
|
|
296
|
+
const always = getTableColumns(
|
|
297
|
+
table("always", { id: sequentialPrimaryId({ generated: "always" }) })
|
|
298
|
+
)
|
|
299
|
+
const int = getTableColumns(
|
|
300
|
+
table("int", { id: sequentialPrimaryId({ type: "integer" }) })
|
|
301
|
+
)
|
|
302
|
+
const big = getTableColumns(
|
|
303
|
+
table("big", { id: sequentialPrimaryId({ mode: "bigint" }) })
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
expect(always.id.getSQLType()).toBe("bigint")
|
|
307
|
+
expect(always.id.generatedIdentity?.type).toBe("always")
|
|
308
|
+
expect(int.id.getSQLType()).toBe("integer")
|
|
309
|
+
expect(int.id.generatedIdentity?.type).toBe("byDefault")
|
|
310
|
+
expect(big.id.getSQLType()).toBe("bigint")
|
|
311
|
+
expect(big.id.generatedIdentity?.type).toBe("byDefault")
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it("builds an application-assigned primary key with no default", () => {
|
|
315
|
+
const columns = getTableColumns(
|
|
316
|
+
table("invoices", { id: primaryId("assigned") })
|
|
317
|
+
)
|
|
318
|
+
const sized = getTableColumns(
|
|
319
|
+
table("sized", { id: assignedPrimaryId({ length: 32 }) })
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
expect(columns.id.name).toBe("id")
|
|
323
|
+
expect(columns.id.getSQLType()).toBe("varchar")
|
|
324
|
+
expect(columns.id.primary).toBe(true)
|
|
325
|
+
expect(columns.id.default).toBeUndefined()
|
|
326
|
+
expect(columns.id.generatedIdentity).toBeUndefined()
|
|
327
|
+
expect(sized.id.getSQLType()).toBe("varchar(32)")
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it("lets every primary key helper override the column name", () => {
|
|
331
|
+
const columns = getTableColumns(
|
|
332
|
+
table("posts", {
|
|
333
|
+
assignedKey: assignedPrimaryId({ name: "assigned_key" }),
|
|
334
|
+
sequentialKey: sequentialPrimaryId({ name: "sequential_key" }),
|
|
335
|
+
uuidKey: uuidPrimaryId({ name: "uuid_key" }),
|
|
336
|
+
})
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
expect(columns.uuidKey.name).toBe("uuid_key")
|
|
340
|
+
expect(columns.sequentialKey.name).toBe("sequential_key")
|
|
341
|
+
expect(columns.assignedKey.name).toBe("assigned_key")
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it("keeps a UUID key chainable into a cascading auth.users reference", () => {
|
|
345
|
+
const profiles = table("profiles", {
|
|
346
|
+
id: uuidPrimaryId({ defaultRandom: false }).references(
|
|
347
|
+
() => authUsers.id,
|
|
348
|
+
{
|
|
349
|
+
onDelete: "cascade",
|
|
350
|
+
onUpdate: "cascade",
|
|
351
|
+
}
|
|
352
|
+
),
|
|
353
|
+
})
|
|
354
|
+
const columns = getTableColumns(profiles)
|
|
355
|
+
const foreignKey = getTableConfig(profiles).foreignKeys[0]
|
|
356
|
+
|
|
357
|
+
expect(columns.id.primary).toBe(true)
|
|
358
|
+
expect(columns.id.notNull).toBe(true)
|
|
359
|
+
expect(columns.id.default).toBeUndefined()
|
|
360
|
+
expect(foreignKey?.reference().foreignTable).toBe(authUsers)
|
|
361
|
+
expect(foreignKey?.onDelete).toBe("cascade")
|
|
362
|
+
expect(foreignKey?.onUpdate).toBe("cascade")
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
it("keeps the random default when a UUID key also references auth.users", () => {
|
|
366
|
+
const profiles = table("profiles", {
|
|
367
|
+
id: uuidPrimaryId().references(() => authUsers.id, {
|
|
368
|
+
onDelete: "cascade",
|
|
369
|
+
onUpdate: "cascade",
|
|
370
|
+
}),
|
|
371
|
+
})
|
|
372
|
+
const columns = getTableColumns(profiles)
|
|
373
|
+
const foreignKey = getTableConfig(profiles).foreignKeys[0]
|
|
374
|
+
|
|
375
|
+
expect(columns.id.primary).toBe(true)
|
|
376
|
+
expect(columns.id.default).toBeDefined()
|
|
377
|
+
expect(foreignKey?.reference().foreignTable).toBe(authUsers)
|
|
378
|
+
expect(foreignKey?.onDelete).toBe("cascade")
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
it("routes every primaryId kind to its dedicated helper", () => {
|
|
382
|
+
const selected = {
|
|
383
|
+
assigned: getTableColumns(table("t", { id: primaryId("assigned") })).id,
|
|
384
|
+
sequential: getTableColumns(table("t", { id: primaryId("sequential") }))
|
|
385
|
+
.id,
|
|
386
|
+
uuid: getTableColumns(table("t", { id: primaryId("uuid") })).id,
|
|
387
|
+
}
|
|
388
|
+
const direct = {
|
|
389
|
+
assigned: getTableColumns(table("t", { id: assignedPrimaryId() })).id,
|
|
390
|
+
sequential: getTableColumns(table("t", { id: sequentialPrimaryId() })).id,
|
|
391
|
+
uuid: getTableColumns(table("t", { id: uuidPrimaryId() })).id,
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
for (const kind of ["uuid", "sequential", "assigned"] as const) {
|
|
395
|
+
expect(selected[kind].getSQLType()).toBe(direct[kind].getSQLType())
|
|
396
|
+
expect(selected[kind].primary).toBe(direct[kind].primary)
|
|
397
|
+
expect(selected[kind].hasDefault).toBe(direct[kind].hasDefault)
|
|
398
|
+
expect(selected[kind].generatedIdentity?.type).toBe(
|
|
399
|
+
direct[kind].generatedIdentity?.type
|
|
400
|
+
)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// The bare call is Supabase's own default for a new table.
|
|
404
|
+
expect(
|
|
405
|
+
getTableColumns(table("t", { id: primaryId() })).id.getSQLType()
|
|
406
|
+
).toBe("bigint")
|
|
155
407
|
})
|
|
156
408
|
|
|
157
409
|
it("exports audit timestamp and auth-user column helpers with runtime defaults", () => {
|
|
158
410
|
const posts = table("posts", {
|
|
159
|
-
createdBy,
|
|
160
|
-
updatedBy,
|
|
161
|
-
...timestamps,
|
|
411
|
+
createdBy: createdBy(),
|
|
412
|
+
updatedBy: updatedBy(),
|
|
413
|
+
...timestamps(),
|
|
162
414
|
})
|
|
163
415
|
const columns = getTableColumns(posts)
|
|
164
416
|
const config = getTableConfig(posts)
|
|
@@ -170,15 +422,16 @@ describe("default casing", () => {
|
|
|
170
422
|
expect(columns.updatedAt.name).toBe("updated_at")
|
|
171
423
|
expect(columns.updatedAt.notNull).toBe(true)
|
|
172
424
|
expect(columns.updatedAt.default).toBeDefined()
|
|
173
|
-
expect(columns.updatedAt.onUpdateFn
|
|
425
|
+
expect(columns.updatedAt.onUpdateFn).toBeUndefined()
|
|
174
426
|
expect(columns.createdBy.name).toBe("created_by")
|
|
175
|
-
|
|
176
|
-
expect(columns.createdBy.
|
|
427
|
+
// Nullable by default so a user delete blanks the author, not fails.
|
|
428
|
+
expect(columns.createdBy.notNull).toBe(false)
|
|
429
|
+
expect(columns.createdBy.default).toBeDefined()
|
|
177
430
|
expect(columns.createdBy.onUpdateFn).toBeUndefined()
|
|
178
431
|
expect(columns.updatedBy.name).toBe("updated_by")
|
|
179
|
-
expect(columns.updatedBy.notNull).toBe(
|
|
180
|
-
expect(columns.updatedBy.default).
|
|
181
|
-
expect(columns.updatedBy.onUpdateFn
|
|
432
|
+
expect(columns.updatedBy.notNull).toBe(false)
|
|
433
|
+
expect(columns.updatedBy.default).toBeDefined()
|
|
434
|
+
expect(columns.updatedBy.onUpdateFn).toBeUndefined()
|
|
182
435
|
expect(config.foreignKeys).toHaveLength(2)
|
|
183
436
|
expect(
|
|
184
437
|
config.foreignKeys.map(
|
|
@@ -187,10 +440,76 @@ describe("default casing", () => {
|
|
|
187
440
|
).toEqual([authUsers, authUsers])
|
|
188
441
|
})
|
|
189
442
|
|
|
443
|
+
it("applies one options object to both author columns", () => {
|
|
444
|
+
const posts = table("posts", {
|
|
445
|
+
...auditColumns({ notNull: true }),
|
|
446
|
+
})
|
|
447
|
+
const columns = getTableColumns(posts)
|
|
448
|
+
const foreignKeys = getTableConfig(posts).foreignKeys
|
|
449
|
+
|
|
450
|
+
expect(columns.createdBy.notNull).toBe(true)
|
|
451
|
+
expect(columns.updatedBy.notNull).toBe(true)
|
|
452
|
+
expect(foreignKeys.map((foreignKey) => foreignKey.onDelete)).toEqual([
|
|
453
|
+
"restrict",
|
|
454
|
+
"restrict",
|
|
455
|
+
])
|
|
456
|
+
// Timestamps are unaffected by the authorship options.
|
|
457
|
+
expect(columns.createdAt.notNull).toBe(true)
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
it("does not add updated_at to an UPDATE Drizzle builds", async () => {
|
|
461
|
+
const posts = table("posts", { title: text(), ...timestamps() })
|
|
462
|
+
const db = createAdminClient()
|
|
463
|
+
const { params, sql: statement } = db
|
|
464
|
+
.update(posts)
|
|
465
|
+
.set({ title: "hello" })
|
|
466
|
+
.toSQL()
|
|
467
|
+
|
|
468
|
+
// Drizzle no longer touches the column, so the trigger is the only thing
|
|
469
|
+
// that sets it and every writer gets the same behaviour.
|
|
470
|
+
expect(statement).not.toContain('"updated_at"')
|
|
471
|
+
expect(params).toEqual(["hello"])
|
|
472
|
+
|
|
473
|
+
await db.close()
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
it("leaves the update side of the audit columns to Postgres", () => {
|
|
477
|
+
const posts = table("posts", { ...auditColumns() })
|
|
478
|
+
const columns = getTableColumns(posts)
|
|
479
|
+
|
|
480
|
+
// No Drizzle-side hooks at all: `$onUpdateFn` is applied while Drizzle
|
|
481
|
+
// builds its own statement, so it would miss every PostgREST write. The
|
|
482
|
+
// triggers in @zeno-lib/db/triggers own these columns instead.
|
|
483
|
+
expect(columns.updatedAt.onUpdateFn).toBeUndefined()
|
|
484
|
+
expect(columns.updatedBy.onUpdateFn).toBeUndefined()
|
|
485
|
+
|
|
486
|
+
// The insert side stays real SQL, so it covers every writer.
|
|
487
|
+
expect(columns.createdAt.default).toBeDefined()
|
|
488
|
+
expect(columns.updatedAt.default).toBeDefined()
|
|
489
|
+
expect(columns.createdBy.default).toBeDefined()
|
|
490
|
+
expect(columns.updatedBy.default).toBeDefined()
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
it("passes timezone and precision through to the column type", () => {
|
|
494
|
+
const naive = table("naive", { ...timestamps({ withTimezone: false }) })
|
|
495
|
+
const precise = table("precise", { ...timestamps({ precision: 3 }) })
|
|
496
|
+
const audited = table("audited", {
|
|
497
|
+
...auditColumns({ precision: 0, withTimezone: false }),
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
expect(getTableColumns(naive).createdAt.getSQLType()).toBe("timestamp")
|
|
501
|
+
expect(getTableColumns(precise).updatedAt.getSQLType()).toBe(
|
|
502
|
+
"timestamp (3) with time zone"
|
|
503
|
+
)
|
|
504
|
+
expect(getTableColumns(audited).createdAt.getSQLType()).toBe(
|
|
505
|
+
"timestamp (0)"
|
|
506
|
+
)
|
|
507
|
+
})
|
|
508
|
+
|
|
190
509
|
it("exports grouped audit column mixins", () => {
|
|
191
|
-
expect(Object.keys(timestamps)).toEqual(["createdAt", "updatedAt"])
|
|
192
|
-
expect(Object.keys(authorship)).toEqual(["createdBy", "updatedBy"])
|
|
193
|
-
expect(Object.keys(auditColumns)).toEqual([
|
|
510
|
+
expect(Object.keys(timestamps())).toEqual(["createdAt", "updatedAt"])
|
|
511
|
+
expect(Object.keys(authorship())).toEqual(["createdBy", "updatedBy"])
|
|
512
|
+
expect(Object.keys(auditColumns())).toEqual([
|
|
194
513
|
"createdAt",
|
|
195
514
|
"updatedAt",
|
|
196
515
|
"createdBy",
|
|
@@ -199,7 +518,7 @@ describe("default casing", () => {
|
|
|
199
518
|
|
|
200
519
|
const posts = table("posts", {
|
|
201
520
|
id: primaryId("uuid"),
|
|
202
|
-
...auditColumns,
|
|
521
|
+
...auditColumns(),
|
|
203
522
|
})
|
|
204
523
|
const columns = getTableColumns(posts)
|
|
205
524
|
|
|
@@ -209,6 +528,40 @@ describe("default casing", () => {
|
|
|
209
528
|
expect(columns.updatedBy.name).toBe("updated_by")
|
|
210
529
|
})
|
|
211
530
|
|
|
531
|
+
it("builds a fresh column builder on every audit mixin call", () => {
|
|
532
|
+
expect(timestamps().createdAt).not.toBe(timestamps().createdAt)
|
|
533
|
+
expect(authorship().createdBy).not.toBe(authorship().createdBy)
|
|
534
|
+
expect(createdBy()).not.toBe(createdBy())
|
|
535
|
+
expect(updatedBy()).not.toBe(updatedBy())
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
it("keeps tables built from separate audit mixin calls independent", () => {
|
|
539
|
+
const postsAuthorship = authorship()
|
|
540
|
+
const commentsAuthorship = authorship()
|
|
541
|
+
const posts = table("posts", {
|
|
542
|
+
...postsAuthorship,
|
|
543
|
+
createdBy: postsAuthorship.createdBy.unique(),
|
|
544
|
+
})
|
|
545
|
+
const comments = table("comments", { ...commentsAuthorship })
|
|
546
|
+
|
|
547
|
+
expect(getTableColumns(posts).createdBy.isUnique).toBe(true)
|
|
548
|
+
expect(getTableColumns(comments).createdBy.isUnique).toBe(false)
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
it("does not share foreign keys between audit mixin calls", () => {
|
|
552
|
+
const profiles = table("profiles", { id: primaryId("uuid") })
|
|
553
|
+
const postsAudit = auditColumns()
|
|
554
|
+
const commentsAudit = auditColumns()
|
|
555
|
+
const posts = table("posts", {
|
|
556
|
+
...postsAudit,
|
|
557
|
+
createdBy: postsAudit.createdBy.references(() => profiles.id),
|
|
558
|
+
})
|
|
559
|
+
const comments = table("comments", { ...commentsAudit })
|
|
560
|
+
|
|
561
|
+
expect(getTableConfig(posts).foreignKeys).toHaveLength(3)
|
|
562
|
+
expect(getTableConfig(comments).foreignKeys).toHaveLength(2)
|
|
563
|
+
})
|
|
564
|
+
|
|
212
565
|
it("exports generic policy helpers that set the policy operation", () => {
|
|
213
566
|
const condition = sql`true`
|
|
214
567
|
|
|
@@ -261,4 +614,135 @@ describe("default casing", () => {
|
|
|
261
614
|
expect(policies[2]?.withCheck).toBeDefined()
|
|
262
615
|
expect(policies[3]?.using).toBeDefined()
|
|
263
616
|
})
|
|
617
|
+
|
|
618
|
+
it("cases column names in a non-public schema", async () => {
|
|
619
|
+
const billing = schema("billing")
|
|
620
|
+
const invoices = billing.table("invoices", {
|
|
621
|
+
displayName: text(),
|
|
622
|
+
ownerId: uuid(),
|
|
623
|
+
})
|
|
624
|
+
const db = createAdminClient()
|
|
625
|
+
|
|
626
|
+
expect(db.select().from(invoices).toSQL().sql).toContain('"display_name"')
|
|
627
|
+
expect(db.select().from(invoices).toSQL().sql).toContain('"owner_id"')
|
|
628
|
+
expect(getTableConfig(invoices).schema).toBe("billing")
|
|
629
|
+
|
|
630
|
+
await db.close()
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
it("enables RLS on a schema table and leaves unsecureTable alone", () => {
|
|
634
|
+
const billing = schema("billing")
|
|
635
|
+
|
|
636
|
+
expect(
|
|
637
|
+
getTableConfig(billing.table("invoices", { ownerId: uuid() })).enableRLS
|
|
638
|
+
).toBe(true)
|
|
639
|
+
expect(
|
|
640
|
+
getTableConfig(billing.unsecureTable("rates", { ownerId: uuid() }))
|
|
641
|
+
.enableRLS
|
|
642
|
+
).toBe(false)
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
it("stays a drizzle schema so the rest of its builders still work", () => {
|
|
646
|
+
const billing = schema("billing")
|
|
647
|
+
|
|
648
|
+
expect(isSchema(billing)).toBe(true)
|
|
649
|
+
expect(billing.schemaName).toBe("billing")
|
|
650
|
+
expect(billing.existing().isExisting).toBe(true)
|
|
651
|
+
expect(isEnum(billing.enum("plan", ["free", "paid"]))).toBe(true)
|
|
652
|
+
expect(isSequence(billing.sequence("invoice_no"))).toBe(true)
|
|
653
|
+
})
|
|
654
|
+
|
|
655
|
+
it("presets the authenticated role and leaves the condition to the caller", () => {
|
|
656
|
+
const condition = sql`true`
|
|
657
|
+
const presets = [
|
|
658
|
+
authenticatedSelectPolicy("s", { using: condition }),
|
|
659
|
+
authenticatedInsertPolicy("i", { withCheck: condition }),
|
|
660
|
+
authenticatedUpdatePolicy("u", { using: condition }),
|
|
661
|
+
authenticatedDeletePolicy("d", { using: condition }),
|
|
662
|
+
authenticatedAllPolicy("a", { using: condition }),
|
|
663
|
+
]
|
|
664
|
+
|
|
665
|
+
expect(presets.map((preset) => preset.for)).toEqual([
|
|
666
|
+
"select",
|
|
667
|
+
"insert",
|
|
668
|
+
"update",
|
|
669
|
+
"delete",
|
|
670
|
+
"all",
|
|
671
|
+
])
|
|
672
|
+
for (const preset of presets) {
|
|
673
|
+
expect(preset.to).toBe(authenticatedRole)
|
|
674
|
+
}
|
|
675
|
+
// No owner check is assumed; the caller's condition is what lands.
|
|
676
|
+
expect(presets[0]?.using).toBe(condition)
|
|
677
|
+
expect(presets[1]?.withCheck).toBe(condition)
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
it("delegates each operation to a security definer function", async () => {
|
|
681
|
+
const posts = table("posts", { id: primaryId("uuid") }, (t) =>
|
|
682
|
+
functionPolicies(t, { argument: t.id })
|
|
683
|
+
)
|
|
684
|
+
const policies = getTableConfig(posts).policies
|
|
685
|
+
const db = createAdminClient()
|
|
686
|
+
|
|
687
|
+
expect(policies.map((rlsPolicy) => rlsPolicy.name)).toEqual([
|
|
688
|
+
"can_select_posts",
|
|
689
|
+
"can_insert_posts",
|
|
690
|
+
"can_update_posts",
|
|
691
|
+
"can_delete_posts",
|
|
692
|
+
])
|
|
693
|
+
for (const rlsPolicy of policies) {
|
|
694
|
+
expect(rlsPolicy.to).toBe(authenticatedRole)
|
|
695
|
+
}
|
|
696
|
+
// using for select and delete, withCheck for insert, both for update.
|
|
697
|
+
expect(policies[0]?.using).toBeDefined()
|
|
698
|
+
expect(policies[0]?.withCheck).toBeUndefined()
|
|
699
|
+
expect(policies[1]?.using).toBeUndefined()
|
|
700
|
+
expect(policies[1]?.withCheck).toBeDefined()
|
|
701
|
+
expect(policies[2]?.using).toBeDefined()
|
|
702
|
+
expect(policies[2]?.withCheck).toBeDefined()
|
|
703
|
+
expect(policies[3]?.using).toBeDefined()
|
|
704
|
+
expect(policies[3]?.withCheck).toBeUndefined()
|
|
705
|
+
|
|
706
|
+
const dialect = new PgDialect()
|
|
707
|
+
|
|
708
|
+
// The shape the issue asks for: the call wrapped in a select, with the
|
|
709
|
+
// column passed through.
|
|
710
|
+
expect(dialect.sqlToQuery(policies[0]?.using as SQL).sql).toBe(
|
|
711
|
+
'(select "can_select_posts"("posts"."id"))'
|
|
712
|
+
)
|
|
713
|
+
expect(dialect.sqlToQuery(policies[2]?.withCheck as SQL).sql).toBe(
|
|
714
|
+
'(select "can_update_posts"("posts"."id"))'
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
expect(db.select().from(posts).toSQL().sql).toBeDefined()
|
|
718
|
+
await db.close()
|
|
719
|
+
})
|
|
720
|
+
|
|
721
|
+
it("calls the function with no argument when none is given", () => {
|
|
722
|
+
const tags = table("tags", { id: primaryId("uuid") }, (t) =>
|
|
723
|
+
functionPolicies(t)
|
|
724
|
+
)
|
|
725
|
+
const policies = getTableConfig(tags).policies
|
|
726
|
+
|
|
727
|
+
expect(policies).toHaveLength(4)
|
|
728
|
+
expect(new PgDialect().sqlToQuery(policies[0]?.using as SQL).sql).toBe(
|
|
729
|
+
'(select "can_select_tags"())'
|
|
730
|
+
)
|
|
731
|
+
})
|
|
732
|
+
|
|
733
|
+
it("takes a prefix and a policy name override", () => {
|
|
734
|
+
const posts = table("posts", { id: primaryId("uuid") }, (t) =>
|
|
735
|
+
functionPolicies(t, {
|
|
736
|
+
name: (operation, tableName) => `${tableName}_${operation}`,
|
|
737
|
+
prefix: "may",
|
|
738
|
+
})
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
expect(getTableConfig(posts).policies.map((p) => p.name)).toEqual([
|
|
742
|
+
"posts_select",
|
|
743
|
+
"posts_insert",
|
|
744
|
+
"posts_update",
|
|
745
|
+
"posts_delete",
|
|
746
|
+
])
|
|
747
|
+
})
|
|
264
748
|
})
|