@vantreeseba/drizzle-graphql 7.1.0 → 7.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/README.md CHANGED
@@ -124,6 +124,60 @@ const { schema } = buildSchema(db, {
124
124
  `mutations: false`
125
125
  - List and single queries are always generated, so `Query` is never empty
126
126
 
127
+ ## Excluding tables and columns
128
+
129
+ `features` decides which *kinds* of operation exist. `exclude` decides which tables and
130
+ columns exist at all — for the table holding session tokens, or the column holding a
131
+ password hash:
132
+
133
+ ```Typescript
134
+ const { schema } = buildSchema(db, {
135
+ exclude: {
136
+ tables: ['magicLinks', 'sessions'],
137
+ columns: { apiKeys: ['keyHash'] },
138
+ },
139
+ })
140
+ ```
141
+
142
+ Both lists are keyed by the **Drizzle schema key**, not the database name and not the
143
+ generated GraphQL name.
144
+
145
+ An excluded **table** generates nothing: no object type, no queries, no mutations, no
146
+ aggregates, and no relation field on any other table that points at it. `Users.sessions`
147
+ is gone, and so is `users(where: { sessions: { … } })` — the table is unreachable, not
148
+ merely unnamed.
149
+
150
+ An excluded **column** disappears from every surface at once:
151
+
152
+ | Surface | Effect |
153
+ | --- | --- |
154
+ | `<Type>` object type | field removed |
155
+ | `Create<Type>Input` / `Update<Type>Input` | field removed (nested-write payloads too) |
156
+ | `<Type>Filters` | field removed |
157
+ | `<Type>OrderBy` | field removed |
158
+ | `<Type>MinAggregate` / `<Type>MaxAggregate` / … | field removed |
159
+ | `<Type>DistinctColumn` / `<Type>GroupByColumn` | value removed |
160
+ | `<Type>ConflictColumn` (upsert targets) | value removed |
161
+
162
+ Two details worth stating plainly:
163
+
164
+ - **The exclusion is all-or-nothing on purpose.** A column you can filter on but not
165
+ select is an oracle: `where: { keyHash: { eq: '…' } }` confirms a value without ever
166
+ returning it. Ordering and grouping leak the same way, more slowly. There is no
167
+ read-only or write-only variant of `exclude` for this reason.
168
+ - **Nothing changes below the schema.** An excluded column is still written by its
169
+ default, still read by the primary-key and cursor machinery, and still usable by your
170
+ own resolvers. Only the generated schema stops mentioning it.
171
+
172
+ Names that match nothing throw at build time, so renaming a column fails the build instead
173
+ of quietly un-hiding it. Excluding a `NOT NULL` column with no default is allowed — the
174
+ table becomes readable but not insertable — and warns on the console, since that is more
175
+ often a mistake than a plan.
176
+
177
+ > `exclude` is a static decision: the column is absent for every caller. Deciding per
178
+ > request who may see what is a job for [`graphql-shield`](https://the-guild.dev/graphql/shield)
179
+ > or an equivalent layer above the schema, which has the request context this library
180
+ > does not.
127
181
  ## Naming
128
182
 
129
183
  Type and field names are derived from the Drizzle schema keys. Three options change them.
@@ -348,6 +402,124 @@ A **to-many** relation takes a `some` / `none` / `every` wrapper
348
402
  `users(where: { roles: { some: { name: { eq: "admin" } } } })` works the same as a direct
349
403
  to-many relation
350
404
 
405
+ ## Matching lists of values
406
+
407
+ `inArray` / `notInArray` take a list of candidate values and compile to SQL `IN` / `NOT IN`.
408
+ Two things about them are worth stating explicitly.
409
+
410
+ **An empty list is answered, not rejected.** A caller building a filter from a variable often
411
+ ends up with an empty list, and that list has a well-defined meaning: nothing is `IN ()` and
412
+ everything is `NOT IN ()`.
413
+
414
+ ```graphql
415
+ {
416
+ # no rows
417
+ posts(where: { id: { inArray: [] } }) {
418
+ id
419
+ }
420
+
421
+ # every row
422
+ posts(where: { id: { notInArray: [] } }) {
423
+ id
424
+ }
425
+ }
426
+ ```
427
+
428
+ The same rule applies to the Postgres array-column operators: `hasSome: []` matches nothing
429
+ (overlapping with no elements is never true) and `hasEvery: []` matches everything (every
430
+ array contains all zero of them).
431
+
432
+ **The list's own order can be the sort order.** SQL returns `IN` matches in whatever order it
433
+ likes, so a caller that fetches rows by a ranked list of ids normally has to re-sort them
434
+ client-side. Setting `matchFilterOrder` on an `orderBy` entry sorts by the row's position in
435
+ that column's `inArray` list instead of by the column's value:
436
+
437
+ ```graphql
438
+ {
439
+ posts(
440
+ where: { id: { inArray: [7, 3, 12] } }
441
+ orderBy: { id: { direction: asc, priority: 1, matchFilterOrder: true } }
442
+ ) {
443
+ id # → 7, 3, 12
444
+ }
445
+ }
446
+ ```
447
+
448
+ - `direction: asc` keeps the list's order; `desc` reverses it
449
+ - It needs an `inArray` filter on the same column, at the top level of `where` — an
450
+ `inArray` buried inside an `OR` branch raises an error rather than being guessed at
451
+ - It interleaves with the other `orderBy` entries by `priority` like any ordering key, and
452
+ works on relation fields as well as root lists
453
+ - It compiles to a `CASE` ladder over the list values, so it behaves identically on all
454
+ three dialects and every value stays a bound parameter
455
+ - It cannot be combined with `after` or `distinct`: a row's position in the request's own
456
+ filter list is not a value of the row, so it cannot be encoded into a cursor or rebuilt
457
+ against a subquery. Selecting `cursor` under it yields `null` rather than an error
458
+
459
+ ## Filtering inside JSON documents
460
+
461
+ `json` / `jsonb` columns get a `path` filter that compares the value at one path inside the
462
+ document, alongside the whole-document `eq` / `ne` / `contains` operators:
463
+
464
+ ```graphql
465
+ {
466
+ users(where: { meta: { path: { path: ["profile", "level"], gt: 2 } } }) {
467
+ id
468
+ }
469
+ }
470
+ ```
471
+
472
+ `path` walks the document key by key from the root; an all-digits key indexes an array
473
+ (`["tags", "0"]`). Several predicates may be given at once and are `AND`ed:
474
+
475
+ ```graphql
476
+ {
477
+ users(
478
+ where: {
479
+ meta: {
480
+ path: [
481
+ { path: ["profile", "role"], eq: "admin" }
482
+ { path: ["profile", "level"], gte: 3 }
483
+ ]
484
+ }
485
+ }
486
+ ) {
487
+ id
488
+ }
489
+ }
490
+ ```
491
+
492
+ Available operators are `eq` / `ne` / `lt` / `lte` / `gt` / `gte`, the safe string matchers
493
+ `startsWith` / `endsWith` / `contains` (plus their case-insensitive `i`-prefixed forms), and
494
+ `isNull` / `isNotNull`. A missing key and a JSON `null` both read as null.
495
+
496
+ **How the value is compared** is decided by the operand: a GraphQL number compares
497
+ numerically, a boolean as a boolean, and anything else as text. Pass `as: TEXT | NUMBER |
498
+ BOOLEAN` when the operand's type doesn't match the document's — comparing a numeric field
499
+ against a `String` variable, say:
500
+
501
+ ```graphql
502
+ {
503
+ users(where: { meta: { path: { path: ["profile", "level"], as: NUMBER, eq: "3" } } }) {
504
+ id
505
+ }
506
+ }
507
+ ```
508
+
509
+ A value of the wrong shape never matches rather than failing the query: `gt: 0` against a
510
+ document whose path holds `"admin"` returns no rows on every dialect. That takes a guard on
511
+ each one — Postgres errors outright on a bad `::numeric`, MySQL quietly casts a non-numeric
512
+ string to `0`, and SQLite's cross-type ordering would otherwise sort every string above every
513
+ number.
514
+
515
+ Note that `contains` means different things at the two levels, because a path names a scalar:
516
+ on the column it is structural JSON containment (Postgres `@>` / MySQL `JSON_CONTAINS`),
517
+ inside a `path` filter it is substring matching on the extracted value.
518
+
519
+ Path filters combine with `AND` / `OR` / `NOT` like any other operator, and are accepted
520
+ everywhere a filter is — list, single and aggregate queries, `update` / `delete`, and the
521
+ `where` argument on a relation field.
522
+
351
523
  ## Aggregate queries
352
524
 
353
525
  Every table also gets an aggregate query field — `<tableName>Aggregate` (e.g. `usersAggregate`),
@@ -588,8 +760,8 @@ field, and passing it back as `after` resumes strictly after that row.
588
760
  - Dates and bigints round-trip losslessly through the cursor
589
761
  - A cursor is only valid under the ordering it was issued for — reusing it with a
590
762
  different `orderBy` (or a malformed/corrupted cursor) returns a `GraphQLError`
591
- - `after` cannot be combined with `distinct`, and a table with no primary key cannot use
592
- cursor pagination (its `cursor` field resolves to `null`)
763
+ - `after` cannot be combined with `distinct` or with `matchFilterOrder`, and a table with
764
+ no primary key cannot use cursor pagination (its `cursor` field resolves to `null`)
593
765
  - If a table has a real column named `cursor`, the column wins and the meta field is
594
766
  skipped
595
767
 
package/dist/README.md CHANGED
@@ -124,6 +124,60 @@ const { schema } = buildSchema(db, {
124
124
  `mutations: false`
125
125
  - List and single queries are always generated, so `Query` is never empty
126
126
 
127
+ ## Excluding tables and columns
128
+
129
+ `features` decides which *kinds* of operation exist. `exclude` decides which tables and
130
+ columns exist at all — for the table holding session tokens, or the column holding a
131
+ password hash:
132
+
133
+ ```Typescript
134
+ const { schema } = buildSchema(db, {
135
+ exclude: {
136
+ tables: ['magicLinks', 'sessions'],
137
+ columns: { apiKeys: ['keyHash'] },
138
+ },
139
+ })
140
+ ```
141
+
142
+ Both lists are keyed by the **Drizzle schema key**, not the database name and not the
143
+ generated GraphQL name.
144
+
145
+ An excluded **table** generates nothing: no object type, no queries, no mutations, no
146
+ aggregates, and no relation field on any other table that points at it. `Users.sessions`
147
+ is gone, and so is `users(where: { sessions: { … } })` — the table is unreachable, not
148
+ merely unnamed.
149
+
150
+ An excluded **column** disappears from every surface at once:
151
+
152
+ | Surface | Effect |
153
+ | --- | --- |
154
+ | `<Type>` object type | field removed |
155
+ | `Create<Type>Input` / `Update<Type>Input` | field removed (nested-write payloads too) |
156
+ | `<Type>Filters` | field removed |
157
+ | `<Type>OrderBy` | field removed |
158
+ | `<Type>MinAggregate` / `<Type>MaxAggregate` / … | field removed |
159
+ | `<Type>DistinctColumn` / `<Type>GroupByColumn` | value removed |
160
+ | `<Type>ConflictColumn` (upsert targets) | value removed |
161
+
162
+ Two details worth stating plainly:
163
+
164
+ - **The exclusion is all-or-nothing on purpose.** A column you can filter on but not
165
+ select is an oracle: `where: { keyHash: { eq: '…' } }` confirms a value without ever
166
+ returning it. Ordering and grouping leak the same way, more slowly. There is no
167
+ read-only or write-only variant of `exclude` for this reason.
168
+ - **Nothing changes below the schema.** An excluded column is still written by its
169
+ default, still read by the primary-key and cursor machinery, and still usable by your
170
+ own resolvers. Only the generated schema stops mentioning it.
171
+
172
+ Names that match nothing throw at build time, so renaming a column fails the build instead
173
+ of quietly un-hiding it. Excluding a `NOT NULL` column with no default is allowed — the
174
+ table becomes readable but not insertable — and warns on the console, since that is more
175
+ often a mistake than a plan.
176
+
177
+ > `exclude` is a static decision: the column is absent for every caller. Deciding per
178
+ > request who may see what is a job for [`graphql-shield`](https://the-guild.dev/graphql/shield)
179
+ > or an equivalent layer above the schema, which has the request context this library
180
+ > does not.
127
181
  ## Naming
128
182
 
129
183
  Type and field names are derived from the Drizzle schema keys. Three options change them.
@@ -348,6 +402,124 @@ A **to-many** relation takes a `some` / `none` / `every` wrapper
348
402
  `users(where: { roles: { some: { name: { eq: "admin" } } } })` works the same as a direct
349
403
  to-many relation
350
404
 
405
+ ## Matching lists of values
406
+
407
+ `inArray` / `notInArray` take a list of candidate values and compile to SQL `IN` / `NOT IN`.
408
+ Two things about them are worth stating explicitly.
409
+
410
+ **An empty list is answered, not rejected.** A caller building a filter from a variable often
411
+ ends up with an empty list, and that list has a well-defined meaning: nothing is `IN ()` and
412
+ everything is `NOT IN ()`.
413
+
414
+ ```graphql
415
+ {
416
+ # no rows
417
+ posts(where: { id: { inArray: [] } }) {
418
+ id
419
+ }
420
+
421
+ # every row
422
+ posts(where: { id: { notInArray: [] } }) {
423
+ id
424
+ }
425
+ }
426
+ ```
427
+
428
+ The same rule applies to the Postgres array-column operators: `hasSome: []` matches nothing
429
+ (overlapping with no elements is never true) and `hasEvery: []` matches everything (every
430
+ array contains all zero of them).
431
+
432
+ **The list's own order can be the sort order.** SQL returns `IN` matches in whatever order it
433
+ likes, so a caller that fetches rows by a ranked list of ids normally has to re-sort them
434
+ client-side. Setting `matchFilterOrder` on an `orderBy` entry sorts by the row's position in
435
+ that column's `inArray` list instead of by the column's value:
436
+
437
+ ```graphql
438
+ {
439
+ posts(
440
+ where: { id: { inArray: [7, 3, 12] } }
441
+ orderBy: { id: { direction: asc, priority: 1, matchFilterOrder: true } }
442
+ ) {
443
+ id # → 7, 3, 12
444
+ }
445
+ }
446
+ ```
447
+
448
+ - `direction: asc` keeps the list's order; `desc` reverses it
449
+ - It needs an `inArray` filter on the same column, at the top level of `where` — an
450
+ `inArray` buried inside an `OR` branch raises an error rather than being guessed at
451
+ - It interleaves with the other `orderBy` entries by `priority` like any ordering key, and
452
+ works on relation fields as well as root lists
453
+ - It compiles to a `CASE` ladder over the list values, so it behaves identically on all
454
+ three dialects and every value stays a bound parameter
455
+ - It cannot be combined with `after` or `distinct`: a row's position in the request's own
456
+ filter list is not a value of the row, so it cannot be encoded into a cursor or rebuilt
457
+ against a subquery. Selecting `cursor` under it yields `null` rather than an error
458
+
459
+ ## Filtering inside JSON documents
460
+
461
+ `json` / `jsonb` columns get a `path` filter that compares the value at one path inside the
462
+ document, alongside the whole-document `eq` / `ne` / `contains` operators:
463
+
464
+ ```graphql
465
+ {
466
+ users(where: { meta: { path: { path: ["profile", "level"], gt: 2 } } }) {
467
+ id
468
+ }
469
+ }
470
+ ```
471
+
472
+ `path` walks the document key by key from the root; an all-digits key indexes an array
473
+ (`["tags", "0"]`). Several predicates may be given at once and are `AND`ed:
474
+
475
+ ```graphql
476
+ {
477
+ users(
478
+ where: {
479
+ meta: {
480
+ path: [
481
+ { path: ["profile", "role"], eq: "admin" }
482
+ { path: ["profile", "level"], gte: 3 }
483
+ ]
484
+ }
485
+ }
486
+ ) {
487
+ id
488
+ }
489
+ }
490
+ ```
491
+
492
+ Available operators are `eq` / `ne` / `lt` / `lte` / `gt` / `gte`, the safe string matchers
493
+ `startsWith` / `endsWith` / `contains` (plus their case-insensitive `i`-prefixed forms), and
494
+ `isNull` / `isNotNull`. A missing key and a JSON `null` both read as null.
495
+
496
+ **How the value is compared** is decided by the operand: a GraphQL number compares
497
+ numerically, a boolean as a boolean, and anything else as text. Pass `as: TEXT | NUMBER |
498
+ BOOLEAN` when the operand's type doesn't match the document's — comparing a numeric field
499
+ against a `String` variable, say:
500
+
501
+ ```graphql
502
+ {
503
+ users(where: { meta: { path: { path: ["profile", "level"], as: NUMBER, eq: "3" } } }) {
504
+ id
505
+ }
506
+ }
507
+ ```
508
+
509
+ A value of the wrong shape never matches rather than failing the query: `gt: 0` against a
510
+ document whose path holds `"admin"` returns no rows on every dialect. That takes a guard on
511
+ each one — Postgres errors outright on a bad `::numeric`, MySQL quietly casts a non-numeric
512
+ string to `0`, and SQLite's cross-type ordering would otherwise sort every string above every
513
+ number.
514
+
515
+ Note that `contains` means different things at the two levels, because a path names a scalar:
516
+ on the column it is structural JSON containment (Postgres `@>` / MySQL `JSON_CONTAINS`),
517
+ inside a `path` filter it is substring matching on the extracted value.
518
+
519
+ Path filters combine with `AND` / `OR` / `NOT` like any other operator, and are accepted
520
+ everywhere a filter is — list, single and aggregate queries, `update` / `delete`, and the
521
+ `where` argument on a relation field.
522
+
351
523
  ## Aggregate queries
352
524
 
353
525
  Every table also gets an aggregate query field — `<tableName>Aggregate` (e.g. `usersAggregate`),
@@ -588,8 +760,8 @@ field, and passing it back as `after` resumes strictly after that row.
588
760
  - Dates and bigints round-trip losslessly through the cursor
589
761
  - A cursor is only valid under the ordering it was issued for — reusing it with a
590
762
  different `orderBy` (or a malformed/corrupted cursor) returns a `GraphQLError`
591
- - `after` cannot be combined with `distinct`, and a table with no primary key cannot use
592
- cursor pagination (its `cursor` field resolves to `null`)
763
+ - `after` cannot be combined with `distinct` or with `matchFilterOrder`, and a table with
764
+ no primary key cannot use cursor pagination (its `cursor` field resolves to `null`)
593
765
  - If a table has a real column named `cursor`, the column wins and the meta field is
594
766
  skipped
595
767