@vantreeseba/drizzle-graphql 15.1.1 → 15.1.3

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
@@ -249,6 +249,12 @@ Two details worth stating plainly:
249
249
  - **Nothing changes below the schema.** An excluded column is still written by its
250
250
  default, still read by the primary-key and cursor machinery, and still usable by your
251
251
  own resolvers. Only the generated schema stops mentioning it.
252
+ - **A cursor carries the primary key in readable form.** A [cursor](#cursor-pagination)
253
+ is base64url JSON of the row's ordering values, and the primary key is always one of
254
+ them. Excluding the primary key removes the field, but anyone holding a cursor can still
255
+ decode the key's value. If the key itself must stay secret, wrap the cursors in your own
256
+ opaque or encrypted form. A `defaults.<table>.orderBy` that names an excluded column
257
+ throws at build time, because its values would ride along in every cursor too.
252
258
 
253
259
  Names that match nothing throw at build time, so renaming a column fails the build instead
254
260
  of quietly un-hiding it. Excluding a `NOT NULL` column with no default is allowed — the
@@ -291,6 +297,8 @@ narrow the scope, never widen it. It reaches every path that touches the table:
291
297
  | `postsAggregate`, `postsGroupBy` | counted over in-scope rows only |
292
298
  | `user.posts`, `user.postsAggregate` | scoped on both the batched and the eager (`with:`) path |
293
299
  | cursor pages | the keyset page is taken within the scope |
300
+ | relation filters — `users(where: { posts: { some: … } })`, `none`, `every`, to-one, `.through()` | only in-scope posts can match, block `none` or fail `every` — on reads, aggregates, `groupBy`, and the `where` of updates and deletes |
301
+ | `orderBy` through a to-one — `comments(orderBy: { post: { title: … } })` | an out-of-scope post sorts as `NULL` |
294
302
  | `updatePosts`, `updatePostsMany`, `deletePosts` | an out-of-scope row is simply not matched |
295
303
  | `upsertPosts` (conflict branch) | a conflicting row outside the scope is left alone, not taken over |
296
304
  | nested `connect` / `disconnect` / `set` | only in-scope rows can be attached or detached |
@@ -303,6 +311,11 @@ Two things it deliberately does not do:
303
311
  - **It does not apply to a plain insert.** There is no existing row to filter. `contextValues`
304
312
  is the write-side half.
305
313
 
314
+ A relation filter or an ordering through a relation sees exactly the rows the relation field
315
+ would return. Through a `.through()` relation, the junction table's own `scope` and soft delete
316
+ apply as well. A scope that returns a filter object is the server's own predicate, so the
317
+ relations it reaches through are read without their targets' scopes.
318
+
306
319
  Returning `undefined` means *no restriction for this context*, so one hook can let an
307
320
  administrator through:
308
321
 
@@ -335,7 +348,9 @@ const { schema } = buildSchema(db, {
335
348
  send it at all — the SDL itself says the column is server-owned. It stays readable on the
336
349
  object type.
337
350
  - Every insert stamps it, including rows created by a nested `create`.
338
- - Updates never write it, so an update cannot hand a row to another owner.
351
+ - Updates never write it, so an update cannot hand a row to another owner. Neither does the
352
+ conflict branch of an upsert: it is left out of the default overwrite and of the columns
353
+ `onConflict.update` can name.
339
354
 
340
355
  Together the two close the loop: `contextValues` decides which tenant a new row belongs to, and
341
356
  `scope` decides which rows a request can see and change afterwards.
@@ -475,6 +490,8 @@ What a declared table gets:
475
490
  | `posts`, `postsSingle`, cursor pages | marked rows are not returned |
476
491
  | `postsAggregate`, `postsGroupBy` | counted over unmarked rows only |
477
492
  | `user.posts`, `user.postsAggregate` | hidden on both the batched and the eager (`with:`) path |
493
+ | relation filters — `users(where: { posts: { some: {} } })`, `none`, `every`, `.through()` junctions | a marked post neither matches nor blocks `none`, with the same default as the relation field (see below) |
494
+ | `orderBy` through a to-one | a marked target the relation field hides sorts as `NULL` |
478
495
  | `updatePosts`, `updatePostsMany`, a second `deletePosts` | a marked row is simply not matched |
479
496
  | nested `connect` / `disconnect` / `set` | a marked row cannot be attached or detached |
480
497
  | `CreatePostsInput`, `UpdatePostsInput` | the marker column is **removed** — only the mutations write it |
@@ -538,6 +555,10 @@ The `deleted` argument is generated on relation fields either way, so a request
538
555
  whichever default applies — `altKind(deleted: EXCLUDE)` under `'root'`, `altKind(deleted:
539
556
  INCLUDE)` under `'all'`.
540
557
 
558
+ A relation filter (`items(where: { kind: { name: … } })`) and an `orderBy` through a to-one
559
+ use the same default as the relation field, so they match and sort over exactly the rows the
560
+ field shows. They take no `deleted` argument of their own.
561
+
541
562
  `scope` changes reads only. A nested `connect` / `disconnect` / `set` still refuses to attach a
542
563
  marked row under either setting, and the table's own writes are unaffected.
543
564
 
@@ -984,6 +1005,9 @@ input StockItemIdLocationIdKey {
984
1005
  composes with column filters, relation filters, `OR` / `AND` / `NOT`
985
1006
  - Composite primary keys get a field like any other unique constraint
986
1007
  - Single-column constraints do not: `eq` on the column already names the row
1008
+ - Partial unique indexes (`uniqueIndex().on(…).where(…)`) do not either: they are unique
1009
+ only among the rows the predicate matches, so with soft-deleted rows included
1010
+ (`deleted: INCLUDE`) the key can match more than one
987
1011
  - A field name a column or relation already holds is skipped, the same way a relation
988
1012
  yields to a same-named column
989
1013
  - It compiles to exactly the equalities you would have written by hand; the guarantee is in
@@ -1490,6 +1514,12 @@ field, and passing it back as `after` resumes strictly after that row.
1490
1514
  millisecond JS `Date` that `mode: 'date'` reads it into, so the cursor carries the
1491
1515
  database's own text of the value instead, and rows a microsecond apart are still distinct
1492
1516
  pages
1517
+ - A cursor is opaque by convention only. It is base64url-encoded JSON of the row's
1518
+ ordering values, primary key included, so a client can decode it and read those values,
1519
+ even for a primary key hidden by [`exclude`](#excluding-tables-and-columns). It is not
1520
+ signed or encrypted. If the ordering values must stay private, wrap the cursor in your
1521
+ own opaque or encrypted token before it leaves the server, and unwrap it on the way back
1522
+ in
1493
1523
  - A cursor is only valid under the ordering it was issued for — reusing it with a
1494
1524
  different `orderBy` (or a malformed/corrupted cursor) returns a `GraphQLError`
1495
1525
  - `after` cannot be combined with `distinct`, with `matchFilterOrder`, or with an `orderBy`
@@ -1566,9 +1596,17 @@ mutation {
1566
1596
  - Columns the request did not supply are never overwritten — a partial upsert does not null
1567
1597
  out the rest of the row. Listing an unsupplied column in `update` is an error rather than
1568
1598
  a silent no-op
1599
+ - That holds per row: a batch whose rows supply different columns is split into one
1600
+ statement per run of consecutive rows that write the same columns, run in one transaction,
1601
+ so a row that left a column out keeps it. The rows come back in the order they were sent
1569
1602
  - `target` must match one of the table's unique constraints exactly; anything else is
1570
1603
  rejected with the list of valid targets, instead of the database's opaque "no unique or
1571
1604
  exclusion constraint matching" error
1605
+ - `target` can be a partial unique index — the `WHERE deleted_at IS NULL` companion of
1606
+ [soft delete](#soft-delete), say. Its predicate goes into `ON CONFLICT (…) WHERE …`, so a
1607
+ conflict is detected only against the rows the index covers
1608
+ - `contextValues` columns are never overwritten on conflict — see
1609
+ [columns the server owns](#columns-the-server-owns)
1572
1610
  - `action: NOTHING` inserts nothing and returns nothing for the conflicting row. An `UPDATE`
1573
1611
  with no columns left to write degrades to the same thing
1574
1612
  - `values` is the same `Create<Type>Input` the insert mutations take, so turning `insert`
@@ -1603,7 +1641,9 @@ Dialect differences:
1603
1641
  - **PostgreSQL** and **SQLite** — the full surface above. A table with no primary key and no
1604
1642
  unique constraint has nothing to conflict on, so it gets no upsert mutations at all
1605
1643
  - **MySQL** — `ON DUPLICATE KEY UPDATE` fires on whichever unique key was violated and takes
1606
- no predicate, so `<Type>OnConflict` there has only `action` and `update`. `action: NOTHING`
1644
+ no predicate, so `<Type>OnConflict` there has only `action` and `update`. Because the
1645
+ conflict can be on any unique key, the default overwrite leaves out the primary key as well
1646
+ — copying a new row's key over the conflicting row's would move it. `action: NOTHING`
1607
1647
  becomes `INSERT IGNORE`, and the mutations return `MutationReturn` like every other MySQL
1608
1648
  mutation
1609
1649
 
@@ -2504,12 +2544,13 @@ import { resolveSelection, selectionToWith } from '@vantreeseba/drizzle-graphql'
2504
2544
  const rows = await resolveSelection(info, {
2505
2545
  db,
2506
2546
  table: 'Users',
2547
+ context, // the resolver's context — required when the build configures `scope`
2507
2548
  where: { id: { eq: userId } },
2508
2549
  executor: tx, // defaults to `db`; pass a transaction to see its own writes
2509
2550
  })
2510
2551
 
2511
2552
  // Or just the `with:` tree, if you are building the query yourself.
2512
- const withTree = selectionToWith(info, { db, table: 'Users' })
2553
+ const withTree = selectionToWith(info, { db, table: 'Users', context })
2513
2554
  const row = await tx.query.Users.findFirst({ where: { id: userId }, with: withTree })
2514
2555
  ```
2515
2556
 
@@ -2520,8 +2561,27 @@ relation they select, compiles each relation's `where` / `orderBy` / `limit` / `
2520
2561
  into its nested entry, and leaves `<relation>Aggregate` selections out — they are not
2521
2562
  `with:` entries. It returns `undefined` when the selection asks for no relations.
2522
2563
 
2523
- `resolveSelection` additionally accepts `single`, `orderBy`, `limit`, `offset` and `after`,
2524
- and returns the rows already remapped to their GraphQL transport forms.
2564
+ `resolveSelection` additionally accepts `single`, `orderBy`, `limit`, `offset`, `after` and
2565
+ `deleted`, and returns the rows already remapped to their GraphQL transport forms.
2566
+
2567
+ Both apply the build's row policies the way the generated fields do, so an override is not a
2568
+ way around them. `resolveSelection` scopes and soft-delete-filters the root table, applies the
2569
+ table's `limits` and `defaults.orderBy` to the root list, and applies all four to every nested
2570
+ relation. `selectionToWith` applies them to the nested relations in the tree it returns. The
2571
+ root query you run with it is yours to scope.
2572
+
2573
+ They find the build through the resolve info: the field's return type, the root table's type in
2574
+ the executing schema, or the parent type, whichever the build generated. That works for a schema
2575
+ assembled from `entities.types`, and for one a tool such as `mapSchema` rebuilt, because the
2576
+ build is found through each type's `extensions.drizzle`. Two things follow:
2577
+
2578
+ - **Pass `context`.** Scope hooks take the request context, which resolve info does not
2579
+ carry. When the build configures `scope`, a call without `context` throws instead of
2580
+ reading unscoped. Soft delete, limits and default ordering need no context.
2581
+ - **Pass `info`, not a parsed tree, when one database has several builds.** A parsed
2582
+ selection tree carries no type to find the build by. Over a database with exactly one
2583
+ build, that build's policies apply. With several builds, the call throws if any of them
2584
+ has policies, since picking one could apply the wrong policies.
2525
2585
 
2526
2586
  Both take the naming options the build used — `typeNameMapper`, `derivedTypeNameMapper`,
2527
2587
  `typeNamePrefix` and `typeNameSuffix`. The translation matches selections against the
@@ -2580,8 +2640,11 @@ It understands `values` (one object or a list), `where` (`eq` for one row, `inAr
2580
2640
  many, an equality on every column of a composite key), and the per-entry `where` inside a
2581
2641
  batch update's `updates`. Check `complete` before treating `rows` as the affected set: it
2582
2642
  is `false` when the arguments do not pin the rows down — an insert whose key the database
2583
- generates, a filter on something other than the primary key, an unbounded update, or a
2584
- table with no primary key at all.
2643
+ generates, a filter on something other than the primary key, an unbounded update, a
2644
+ table with no primary key at all, or an upsert whose `onConflict` targets another unique key
2645
+ or can skip a conflicting row (`action: NOTHING`, a `where`). On MySQL, where an upsert has
2646
+ no `target` and conflicts on whichever unique key it hits, `complete` assumes the primary
2647
+ key: do not rely on it for a table with other unique keys.
2585
2648
 
2586
2649
  > [!NOTE]
2587
2650
  > `extensions.drizzle` is stable API. The point of publishing it is that consumers stop