@zeno-lib/db 0.2.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.
- package/package.json +1 -1
- package/src/casing.test.ts +73 -3
- package/src/schema.ts +86 -21
package/package.json
CHANGED
package/src/casing.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getTableColumns, type SQL, sql } from "drizzle-orm"
|
|
2
2
|
import {
|
|
3
|
+
bigint,
|
|
3
4
|
getTableConfig,
|
|
4
5
|
isPgEnum,
|
|
5
6
|
isPgMaterializedView,
|
|
@@ -693,13 +694,16 @@ describe("default casing", () => {
|
|
|
693
694
|
for (const rlsPolicy of policies) {
|
|
694
695
|
expect(rlsPolicy.to).toBe(authenticatedRole)
|
|
695
696
|
}
|
|
696
|
-
// using for select and delete, withCheck for insert
|
|
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.
|
|
697
701
|
expect(policies[0]?.using).toBeDefined()
|
|
698
702
|
expect(policies[0]?.withCheck).toBeUndefined()
|
|
699
703
|
expect(policies[1]?.using).toBeUndefined()
|
|
700
704
|
expect(policies[1]?.withCheck).toBeDefined()
|
|
701
705
|
expect(policies[2]?.using).toBeDefined()
|
|
702
|
-
expect(policies[2]?.withCheck).
|
|
706
|
+
expect(policies[2]?.withCheck).toBeUndefined()
|
|
703
707
|
expect(policies[3]?.using).toBeDefined()
|
|
704
708
|
expect(policies[3]?.withCheck).toBeUndefined()
|
|
705
709
|
|
|
@@ -710,7 +714,7 @@ describe("default casing", () => {
|
|
|
710
714
|
expect(dialect.sqlToQuery(policies[0]?.using as SQL).sql).toBe(
|
|
711
715
|
'(select "can_select_posts"("posts"."id"))'
|
|
712
716
|
)
|
|
713
|
-
expect(dialect.sqlToQuery(policies[2]?.
|
|
717
|
+
expect(dialect.sqlToQuery(policies[2]?.using as SQL).sql).toBe(
|
|
714
718
|
'(select "can_update_posts"("posts"."id"))'
|
|
715
719
|
)
|
|
716
720
|
|
|
@@ -745,4 +749,70 @@ describe("default casing", () => {
|
|
|
745
749
|
"posts_delete",
|
|
746
750
|
])
|
|
747
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
|
+
})
|
|
748
818
|
})
|
package/src/schema.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// https://orm.drizzle.team/docs/rls#using-with-supabase (re-exported roles, authUid, realtimeMessages)
|
|
2
|
-
import { getColumnTable, getTableName, sql } from "drizzle-orm"
|
|
2
|
+
import { Column, getColumnTable, getTableName, is, sql } from "drizzle-orm"
|
|
3
3
|
import {
|
|
4
4
|
type AnyPgColumn,
|
|
5
5
|
bigint,
|
|
@@ -393,15 +393,18 @@ export const authenticatedAllPolicy = (
|
|
|
393
393
|
config: AuthenticatedPolicyOptions = {}
|
|
394
394
|
) => allPolicy(name, { ...config, to: authenticatedRole })
|
|
395
395
|
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
396
|
+
// The clause each operation's condition belongs in: `using` filters the rows
|
|
397
|
+
// already there, `withCheck` vets the rows going in. `update` and `all` accept
|
|
398
|
+
// both, but Postgres reuses `using` for the check when `withCheck` is left out,
|
|
399
|
+
// so one clause says the same thing and leaves the catalog matching the
|
|
400
|
+
// `USING`-only policies `drizzle-kit pull` reads back from an existing database.
|
|
401
|
+
const POLICY_CLAUSE = {
|
|
402
|
+
all: "using",
|
|
403
|
+
delete: "using",
|
|
404
|
+
insert: "withCheck",
|
|
405
|
+
select: "using",
|
|
406
|
+
update: "using",
|
|
407
|
+
} as const satisfies Record<PolicyOperation, "using" | "withCheck">
|
|
405
408
|
|
|
406
409
|
const POLICY_BUILDERS = {
|
|
407
410
|
all: allPolicy,
|
|
@@ -418,15 +421,62 @@ const FUNCTION_POLICY_OPERATIONS = [
|
|
|
418
421
|
"delete",
|
|
419
422
|
] as const
|
|
420
423
|
|
|
424
|
+
type FunctionPolicyOperation = (typeof FUNCTION_POLICY_OPERATIONS)[number]
|
|
425
|
+
|
|
426
|
+
/** Columns one function is called with. `null` calls it with none. */
|
|
427
|
+
type FunctionArgument = AnyPgColumn | readonly AnyPgColumn[] | null
|
|
428
|
+
|
|
421
429
|
type FunctionPoliciesOptions = {
|
|
422
|
-
/**
|
|
423
|
-
|
|
430
|
+
/**
|
|
431
|
+
* Passed to each function, e.g. the row's id: one column, an array for a
|
|
432
|
+
* function taking several, or a record to vary them per operation. The
|
|
433
|
+
* common shape is that the row-scoped operations take the row and `insert`
|
|
434
|
+
* takes nothing, there being no row yet to authorise, only the caller. An
|
|
435
|
+
* operation missing from the record, or set to `null`, is called with no
|
|
436
|
+
* arguments; so is every operation when `argument` is omitted.
|
|
437
|
+
*/
|
|
438
|
+
argument?:
|
|
439
|
+
| FunctionArgument
|
|
440
|
+
| Partial<Record<FunctionPolicyOperation, FunctionArgument>>
|
|
441
|
+
/**
|
|
442
|
+
* Schema the functions live in, e.g. `"billing"` for
|
|
443
|
+
* `billing.can_select_invoices`. Omit to emit the name unqualified and let
|
|
444
|
+
* the `search_path` in effect resolve it.
|
|
445
|
+
*/
|
|
446
|
+
schema?: string
|
|
424
447
|
/** Prefix for the default function and policy names. Default `"can"`. */
|
|
425
448
|
prefix?: string
|
|
426
449
|
/** Overrides the generated policy name. */
|
|
427
450
|
name?: (operation: PolicyOperation, table: string) => string
|
|
428
451
|
}
|
|
429
452
|
|
|
453
|
+
// A column and an array of them are the whole-set form; anything else is the
|
|
454
|
+
// per-operation record. `is` is how drizzle asks "is this one of mine", and a
|
|
455
|
+
// column is the only entity either form can hold.
|
|
456
|
+
const isWholeSetArgument = (
|
|
457
|
+
argument: NonNullable<FunctionPoliciesOptions["argument"]>
|
|
458
|
+
): argument is NonNullable<FunctionArgument> =>
|
|
459
|
+
is(argument, Column) || Array.isArray(argument)
|
|
460
|
+
|
|
461
|
+
const argumentsFor = (
|
|
462
|
+
argument: FunctionPoliciesOptions["argument"],
|
|
463
|
+
operation: FunctionPolicyOperation
|
|
464
|
+
): AnyPgColumn[] => {
|
|
465
|
+
if (!argument) {
|
|
466
|
+
return []
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const forOperation = isWholeSetArgument(argument)
|
|
470
|
+
? argument
|
|
471
|
+
: argument[operation]
|
|
472
|
+
|
|
473
|
+
if (!forOperation) {
|
|
474
|
+
return []
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return is(forOperation, Column) ? [forOperation] : [...forOperation]
|
|
478
|
+
}
|
|
479
|
+
|
|
430
480
|
// The columns object drizzle hands the extra-config callback. Taking it rather
|
|
431
481
|
// than the table is what keeps `functionPolicies` usable: naming the table
|
|
432
482
|
// inside its own definition makes its type circular, so `(t) => ...` is the
|
|
@@ -450,10 +500,24 @@ type ExtraConfigColumns = Record<string, ExtraConfigColumn>
|
|
|
450
500
|
* CREATE POLICY "can_select_posts" ON "posts" FOR SELECT TO "authenticated"
|
|
451
501
|
* USING ((select "can_select_posts"("posts"."id")));
|
|
452
502
|
* ```
|
|
503
|
+
*
|
|
504
|
+
* `argument` also takes a record, for the usual shape where `insert` has no
|
|
505
|
+
* row to authorise yet:
|
|
506
|
+
*
|
|
507
|
+
* ```ts
|
|
508
|
+
* functionPolicies(t, {
|
|
509
|
+
* argument: { delete: t.id, select: t.id, update: t.id },
|
|
510
|
+
* })
|
|
511
|
+
* ```
|
|
453
512
|
*/
|
|
454
513
|
export const functionPolicies = (
|
|
455
514
|
columns: ExtraConfigColumns,
|
|
456
|
-
{
|
|
515
|
+
{
|
|
516
|
+
argument,
|
|
517
|
+
name,
|
|
518
|
+
prefix = "can",
|
|
519
|
+
schema: functionSchema,
|
|
520
|
+
}: FunctionPoliciesOptions = {}
|
|
457
521
|
) => {
|
|
458
522
|
const [firstColumn] = Object.values(columns)
|
|
459
523
|
|
|
@@ -465,19 +529,20 @@ export const functionPolicies = (
|
|
|
465
529
|
|
|
466
530
|
return FUNCTION_POLICY_OPERATIONS.map((operation) => {
|
|
467
531
|
const functionName = `${prefix}_${operation}_${tableName}`
|
|
532
|
+
// Unqualified by default, so the name resolves through `search_path` the
|
|
533
|
+
// way a hand-written policy would; `schema` pins it to the functions that
|
|
534
|
+
// live beside their table instead.
|
|
535
|
+
const callee = functionSchema
|
|
536
|
+
? sql`${sql.identifier(functionSchema)}.${sql.identifier(functionName)}`
|
|
537
|
+
: sql`${sql.identifier(functionName)}`
|
|
468
538
|
// `select` wraps it so Postgres evaluates the call once per statement
|
|
469
539
|
// rather than once per row, the same shape `authUid` uses.
|
|
470
|
-
const condition = argument
|
|
471
|
-
|
|
472
|
-
: sql`(select ${sql.identifier(functionName)}())`
|
|
473
|
-
const clauses = POLICY_CLAUSES[operation]
|
|
540
|
+
const condition = sql`(select ${callee}(${sql.join(argumentsFor(argument, operation), sql`, `)}))`
|
|
541
|
+
const clause = { [POLICY_CLAUSE[operation]]: condition }
|
|
474
542
|
|
|
475
543
|
return POLICY_BUILDERS[operation](
|
|
476
544
|
name?.(operation, tableName) ?? functionName,
|
|
477
|
-
{
|
|
478
|
-
to: authenticatedRole,
|
|
479
|
-
...Object.fromEntries(clauses.map((clause) => [clause, condition])),
|
|
480
|
-
}
|
|
545
|
+
{ to: authenticatedRole, ...clause }
|
|
481
546
|
)
|
|
482
547
|
})
|
|
483
548
|
}
|