@drizzle-graphql-suite/schema 0.6.0 → 0.8.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/README.md +95 -3
- package/index.d.ts +7 -1
- package/index.js +255 -13
- package/package.json +1 -1
- package/permissions.d.ts +25 -0
- package/row-security.d.ts +21 -0
- package/schema-builder.d.ts +21 -14
- package/types.d.ts +11 -0
package/README.md
CHANGED
|
@@ -32,6 +32,8 @@ Inspired by [`drizzle-graphql`](https://github.com/drizzle-team/drizzle-graphql)
|
|
|
32
32
|
- **Small generated schema** — the generated schema stays compact even when supporting self-relations and deeply nested relations, thanks to configurable depth limiting (`limitRelationDepth`, `limitSelfRelationDepth`), per-relation pruning (`pruneRelations`), and per-table control (`tables.exclude`, per-table `queries`/`mutations` toggles) — up to 90% schema size reduction when tuned
|
|
33
33
|
- **Native PostgreSQL JSON/JSONB support** — `json` and `jsonb` columns map to a custom `JSON` GraphQL scalar, so structured data passes through without manual type wiring
|
|
34
34
|
- **Relation-level filtering** with EXISTS subqueries (`some`/`every`/`none` quantifiers)
|
|
35
|
+
- **Runtime permissions** — `withPermissions()` builds filtered schemas per role, fully reflected in introspection
|
|
36
|
+
- **Row-level security helpers** — `withRowSecurity()` + `mergeHooks()` for composable auth
|
|
35
37
|
- **Per-operation hooks system** (before/after/resolve) for auth, audit, and custom logic
|
|
36
38
|
- **Count queries** with full filter support
|
|
37
39
|
- **`buildEntities()`** for composable schema building (avoids redundant schema validation)
|
|
@@ -47,14 +49,14 @@ Inspired by [`drizzle-graphql`](https://github.com/drizzle-team/drizzle-graphql)
|
|
|
47
49
|
|
|
48
50
|
### `buildSchema(db, config?)`
|
|
49
51
|
|
|
50
|
-
Builds a complete `GraphQLSchema` with all CRUD operations from a Drizzle database instance. Returns `{ schema, entities }`.
|
|
52
|
+
Builds a complete `GraphQLSchema` with all CRUD operations from a Drizzle database instance. Returns `{ schema, entities, withPermissions }`.
|
|
51
53
|
|
|
52
54
|
```ts
|
|
53
55
|
import { buildSchema } from 'drizzle-graphql-suite/schema'
|
|
54
56
|
import { createYoga } from 'graphql-yoga'
|
|
55
57
|
import { db } from './db'
|
|
56
58
|
|
|
57
|
-
const { schema, entities } = buildSchema(db, {
|
|
59
|
+
const { schema, entities, withPermissions } = buildSchema(db, {
|
|
58
60
|
limitRelationDepth: 3,
|
|
59
61
|
tables: { exclude: ['session'] },
|
|
60
62
|
})
|
|
@@ -62,6 +64,8 @@ const { schema, entities } = buildSchema(db, {
|
|
|
62
64
|
const yoga = createYoga({ schema })
|
|
63
65
|
```
|
|
64
66
|
|
|
67
|
+
See [Runtime Permissions](#runtime-permissions) for `withPermissions` usage.
|
|
68
|
+
|
|
65
69
|
#### Framework Integration
|
|
66
70
|
|
|
67
71
|
**Next.js App Router**
|
|
@@ -200,6 +204,92 @@ Enable diagnostic logging for schema size and relation tree.
|
|
|
200
204
|
- **Type**: `boolean | { schemaSize?: boolean; relationTree?: boolean }`
|
|
201
205
|
- **Default**: `undefined`
|
|
202
206
|
|
|
207
|
+
## Runtime Permissions
|
|
208
|
+
|
|
209
|
+
Build filtered `GraphQLSchema` variants per role or user — introspection fully reflects what each role can see and do.
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
import { buildSchema, permissive, restricted, readOnly } from 'drizzle-graphql-suite/schema'
|
|
213
|
+
|
|
214
|
+
const { schema, withPermissions } = buildSchema(db)
|
|
215
|
+
|
|
216
|
+
// Full schema (admin)
|
|
217
|
+
const adminSchema = schema
|
|
218
|
+
|
|
219
|
+
// Permissive: everything allowed except audit (excluded) and users (read-only)
|
|
220
|
+
const maintainerSchema = withPermissions(
|
|
221
|
+
permissive('maintainer', { audit: false, users: readOnly() }),
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
// Restricted: nothing allowed except posts and comments (queries only)
|
|
225
|
+
const userSchema = withPermissions(
|
|
226
|
+
restricted('user', { posts: { query: true }, comments: { query: true } }),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
// Restricted with nothing granted — only Query { _empty: Boolean }
|
|
230
|
+
const anonSchema = withPermissions(restricted('anon'))
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Schemas are cached by `id` — calling `withPermissions` with the same `id` returns the same `GraphQLSchema` instance.
|
|
234
|
+
|
|
235
|
+
### Permission Helpers
|
|
236
|
+
|
|
237
|
+
| Helper | Description |
|
|
238
|
+
|--------|-------------|
|
|
239
|
+
| `permissive(id, tables?)` | All tables allowed by default; overrides deny |
|
|
240
|
+
| `restricted(id, tables?)` | Nothing allowed by default; overrides grant |
|
|
241
|
+
| `readOnly()` | Shorthand for `{ query: true, insert: false, update: false, delete: false }` |
|
|
242
|
+
|
|
243
|
+
### Table Access
|
|
244
|
+
|
|
245
|
+
Each table can be set to `true` (all operations), `false` (excluded entirely), or a `TableAccess` object:
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
type TableAccess = {
|
|
249
|
+
query?: boolean // list + single + count
|
|
250
|
+
insert?: boolean // insert + insertSingle
|
|
251
|
+
update?: boolean
|
|
252
|
+
delete?: boolean
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
In **permissive** mode, omitted fields default to `true`. In **restricted** mode, omitted fields default to `false`.
|
|
257
|
+
|
|
258
|
+
### Introspection Behavior
|
|
259
|
+
|
|
260
|
+
- `false` (excluded table) — removed from everywhere: no entry points, no relation fields on other types, no filter fields
|
|
261
|
+
- `readOnly()` — table types exist (accessible via relations), but only query entry points; no mutations
|
|
262
|
+
- Granular control — e.g. `{ query: true, insert: true, delete: false }` removes only `deleteFrom{Table}` mutation
|
|
263
|
+
|
|
264
|
+
## Row-Level Security
|
|
265
|
+
|
|
266
|
+
Generate hooks that inject WHERE clauses for row-level filtering. Compose with other hooks using `mergeHooks`.
|
|
267
|
+
|
|
268
|
+
```ts
|
|
269
|
+
import { buildSchema, withRowSecurity, mergeHooks } from 'drizzle-graphql-suite/schema'
|
|
270
|
+
|
|
271
|
+
const { schema } = buildSchema(db, {
|
|
272
|
+
hooks: mergeHooks(
|
|
273
|
+
withRowSecurity({
|
|
274
|
+
posts: (context) => ({ authorId: { eq: context.user.id } }),
|
|
275
|
+
}),
|
|
276
|
+
myOtherHooks,
|
|
277
|
+
),
|
|
278
|
+
})
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
### `withRowSecurity(rules)`
|
|
282
|
+
|
|
283
|
+
Generates a `HooksConfig` with `before` hooks on `query`, `querySingle`, `count`, `update`, and `delete` operations. Each rule is a function that receives the GraphQL context and returns a WHERE filter object.
|
|
284
|
+
|
|
285
|
+
### `mergeHooks(...configs)`
|
|
286
|
+
|
|
287
|
+
Deep-merges multiple `HooksConfig` objects:
|
|
288
|
+
|
|
289
|
+
- **`before` hooks** — chained sequentially; each receives the previous hook's modified args
|
|
290
|
+
- **`after` hooks** — chained sequentially; each receives the previous hook's result
|
|
291
|
+
- **`resolve` hooks** — last one wins (cannot be composed)
|
|
292
|
+
|
|
203
293
|
## Hooks System
|
|
204
294
|
|
|
205
295
|
Hooks intercept operations for auth, validation, audit logging, or custom resolution.
|
|
@@ -321,6 +411,8 @@ query {
|
|
|
321
411
|
|
|
322
412
|
## Code Generation
|
|
323
413
|
|
|
414
|
+
> **When to use:** Only when the client is in a separate repository that cannot import the Drizzle schema directly. For same-repo setups, [`createDrizzleClient`](https://github.com/annexare/drizzle-graphql-suite/tree/main/packages/client) infers all types automatically — no codegen needed.
|
|
415
|
+
|
|
324
416
|
Three code generation functions for producing static artifacts from a GraphQL schema:
|
|
325
417
|
|
|
326
418
|
### `generateSDL(schema)`
|
|
@@ -354,7 +446,7 @@ writeFileSync('generated/types.ts', types)
|
|
|
354
446
|
|
|
355
447
|
### `generateEntityDefs(schema, options?)`
|
|
356
448
|
|
|
357
|
-
Generates a runtime schema descriptor object and `EntityDefs` type for the client package.
|
|
449
|
+
Generates a runtime schema descriptor object and `EntityDefs` type for the client package. Use this instead of `createDrizzleClient` when the client is in a separate repo and can't import the Drizzle schema.
|
|
358
450
|
|
|
359
451
|
```ts
|
|
360
452
|
import { generateEntityDefs } from 'drizzle-graphql-suite/schema'
|
package/index.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { PgDatabase } from 'drizzle-orm/pg-core';
|
|
2
2
|
import type { GraphQLSchema } from 'graphql';
|
|
3
|
-
import type { BuildSchemaConfig, GeneratedEntities } from './types';
|
|
3
|
+
import type { BuildSchemaConfig, GeneratedEntities, PermissionConfig } from './types';
|
|
4
4
|
export { GraphQLJSON } from './graphql/scalars';
|
|
5
5
|
export declare const buildSchema: (db: PgDatabase<any, any, any>, config?: BuildSchemaConfig) => {
|
|
6
6
|
schema: GraphQLSchema;
|
|
7
7
|
entities: GeneratedEntities;
|
|
8
|
+
withPermissions: (permissions: PermissionConfig) => GraphQLSchema;
|
|
9
|
+
clearPermissionCache: (id?: string) => void;
|
|
8
10
|
};
|
|
9
11
|
export declare const buildEntities: (db: PgDatabase<any, any, any>, config?: BuildSchemaConfig) => GeneratedEntities;
|
|
10
12
|
/**
|
|
@@ -19,8 +21,12 @@ export declare const buildEntities: (db: PgDatabase<any, any, any>, config?: Bui
|
|
|
19
21
|
export declare const buildSchemaFromDrizzle: (drizzleSchema: Record<string, unknown>, config?: BuildSchemaConfig) => {
|
|
20
22
|
schema: GraphQLSchema;
|
|
21
23
|
entities: GeneratedEntities;
|
|
24
|
+
withPermissions: (permissions: PermissionConfig) => GraphQLSchema;
|
|
25
|
+
clearPermissionCache: (id?: string) => void;
|
|
22
26
|
};
|
|
23
27
|
export type { CodegenOptions } from './codegen';
|
|
24
28
|
export { generateEntityDefs, generateSDL, generateTypes } from './codegen';
|
|
29
|
+
export { permissive, readOnly, restricted } from './permissions';
|
|
30
|
+
export { mergeHooks, withRowSecurity } from './row-security';
|
|
25
31
|
export { SchemaBuilder } from './schema-builder';
|
|
26
32
|
export * from './types';
|
package/index.js
CHANGED
|
@@ -363,6 +363,103 @@ var drizzleColumnToGraphQLType = (column, columnName, tableName, forceNullable =
|
|
|
363
363
|
return typeDesc;
|
|
364
364
|
};
|
|
365
365
|
|
|
366
|
+
// src/permissions.ts
|
|
367
|
+
var readOnly = () => ({
|
|
368
|
+
query: true,
|
|
369
|
+
insert: false,
|
|
370
|
+
update: false,
|
|
371
|
+
delete: false
|
|
372
|
+
});
|
|
373
|
+
var permissive = (id, tables) => ({
|
|
374
|
+
id,
|
|
375
|
+
mode: "permissive",
|
|
376
|
+
tables
|
|
377
|
+
});
|
|
378
|
+
var restricted = (id, tables) => ({
|
|
379
|
+
id,
|
|
380
|
+
mode: "restricted",
|
|
381
|
+
tables
|
|
382
|
+
});
|
|
383
|
+
function mergePermissionsIntoConfig(baseConfig, permissions, allTableNames) {
|
|
384
|
+
const excluded = [...baseConfig.tables?.exclude ?? []];
|
|
385
|
+
const tableConfig = {
|
|
386
|
+
...baseConfig.tables?.config ?? {}
|
|
387
|
+
};
|
|
388
|
+
const mutationFilter = {};
|
|
389
|
+
for (const tableName of allTableNames) {
|
|
390
|
+
if (excluded.includes(tableName))
|
|
391
|
+
continue;
|
|
392
|
+
const access = resolveTableAccess(permissions, tableName);
|
|
393
|
+
if (access === false) {
|
|
394
|
+
excluded.push(tableName);
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (access === true) {
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
const defaultAllow = permissions.mode === "permissive";
|
|
401
|
+
const queryAllowed = access.query ?? defaultAllow;
|
|
402
|
+
const insertAllowed = access.insert ?? defaultAllow;
|
|
403
|
+
const updateAllowed = access.update ?? defaultAllow;
|
|
404
|
+
const deleteAllowed = access.delete ?? defaultAllow;
|
|
405
|
+
if (!queryAllowed && !insertAllowed && !updateAllowed && !deleteAllowed) {
|
|
406
|
+
excluded.push(tableName);
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
tableConfig[tableName] = {
|
|
410
|
+
...tableConfig[tableName],
|
|
411
|
+
queries: queryAllowed
|
|
412
|
+
};
|
|
413
|
+
const anyMutation = insertAllowed || updateAllowed || deleteAllowed;
|
|
414
|
+
if (!anyMutation) {
|
|
415
|
+
tableConfig[tableName] = { ...tableConfig[tableName], mutations: false };
|
|
416
|
+
} else {
|
|
417
|
+
tableConfig[tableName] = { ...tableConfig[tableName], mutations: true };
|
|
418
|
+
if (!insertAllowed || !updateAllowed || !deleteAllowed) {
|
|
419
|
+
mutationFilter[tableName] = {
|
|
420
|
+
insert: insertAllowed,
|
|
421
|
+
update: updateAllowed,
|
|
422
|
+
delete: deleteAllowed
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
const config = {
|
|
428
|
+
...baseConfig,
|
|
429
|
+
tables: {
|
|
430
|
+
exclude: excluded.length ? excluded : undefined,
|
|
431
|
+
config: Object.keys(tableConfig).length ? tableConfig : undefined
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
return { config, mutationFilter };
|
|
435
|
+
}
|
|
436
|
+
function resolveTableAccess(permissions, tableName) {
|
|
437
|
+
const override = permissions.tables?.[tableName];
|
|
438
|
+
if (permissions.mode === "permissive") {
|
|
439
|
+
if (override === undefined)
|
|
440
|
+
return true;
|
|
441
|
+
return override;
|
|
442
|
+
}
|
|
443
|
+
if (override === undefined)
|
|
444
|
+
return false;
|
|
445
|
+
return override;
|
|
446
|
+
}
|
|
447
|
+
function postFilterMutations(mutations, mutationFilter) {
|
|
448
|
+
for (const [tableName, flags] of Object.entries(mutationFilter)) {
|
|
449
|
+
const cap = capitalize(tableName);
|
|
450
|
+
if (!flags.insert) {
|
|
451
|
+
delete mutations[`insertInto${cap}`];
|
|
452
|
+
delete mutations[`insertInto${cap}Single`];
|
|
453
|
+
}
|
|
454
|
+
if (!flags.update) {
|
|
455
|
+
delete mutations[`update${cap}`];
|
|
456
|
+
}
|
|
457
|
+
if (!flags.delete) {
|
|
458
|
+
delete mutations[`deleteFrom${cap}`];
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
366
463
|
// src/schema-builder.ts
|
|
367
464
|
class SchemaBuilder {
|
|
368
465
|
db;
|
|
@@ -376,6 +473,7 @@ class SchemaBuilder {
|
|
|
376
473
|
suffixes;
|
|
377
474
|
limitRelationDepth;
|
|
378
475
|
limitSelfRelationDepth;
|
|
476
|
+
allTableNames;
|
|
379
477
|
excludedTables;
|
|
380
478
|
tableOperations;
|
|
381
479
|
pruneRelations;
|
|
@@ -434,6 +532,7 @@ class SchemaBuilder {
|
|
|
434
532
|
}
|
|
435
533
|
});
|
|
436
534
|
this.tables = this.extractTables(schema);
|
|
535
|
+
this.allTableNames = Object.keys(this.tables);
|
|
437
536
|
this.excludedTables = new Set(config?.tables?.exclude ?? []);
|
|
438
537
|
this.tableOperations = config?.tables?.config ?? {};
|
|
439
538
|
for (const tableName of this.excludedTables) {
|
|
@@ -533,7 +632,58 @@ class SchemaBuilder {
|
|
|
533
632
|
}
|
|
534
633
|
const schema = new GraphQLSchema(graphQLSchemaConfig);
|
|
535
634
|
this.logDebugInfo(schema);
|
|
536
|
-
|
|
635
|
+
const cache = new Map;
|
|
636
|
+
const db = this.db;
|
|
637
|
+
const baseConfig = this.config;
|
|
638
|
+
const allTableNames = this.allTableNames;
|
|
639
|
+
const withPermissions = (permissions) => {
|
|
640
|
+
const cached = cache.get(permissions.id);
|
|
641
|
+
if (cached)
|
|
642
|
+
return cached;
|
|
643
|
+
const { config: mergedConfig, mutationFilter } = mergePermissionsIntoConfig(baseConfig, permissions, allTableNames);
|
|
644
|
+
const excludedSet = new Set(mergedConfig.tables?.exclude ?? []);
|
|
645
|
+
const hasAnyTable = allTableNames.some((t) => !excludedSet.has(t));
|
|
646
|
+
if (!hasAnyTable) {
|
|
647
|
+
const emptySchema = new GraphQLSchema({
|
|
648
|
+
query: new GraphQLObjectType2({
|
|
649
|
+
name: "Query",
|
|
650
|
+
fields: {
|
|
651
|
+
_empty: { type: GraphQLBoolean2 }
|
|
652
|
+
}
|
|
653
|
+
})
|
|
654
|
+
});
|
|
655
|
+
cache.set(permissions.id, emptySchema);
|
|
656
|
+
return emptySchema;
|
|
657
|
+
}
|
|
658
|
+
const permBuilder = new SchemaBuilder(db, mergedConfig);
|
|
659
|
+
const permEntities = permBuilder.buildEntities();
|
|
660
|
+
if (Object.keys(mutationFilter).length) {
|
|
661
|
+
postFilterMutations(permEntities.mutations, mutationFilter);
|
|
662
|
+
}
|
|
663
|
+
const permSchemaConfig = {
|
|
664
|
+
types: [...Object.values(permEntities.inputs), ...Object.values(permEntities.types)],
|
|
665
|
+
query: new GraphQLObjectType2({
|
|
666
|
+
name: "Query",
|
|
667
|
+
fields: Object.keys(permEntities.queries).length ? permEntities.queries : { _empty: { type: GraphQLBoolean2 } }
|
|
668
|
+
})
|
|
669
|
+
};
|
|
670
|
+
if (mergedConfig.mutations !== false && Object.keys(permEntities.mutations).length) {
|
|
671
|
+
permSchemaConfig.mutation = new GraphQLObjectType2({
|
|
672
|
+
name: "Mutation",
|
|
673
|
+
fields: permEntities.mutations
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
const permSchema = new GraphQLSchema(permSchemaConfig);
|
|
677
|
+
cache.set(permissions.id, permSchema);
|
|
678
|
+
return permSchema;
|
|
679
|
+
};
|
|
680
|
+
const clearPermissionCache = (id) => {
|
|
681
|
+
if (id)
|
|
682
|
+
cache.delete(id);
|
|
683
|
+
else
|
|
684
|
+
cache.clear();
|
|
685
|
+
};
|
|
686
|
+
return { schema, entities, withPermissions, clearPermissionCache };
|
|
537
687
|
}
|
|
538
688
|
logDebugInfo(schema) {
|
|
539
689
|
const debug = this.config.debug;
|
|
@@ -1041,16 +1191,22 @@ class SchemaBuilder {
|
|
|
1041
1191
|
delete operators.OR;
|
|
1042
1192
|
const entries = Object.entries(operators);
|
|
1043
1193
|
if (operators.OR) {
|
|
1044
|
-
|
|
1045
|
-
throw new GraphQLError2(`WHERE ${columnName}: Cannot specify both fields and 'OR' in column operators!`);
|
|
1046
|
-
}
|
|
1047
|
-
const variants2 = [];
|
|
1194
|
+
const orVariants = [];
|
|
1048
1195
|
for (const variant of operators.OR) {
|
|
1049
1196
|
const extracted = this.extractColumnFilters(column, columnName, variant);
|
|
1050
1197
|
if (extracted)
|
|
1051
|
-
|
|
1198
|
+
orVariants.push(extracted);
|
|
1052
1199
|
}
|
|
1053
|
-
|
|
1200
|
+
const orClause = orVariants.length > 1 ? or(...orVariants) : orVariants.length === 1 ? orVariants[0] : undefined;
|
|
1201
|
+
if (entries.length <= 1)
|
|
1202
|
+
return orClause;
|
|
1203
|
+
const { OR: _, ...rest } = operators;
|
|
1204
|
+
const fieldClause = this.extractColumnFilters(column, columnName, rest);
|
|
1205
|
+
if (!fieldClause)
|
|
1206
|
+
return orClause;
|
|
1207
|
+
if (!orClause)
|
|
1208
|
+
return fieldClause;
|
|
1209
|
+
return and(fieldClause, orClause);
|
|
1054
1210
|
}
|
|
1055
1211
|
const comparisonOps = { eq, ne, gt, gte, lt, lte };
|
|
1056
1212
|
const stringOps = { like, notLike, ilike, notIlike };
|
|
@@ -1108,16 +1264,22 @@ class SchemaBuilder {
|
|
|
1108
1264
|
}
|
|
1109
1265
|
}
|
|
1110
1266
|
if (filters.OR) {
|
|
1111
|
-
|
|
1112
|
-
throw new GraphQLError2(`WHERE ${tableName}: Cannot specify both fields and 'OR' in table filters!`);
|
|
1113
|
-
}
|
|
1114
|
-
const variants2 = [];
|
|
1267
|
+
const orVariants = [];
|
|
1115
1268
|
for (const variant of filters.OR) {
|
|
1116
1269
|
const extracted = this.extractAllFilters(table, tableName, variant);
|
|
1117
1270
|
if (extracted)
|
|
1118
|
-
|
|
1271
|
+
orVariants.push(extracted);
|
|
1119
1272
|
}
|
|
1120
|
-
|
|
1273
|
+
const orClause = orVariants.length > 1 ? or(...orVariants) : orVariants.length === 1 ? orVariants[0] : undefined;
|
|
1274
|
+
if (columnEntries.length === 0 && relationEntries.length === 0)
|
|
1275
|
+
return orClause;
|
|
1276
|
+
const { OR: _, ...rest } = filters;
|
|
1277
|
+
const fieldClause = this.extractAllFilters(table, tableName, rest);
|
|
1278
|
+
if (!fieldClause)
|
|
1279
|
+
return orClause;
|
|
1280
|
+
if (!orClause)
|
|
1281
|
+
return fieldClause;
|
|
1282
|
+
return and(fieldClause, orClause);
|
|
1121
1283
|
}
|
|
1122
1284
|
const variants = [];
|
|
1123
1285
|
for (const [columnName, operators] of columnEntries) {
|
|
@@ -1774,6 +1936,81 @@ function generateEntityDefs(schema, options) {
|
|
|
1774
1936
|
return lines.join(`
|
|
1775
1937
|
`);
|
|
1776
1938
|
}
|
|
1939
|
+
// src/row-security.ts
|
|
1940
|
+
function withRowSecurity(rules) {
|
|
1941
|
+
const hooks = {};
|
|
1942
|
+
for (const [tableName, rule] of Object.entries(rules)) {
|
|
1943
|
+
const before = (ctx) => {
|
|
1944
|
+
const whereClause = rule(ctx.context);
|
|
1945
|
+
const existingWhere = ctx.args?.where;
|
|
1946
|
+
const mergedWhere = existingWhere ? { ...existingWhere, ...whereClause } : whereClause;
|
|
1947
|
+
return { args: { ...ctx.args, where: mergedWhere } };
|
|
1948
|
+
};
|
|
1949
|
+
const ops = ["query", "querySingle", "count", "update", "delete"];
|
|
1950
|
+
const tableHooks = {};
|
|
1951
|
+
for (const op of ops) {
|
|
1952
|
+
tableHooks[op] = { before };
|
|
1953
|
+
}
|
|
1954
|
+
hooks[tableName] = tableHooks;
|
|
1955
|
+
}
|
|
1956
|
+
return hooks;
|
|
1957
|
+
}
|
|
1958
|
+
function mergeHooks(...configs) {
|
|
1959
|
+
const result = {};
|
|
1960
|
+
for (const config of configs) {
|
|
1961
|
+
if (!config)
|
|
1962
|
+
continue;
|
|
1963
|
+
for (const [tableName, tableHooks] of Object.entries(config)) {
|
|
1964
|
+
if (!result[tableName]) {
|
|
1965
|
+
result[tableName] = {};
|
|
1966
|
+
}
|
|
1967
|
+
const existing = result[tableName];
|
|
1968
|
+
for (const [op, hooks] of Object.entries(tableHooks)) {
|
|
1969
|
+
if (!existing[op]) {
|
|
1970
|
+
existing[op] = hooks;
|
|
1971
|
+
continue;
|
|
1972
|
+
}
|
|
1973
|
+
const existingOp = existing[op];
|
|
1974
|
+
if ("resolve" in hooks) {
|
|
1975
|
+
existing[op] = hooks;
|
|
1976
|
+
continue;
|
|
1977
|
+
}
|
|
1978
|
+
if ("resolve" in existingOp) {
|
|
1979
|
+
existing[op] = hooks;
|
|
1980
|
+
continue;
|
|
1981
|
+
}
|
|
1982
|
+
const merged = {};
|
|
1983
|
+
if (hooks.before && existingOp.before) {
|
|
1984
|
+
const first = existingOp.before;
|
|
1985
|
+
const second = hooks.before;
|
|
1986
|
+
merged.before = async (ctx) => {
|
|
1987
|
+
const firstResult = await first(ctx);
|
|
1988
|
+
const nextCtx = firstResult?.args ? { ...ctx, args: firstResult.args } : ctx;
|
|
1989
|
+
const secondResult = await second(nextCtx);
|
|
1990
|
+
return {
|
|
1991
|
+
args: secondResult?.args ?? firstResult?.args ?? undefined,
|
|
1992
|
+
data: secondResult?.data ?? firstResult?.data ?? undefined
|
|
1993
|
+
};
|
|
1994
|
+
};
|
|
1995
|
+
} else {
|
|
1996
|
+
merged.before = hooks.before ?? existingOp.before;
|
|
1997
|
+
}
|
|
1998
|
+
if (hooks.after && existingOp.after) {
|
|
1999
|
+
const first = existingOp.after;
|
|
2000
|
+
const second = hooks.after;
|
|
2001
|
+
merged.after = async (ctx) => {
|
|
2002
|
+
const firstResult = await first(ctx);
|
|
2003
|
+
return second({ ...ctx, result: firstResult });
|
|
2004
|
+
};
|
|
2005
|
+
} else {
|
|
2006
|
+
merged.after = hooks.after ?? existingOp.after;
|
|
2007
|
+
}
|
|
2008
|
+
existing[op] = merged;
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
return result;
|
|
2013
|
+
}
|
|
1777
2014
|
|
|
1778
2015
|
// src/index.ts
|
|
1779
2016
|
var buildSchema = (db, config) => {
|
|
@@ -1801,6 +2038,11 @@ var buildSchemaFromDrizzle = (drizzleSchema, config) => {
|
|
|
1801
2038
|
return builder.build();
|
|
1802
2039
|
};
|
|
1803
2040
|
export {
|
|
2041
|
+
withRowSecurity,
|
|
2042
|
+
restricted,
|
|
2043
|
+
readOnly,
|
|
2044
|
+
permissive,
|
|
2045
|
+
mergeHooks,
|
|
1804
2046
|
generateTypes,
|
|
1805
2047
|
generateSDL,
|
|
1806
2048
|
generateEntityDefs,
|
package/package.json
CHANGED
package/permissions.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { GraphQLFieldConfig } from 'graphql';
|
|
2
|
+
import type { BuildSchemaConfig, PermissionConfig, TableAccess } from './types';
|
|
3
|
+
export declare const readOnly: () => TableAccess;
|
|
4
|
+
export declare const permissive: (id: string, tables?: Record<string, boolean | TableAccess>) => PermissionConfig;
|
|
5
|
+
export declare const restricted: (id: string, tables?: Record<string, boolean | TableAccess>) => PermissionConfig;
|
|
6
|
+
type MergeResult = {
|
|
7
|
+
config: BuildSchemaConfig;
|
|
8
|
+
/** Tables that need individual mutation entry points filtered after build */
|
|
9
|
+
mutationFilter: Record<string, {
|
|
10
|
+
insert: boolean;
|
|
11
|
+
update: boolean;
|
|
12
|
+
delete: boolean;
|
|
13
|
+
}>;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Converts a PermissionConfig into BuildSchemaConfig overrides.
|
|
17
|
+
* Returns the merged config + a map of tables needing post-build mutation filtering.
|
|
18
|
+
*/
|
|
19
|
+
export declare function mergePermissionsIntoConfig(baseConfig: BuildSchemaConfig, permissions: PermissionConfig, allTableNames: string[]): MergeResult;
|
|
20
|
+
/**
|
|
21
|
+
* Removes disallowed mutation entry points from the mutations record.
|
|
22
|
+
* Mutates and returns the same record.
|
|
23
|
+
*/
|
|
24
|
+
export declare function postFilterMutations(mutations: Record<string, GraphQLFieldConfig<any, any>>, mutationFilter: MergeResult['mutationFilter']): void;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { HooksConfig } from './types';
|
|
2
|
+
type RowSecurityRule = (context: any) => Record<string, unknown>;
|
|
3
|
+
/**
|
|
4
|
+
* Generates a HooksConfig that injects WHERE clauses from row-level security rules.
|
|
5
|
+
* Rules are applied as `before` hooks on query, querySingle, count, update, and delete operations.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* const hooks = withRowSecurity({
|
|
9
|
+
* posts: (context) => ({ authorId: { eq: context.user.id } }),
|
|
10
|
+
* })
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
export declare function withRowSecurity(rules: Record<string, RowSecurityRule>): HooksConfig;
|
|
14
|
+
/**
|
|
15
|
+
* Deep-merges multiple HooksConfig objects.
|
|
16
|
+
* - `before` hooks are chained: each runs sequentially, passing the modified args forward.
|
|
17
|
+
* - `after` hooks are chained: each runs sequentially, passing the modified result forward.
|
|
18
|
+
* - `resolve` hooks: last one wins (cannot be composed).
|
|
19
|
+
*/
|
|
20
|
+
export declare function mergeHooks(...configs: (HooksConfig | undefined)[]): HooksConfig;
|
|
21
|
+
export {};
|
package/schema-builder.d.ts
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Column, Relation, Table, TablesRelationalConfig } from 'drizzle-orm';
|
|
2
|
+
import { type SQL } from 'drizzle-orm';
|
|
3
|
+
import { type PgDatabase, PgTable } from 'drizzle-orm/pg-core';
|
|
2
4
|
import { GraphQLSchema } from 'graphql';
|
|
3
|
-
import type {
|
|
5
|
+
import type { ResolveTree } from 'graphql-parse-resolve-info';
|
|
6
|
+
import { type TableNamedRelations } from './data-mappers';
|
|
7
|
+
import type { BuildSchemaConfig, GeneratedEntities, PermissionConfig } from './types';
|
|
4
8
|
export declare class SchemaBuilder {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
protected db: PgDatabase<any, any, any>;
|
|
10
|
+
protected tables: Record<string, PgTable>;
|
|
11
|
+
protected relationMap: Record<string, Record<string, TableNamedRelations>>;
|
|
12
|
+
protected relationalSchema: TablesRelationalConfig;
|
|
13
|
+
protected tableNamesMap: Record<string, string>;
|
|
10
14
|
private config;
|
|
11
15
|
private hooks;
|
|
12
16
|
private adapter;
|
|
13
17
|
private suffixes;
|
|
14
18
|
private limitRelationDepth;
|
|
15
19
|
private limitSelfRelationDepth;
|
|
20
|
+
private allTableNames;
|
|
16
21
|
private excludedTables;
|
|
17
22
|
private tableOperations;
|
|
18
23
|
private pruneRelations;
|
|
@@ -27,6 +32,8 @@ export declare class SchemaBuilder {
|
|
|
27
32
|
build(): {
|
|
28
33
|
schema: GraphQLSchema;
|
|
29
34
|
entities: GeneratedEntities;
|
|
35
|
+
withPermissions: (permissions: PermissionConfig) => GraphQLSchema;
|
|
36
|
+
clearPermissionCache: (id?: string) => void;
|
|
30
37
|
};
|
|
31
38
|
private logDebugInfo;
|
|
32
39
|
private extractTables;
|
|
@@ -47,15 +54,15 @@ export declare class SchemaBuilder {
|
|
|
47
54
|
private createInsertSingleResolver;
|
|
48
55
|
private createUpdateResolver;
|
|
49
56
|
private createDeleteResolver;
|
|
50
|
-
|
|
51
|
-
|
|
57
|
+
protected extractColumnFilters(column: Column, columnName: string, operators: any): SQL | undefined;
|
|
58
|
+
protected extractTableColumnFilters(table: Table, tableName: string, filters: any): SQL | undefined;
|
|
52
59
|
/** Combined filter extraction: column filters + relation filters */
|
|
53
60
|
private extractAllFilters;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
61
|
+
protected extractRelationFilters(table: Table, tableName: string, relationName: string, filterValue: any): SQL | undefined;
|
|
62
|
+
protected buildExistsSubquery(parentTable: Table, targetTable: Table, relation: Relation, targetTableName: string, filterValue: any, quantifier: 'some' | 'every' | 'none'): SQL | undefined;
|
|
63
|
+
protected buildJoinCondition(parentTable: Table, _targetTable: Table, relation: Relation): SQL | undefined;
|
|
64
|
+
protected extractOrderBy(table: Table, orderArgs: any): SQL[];
|
|
65
|
+
protected extractColumns(tree: Record<string, ResolveTree>, table: Table): Record<string, true>;
|
|
59
66
|
private extractColumnsSQLFormat;
|
|
60
67
|
private getFieldsByTypeName;
|
|
61
68
|
private extractRelationsParams;
|
package/types.d.ts
CHANGED
|
@@ -39,6 +39,17 @@ export type TableHookConfig = {
|
|
|
39
39
|
export type HooksConfig = {
|
|
40
40
|
[tableName: string]: TableHookConfig;
|
|
41
41
|
};
|
|
42
|
+
export type TableAccess = {
|
|
43
|
+
query?: boolean;
|
|
44
|
+
insert?: boolean;
|
|
45
|
+
update?: boolean;
|
|
46
|
+
delete?: boolean;
|
|
47
|
+
};
|
|
48
|
+
export type PermissionConfig = {
|
|
49
|
+
id: string;
|
|
50
|
+
mode: 'permissive' | 'restricted';
|
|
51
|
+
tables?: Record<string, boolean | TableAccess>;
|
|
52
|
+
};
|
|
42
53
|
export type TableOperations = {
|
|
43
54
|
/** Generate query operations (list, single, count). @default true */
|
|
44
55
|
queries?: boolean;
|