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