@vantreeseba/drizzle-graphql 15.0.0 → 15.1.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
@@ -6,7 +6,7 @@ Site: [cubicecho.github.io/drizzle-graphql](https://cubicecho.github.io/drizzle-
6
6
 
7
7
  ## Usage
8
8
 
9
- - Pass your drizzle database instance and schema into builder to generate `{ schema, entities }` object
9
+ - Pass your drizzle database instance into the builder to generate a `{ schema, entities }` object. The tables are read from the `relations` you gave the drizzle constructor (`drizzle({ client, relations })`), so a table missing from your relations config is not part of the generated schema
10
10
  - Use `schema` if pre-built schema already satisfies all your needs. It's compatible with any server that consumes `GraphQLSchema` class instance
11
11
 
12
12
  Example: hosting schema using [GraphQL Yoga](https://the-guild.dev/graphql/yoga-server)
@@ -39,6 +39,7 @@ Site: [cubicecho.github.io/drizzle-graphql](https://cubicecho.github.io/drizzle-
39
39
 
40
40
  // Schema contains 'Users' and 'Customers' tables
41
41
  import { db } from './database'
42
+ import { users } from './schema'
42
43
 
43
44
  const { entities } = buildSchema(db)
44
45
 
@@ -63,7 +64,7 @@ Site: [cubicecho.github.io/drizzle-graphql](https://cubicecho.github.io/drizzle-
63
64
  },
64
65
  resolve: async (source, args, context, info) => {
65
66
  // Your custom logic goes here...
66
- const result = await db.select(schema.Users).where()...
67
+ const result = await db.select().from(users).where(/* … */)
67
68
 
68
69
  return result
69
70
  }
@@ -168,8 +169,9 @@ const { schema } = buildSchema(db, {
168
169
  turns the group-by queries off with it
169
170
  - Turning off `insert` or `update` also drops the input type that only that mutation
170
171
  used (`Create<Type>Input` / `Update<Type>Input`)
171
- - Turning off all three mutation features omits the `Mutation` type entirely, the same as
172
- `mutations: false`
172
+ - Turning off every mutation feature omits the `Mutation` type entirely, the same as
173
+ `mutations: false` — unless a table has `softDelete`, whose `restore` mutations are
174
+ generated regardless
173
175
  - List and single queries are always generated, so `Query` is never empty
174
176
 
175
177
  ### Per-table operations
@@ -344,9 +346,7 @@ Together the two close the loop: `contextValues` decides which tenant a new row
344
346
 
345
347
  Names that match nothing throw at build time, the same as `exclude` — a renamed column fails the
346
348
  build instead of quietly ceasing to be scoped.
347
- > request who may see what is a job for [`graphql-shield`](https://the-guild.dev/graphql/shield)
348
- > or an equivalent layer above the schema, which has the request context this library
349
- > does not.
349
+
350
350
  ## Naming
351
351
 
352
352
  Type and field names are derived from the Drizzle schema keys. Three options change them.
@@ -463,7 +463,7 @@ Keyed by the **Drizzle schema key**, like `scope` and `exclude`. A function form
463
463
  accepted, for a convention applied across the schema:
464
464
 
465
465
  ```Typescript
466
- softDelete: (table, tableName) => ('deletedAt' in getTableColumns(table) ? 'deletedAt' : undefined)
466
+ softDelete: (table, tableName) => ('deletedAt' in getColumns(table) ? 'deletedAt' : undefined)
467
467
  ```
468
468
 
469
469
  What a declared table gets:
@@ -612,6 +612,7 @@ generated SDL says what a field actually holds instead of falling back to `Strin
612
612
  | `timestamp` / `datetime` (either `mode`) | `DateTime` | an ISO-8601 string |
613
613
  | SQLite `integer({ mode: 'timestamp' })` / `'timestamp_ms'` | `DateTime` | an ISO-8601 string |
614
614
  | `date` (either `mode`) | `Date` | a `YYYY-MM-DD` string |
615
+ | `numeric` / `decimal` | `Decimal` | a decimal string |
615
616
 
616
617
  ```graphql
617
618
  mutation {
@@ -629,6 +630,8 @@ mutation {
629
630
  `Number.MAX_SAFE_INTEGER` survive the round-trip. Integer literals are accepted on input;
630
631
  floats and non-numeric strings are rejected
631
632
  - **`UUID`** validates the format on the way in, including inside `where` filters
633
+ - **`Decimal`** is a decimal string in both directions, so the precision the column declares
634
+ is not lost to a JS float. Non-numeric and non-finite input is rejected
632
635
  - **`Date`** is a calendar day — no time, no offset — and is the same type in every position
633
636
  a `date` column appears in: the object field, the create and update inputs, the
634
637
  `DateFilter` operands, aggregate `min` / `max` and group keys. It takes and returns an
@@ -656,7 +659,7 @@ mutation {
656
659
  The scalars are exported if you need them in a hand-written schema:
657
660
 
658
661
  ```Typescript
659
- import { GraphQLBigIntString, GraphQLDate, GraphQLDateTime, GraphQLJSON, GraphQLUUID } from '@vantreeseba/drizzle-graphql'
662
+ import { GraphQLBigIntString, GraphQLDate, GraphQLDateTime, GraphQLDecimalString, GraphQLJSON, GraphQLUUID } from '@vantreeseba/drizzle-graphql'
660
663
  ```
661
664
 
662
665
  `GraphQLBigIntString` is the `BigInt` scalar — named for what it does rather than what it is
@@ -781,7 +784,7 @@ only has to handle the cases it cares about:
781
784
  ```Typescript
782
785
  const { schema } = buildSchema(db, {
783
786
  // `schema` is the Postgres schema the enum was declared in, if any; `values` is its
784
- // value list. Everything but `enumName` is always present.
787
+ // value list. `enumName` and `schema` are undefined for an inline value list.
785
788
  enumNameMapper: ({ enumName, schema }) =>
786
789
  schema === 'reporting' ? `Reporting${enumName}Enum` : undefined,
787
790
  })
@@ -861,9 +864,40 @@ const { schema } = buildSchema(db, {
861
864
 
862
865
  - `describeColumn` reaches every place the column appears: the object type, the create and
863
866
  update inputs, the filter input's field, `Min`/`Max` aggregates and `GroupKeys`
864
- - The hook receives the drizzle column itself, so a project that keeps its documentation in
865
- the schema (a `.$type()` brand, a custom column property, a comment table) can read it
866
- straight off
867
+ - `describeColumn` receives the drizzle column itself and `describeTable` the drizzle table
868
+ itself, so a project that keeps its documentation in the schema (a `WeakMap` keyed by
869
+ column/table, a custom property, a comment table) can read it straight off:
870
+
871
+ ```Typescript
872
+ import type { Table } from 'drizzle-orm';
873
+
874
+ const docs = new WeakMap<object, string>();
875
+
876
+ // Register docs on the *built* table and its columns. `text('email')` is only a builder:
877
+ // pgTable creates a new column object from it, so keying on the builder never matches.
878
+ const described = <T extends Table>(
879
+ table: T,
880
+ description: string,
881
+ columns: Partial<Record<keyof T['_']['columns'], string>> = {},
882
+ ): T => {
883
+ docs.set(table, description);
884
+ for (const [key, columnText] of Object.entries(columns)) {
885
+ docs.set((table as any)[key], columnText as string);
886
+ }
887
+ return table;
888
+ };
889
+
890
+ export const users = described(
891
+ pgTable('users', { id: integer('id').primaryKey(), email: text('email').notNull() }),
892
+ 'A person who can sign in',
893
+ { email: 'Primary login address' },
894
+ );
895
+
896
+ buildSchema(db, {
897
+ describeColumn: (column) => docs.get(column),
898
+ describeTable: (_tableName, table) => docs.get(table),
899
+ });
900
+ ```
867
901
  - `deprecateColumn` marks output fields and *optional* input fields. A required input field
868
902
  is skipped: GraphQL forbids deprecating one, and emitting it would make the schema invalid
869
903
  - Filter and `orderBy` fields are never deprecated — filtering on a deprecated column is how
@@ -1090,7 +1124,8 @@ that column's `inArray` list instead of by the column's value:
1090
1124
  ## Filtering inside JSON documents
1091
1125
 
1092
1126
  `json` / `jsonb` columns get a `path` filter that compares the value at one path inside the
1093
- document, alongside the whole-document `eq` / `ne` / `contains` operators:
1127
+ document, alongside the whole-document `eq` / `ne` operators and, on PostgreSQL and MySQL,
1128
+ structural `contains` (SQLite stores JSON as text and has no containment operator):
1094
1129
 
1095
1130
  ```graphql
1096
1131
  {
@@ -1256,9 +1291,10 @@ columns you group by.
1256
1291
  }
1257
1292
  ```
1258
1293
 
1259
- - `having: { count: … }` filters on the row count; `avg` / `sum` / `min` / `max` /
1260
- `countNonNull` / `countDistinct` take one filter per column, over the same columns the
1261
- matching aggregate type exposes
1294
+ - `having: { count: … }` filters on the row count; `avg` / `sum` / `min` / `max` take one
1295
+ filter per numeric column (the comparison is numeric, so a `max` over a text or date
1296
+ column has no `having` entry even though the aggregate type returns it),
1297
+ `countNonNull` one per column, and `countDistinct` one per column `countDistinct` exposes
1262
1298
  - Every entry in a `having` is ANDed together
1263
1299
  - A group filter does not need to be in the selection — `having: { count: { gt: 3 } }` works
1264
1300
  whether or not you asked for `count`
@@ -1297,6 +1333,8 @@ can count or summarise related rows without fetching them:
1297
1333
  - A parent with no related rows gets `count: 0` and `null` for every other aggregate
1298
1334
  - To-one relations get no aggregate field — there is nothing to aggregate over
1299
1335
  - The field is skipped if its name would collide with a column or another relation
1336
+ - Set `features: { relationAggregates: false }` (globally or per table) to leave these
1337
+ fields out
1300
1338
 
1301
1339
  All parents in a selection are aggregated with a single
1302
1340
  `SELECT <fk>, … WHERE <fk> IN (…) GROUP BY <fk>` per request, so `postsAggregate` on a list of
@@ -1320,7 +1358,8 @@ enum. Rows sharing the same combination of those columns collapse to one:
1320
1358
  ```
1321
1359
 
1322
1360
  - Which row survives each group is decided by `orderBy` — the first one wins. With no
1323
- `orderBy`, that's the lowest primary key
1361
+ `orderBy`, the table's [default ordering](#default-ordering) decides, and failing that the
1362
+ lowest primary key
1324
1363
  - `where` is applied **before** rows are collapsed; `limit` and `offset` are applied
1325
1364
  **after**, so `limit: 10` returns ten distinct rows
1326
1365
  - Several columns are treated as one combined key, not as independent ones
@@ -1339,7 +1378,7 @@ or **SQLite 3.25+**), and a table with no primary key cannot use it.
1339
1378
  SQL gives no ordering guarantee for a query that has no `ORDER BY`, so paging through an
1340
1379
  unordered result can return the same row twice or skip one entirely. To keep pages stable,
1341
1380
  a query that returns only part of a table is ordered by its primary key when the request
1342
- supplies no `orderBy`:
1381
+ supplies no `orderBy` and the table has no [default ordering](#default-ordering):
1343
1382
 
1344
1383
  - list queries with `limit` and/or `offset`
1345
1384
  - `<tableName>Single` queries, which are an implicit `limit 1`
@@ -1347,7 +1386,8 @@ supplies no `orderBy`:
1347
1386
  `orderBy` you do supply, so per-parent slices are deterministic)
1348
1387
 
1349
1388
  An explicit `orderBy` always takes precedence, composite primary keys are ordered by every
1350
- key column, and an unpaginated list query is left unordered so no sort is paid for.
1389
+ key column, and an unpaginated list query of a table with no `defaults` entry is left
1390
+ unordered so no sort is paid for.
1351
1391
 
1352
1392
  ## Default ordering
1353
1393
 
@@ -1434,14 +1474,16 @@ field, and passing it back as `after` resumes strictly after that row.
1434
1474
  window
1435
1475
  - `after` compiles to a keyset predicate built from drizzle's `and`/`or`/`gt`/`lt`
1436
1476
  comparisons (not SQL row-value syntax), so mixed-direction `orderBy` works
1437
- - `NULL`s in ordered columns follow each dialect's native `ORDER BY` placement —
1438
- PostgreSQL sorts them largest (last in `asc`), MySQL and SQLite smallest (first in
1439
- `asc`) — and the predicate matches, so rows with `NULL` in an ordered column are paged
1440
- through, not skipped
1477
+ - `NULL`s in ordered columns sort where the entry's `nulls: first | last` says or, without
1478
+ it, at each dialect's native `ORDER BY` placement — PostgreSQL sorts them largest (last
1479
+ in `asc`), MySQL and SQLite smallest (first in `asc`). The predicate matches either way,
1480
+ so rows with `NULL` in an ordered column are paged through, not skipped, and `nulls` is
1481
+ part of the ordering a cursor is tied to
1441
1482
  - Dates and bigints round-trip losslessly through the cursor
1442
1483
  - A cursor is only valid under the ordering it was issued for — reusing it with a
1443
1484
  different `orderBy` (or a malformed/corrupted cursor) returns a `GraphQLError`
1444
- - `after` cannot be combined with `distinct` or with `matchFilterOrder`, and a table with
1485
+ - `after` cannot be combined with `distinct`, with `matchFilterOrder`, or with an `orderBy`
1486
+ that orders through a relation (`cursor` resolves to `null` there), and a table with
1445
1487
  no primary key cannot use cursor pagination (its `cursor` field resolves to `null`)
1446
1488
  - If a table has a real column named `cursor`, the column wins and the meta field is
1447
1489
  skipped
@@ -1595,8 +1637,10 @@ mutation {
1595
1637
  }
1596
1638
  ```
1597
1639
 
1598
- - `connect`, `disconnect` and `set` take the target's own `Filters` input, so they select
1599
- rows the same way a query does. A filter that matches everything is rejected rather than
1640
+ - `connect`, `disconnect` and `set` take the target's own `Filters` input — a list of them
1641
+ on a to-many relation, a single one that must match exactly one row on a to-one — so they
1642
+ select rows the same way a query does. A to-one `disconnect` is just `disconnect: true`,
1643
+ since there is only one row to detach. A filter that matches everything is rejected rather than
1600
1644
  attaching the whole table
1601
1645
  - `set` replaces the relation wholesale — everything attached is detached first, then the
1602
1646
  filters attach. It runs before `create`, so a row created in the same write survives it
@@ -1944,8 +1988,8 @@ const yoga = createYoga({ schema, plugins: armor.protect().plugins });
1944
1988
 
1945
1989
  Armor's `costLimit` is its own document-shape estimate and does not read the `complexity`
1946
1990
  extensions above; the two compose, if you want a cheap static ceiling alongside row-count
1947
- aware pricing. Neither one bounds an individual `limit` argument, so a single unbounded list
1948
- query is still a full table scan — cap `limit` yourself if that matters.
1991
+ aware pricing. Neither one bounds an individual `limit` argument — a list query with no
1992
+ `limit` is still a full table scan unless you set a [`limits`](#bounding-page-size) policy.
1949
1993
 
1950
1994
  ## Authorization
1951
1995
 
@@ -1987,21 +2031,17 @@ request context actually lives.
1987
2031
  or through your own resolver wrapper, and let the ability answer the questions.
1988
2032
 
1989
2033
  What neither can do is **narrow** a query. They permit or deny a field; they do not append
1990
- `WHERE user_id = …` to the SQL a generated resolver runs, and a permitted `users` query still
1991
- returns every tenant's rows. Until row scoping is a first-class option here, the ways to get
1992
- it are, in increasing order of effort:
1993
-
1994
- - supply the tenant predicate as an ordinary `where` argument from a trusted gateway, and
1995
- deny the raw generated fields to everyone else;
1996
- - build the schema by hand from `entities`, exposing only the generated resolvers you have
1997
- wrapped;
1998
- - put the scoped tables behind database row-level security and give the driver the
1999
- request's identity, so the narrowing happens below Drizzle entirely.
2034
+ `WHERE org_id = …` to the SQL a generated resolver runs, so a permitted `posts` query still
2035
+ returns every tenant's rows. That is what [`scope`](#row-scoping-and-multi-tenancy) is for: a
2036
+ per-table predicate built from the request context and ANDed onto every read and write of
2037
+ that table, nested relation fields included. Pair it with
2038
+ [`contextValues`](#columns-the-server-owns) so new rows are stamped with the caller's tenant.
2039
+ The usual split is shield (or CASL) to decide who may reach a field, and `scope` to decide
2040
+ which rows they see through it.
2000
2041
 
2001
2042
  None of this removes anything from introspection either — `deny` hides the data, not the
2002
- shape. If the schema must not so much as mention a table, keep that table out of the Drizzle
2003
- instance you pass to `buildSchema` (a second instance over a narrowed schema object is
2004
- cheap).
2043
+ shape. If the schema must not so much as mention a table or column, leave it out with
2044
+ [`exclude`](#excluding-tables-and-columns).
2005
2045
 
2006
2046
  Nor is it a row bound: a complexity ceiling prices the `limit` a client asks for, it does not
2007
2047
  cap it. Use [`limits`](#bounding-page-size) for that.
@@ -2142,6 +2182,9 @@ are untouched.
2142
2182
  - A document that mixes in mutation fields this build did not generate is left alone: their
2143
2183
  completion cannot be tracked, so no transaction is opened
2144
2184
  - It never nests — an executor supplied on the context wins, as below
2185
+ - A single mutation field can still run in a transaction of its own when it carries a
2186
+ nested write or its table has an `onWrite` hook — those open one regardless of this
2187
+ setting
2145
2188
 
2146
2189
  ### Supplying your own executor
2147
2190
 
@@ -2176,17 +2219,6 @@ example a pooled connection bound to a tenant, or a logging proxy around `db`. B
2176
2219
  key is created with `Symbol.for`, the ESM and CJS builds of this package agree on it when
2177
2220
  both end up loaded in one process.
2178
2221
 
2179
- Or let the library open one per request: `transactions: 'auto'` wraps a document that fires
2180
- more than one generated mutation in a single transaction, committed when the last one
2181
- completes and rolled back if any of them fails.
2182
-
2183
- ```Typescript
2184
- buildSchema(db, { transactions: 'auto' })
2185
- ```
2186
-
2187
- A document with one mutation, or one that selects a mutation field this build did not
2188
- generate, still runs unwrapped — there is nothing to keep atomic.
2189
-
2190
2222
  ## Write hooks
2191
2223
 
2192
2224
  `onWrite` runs a hook **inside the mutation's transaction**, so its writes commit or roll back
@@ -2347,12 +2379,12 @@ Generated schemas resolve nested relations without N+1 query explosions:
2347
2379
  relation in a single round-trip using Drizzle's relational query builder
2348
2380
  (`with:`), including nested relations and per-relation `where` / `orderBy` /
2349
2381
  `limit` / `offset` arguments.
2350
- - **Mutations (PostgreSQL & SQLite)** — after an insert or update, if the selection set
2382
+ - **Mutations (PostgreSQL & SQLite)** — after an insert, upsert, update or `updateMany`, if the selection set
2351
2383
  includes relation fields, the affected rows are re-fetched by primary key through one
2352
2384
  relational query so their relations are eagerly loaded (single- and composite-column
2353
- primary keys are supported). `delete` mutations keep using the request-scoped batch
2354
- loader, since the rows no longer exist to re-fetch. MySQL mutations return only a
2355
- success flag (no row payload), so this step does not apply there.
2385
+ primary keys are supported). `delete` and `restore` mutations keep using the
2386
+ request-scoped batch loader. MySQL mutations return only a success flag (no row
2387
+ payload), so this step does not apply there.
2356
2388
  - **Custom schemas** — each relation field also has a standalone resolver, exported as
2357
2389
  `entities.fieldResolvers[TableName][relationName]`. When you wire generated types
2358
2390
  into your own schema and your root resolver does **not** pre-fetch relations, these
@@ -2369,9 +2401,8 @@ Generated schemas resolve nested relations without N+1 query explosions:
2369
2401
 
2370
2402
  > [!IMPORTANT]
2371
2403
  > Per-parent paginated relations (a to-many relation field with `limit` or `offset`)
2372
- > rely on SQL window functions. These require **PostgreSQL**, **MySQL 8.0+**, or
2373
- > **SQLite 3.25+**. Relations without pagination, and all other query/mutation paths,
2374
- > have no such requirement.
2404
+ > and the `distinct` argument rely on SQL window functions. These require **PostgreSQL**,
2405
+ > **MySQL 8.0+**, or **SQLite 3.25+**. Everything else has no such requirement.
2375
2406
 
2376
2407
  ### Relation nullability
2377
2408
 
@@ -2432,7 +2463,9 @@ const finalSchema = addResolversToSchema({
2432
2463
  excludes that relation from `with:` (and from the mutation eager re-fetch).
2433
2464
 
2434
2465
  Opting a relation out does **not** remove its field — it keeps resolving lazily via the
2435
- request-scoped batch loader, so the field still works even before you override it. Table
2466
+ request-scoped batch loader, so the field still works even before you override it. A
2467
+ many-to-many (`.through()`) relation whose junction has a composite key cannot be loaded
2468
+ lazily (`DRIZZLE_UNSUPPORTED_RELATION`); leave it eager-loaded. Table
2436
2469
  and relation names are the Drizzle schema keys (e.g. `Users`, `posts`), matching the keys
2437
2470
  of `entities.fieldResolvers`.
2438
2471
 
@@ -2475,9 +2508,10 @@ into its nested entry, and leaves `<relation>Aggregate` selections out — they
2475
2508
  `resolveSelection` additionally accepts `single`, `orderBy`, `limit`, `offset` and `after`,
2476
2509
  and returns the rows already remapped to their GraphQL transport forms.
2477
2510
 
2478
- Both take a `typeNameMapper` option. If the schema was built with one, pass the same
2479
- function here — the translation matches selections against GraphQL type names, so a build
2480
- that renames its types has to say so.
2511
+ Both take the naming options the build used — `typeNameMapper`, `derivedTypeNameMapper`,
2512
+ `typeNamePrefix` and `typeNameSuffix`. The translation matches selections against the
2513
+ published GraphQL type names, so a build that renames its types has to say so here too. If
2514
+ the build used `typeNameMapper: 'singularize'`, pass the exported `singularizeMapper`.
2481
2515
 
2482
2516
  ## Identifying generated fields
2483
2517
 
@@ -2548,7 +2582,7 @@ that explains it, where there is one.
2548
2582
  | `mutations` | `true` | `false` omits the `Mutation` type entirely |
2549
2583
  | `features` | see [Choosing what gets generated](#choosing-what-gets-generated) | which operations are generated, per operation |
2550
2584
  | `exclude` | none | [tables and columns left out](#excluding-tables-and-columns) of the generated schema entirely |
2551
- | `relationsDepthLimit` | unlimited | how deep relation fields are generated; `0` gives a flat, columns-only schema |
2585
+ | `relationsDepthLimit` | unlimited | `0` generates no relation fields (a flat, columns-only schema); any other value currently behaves like the default |
2552
2586
  | `prefixes` / `suffixes` / `typeNameMapper` | see [Naming](#naming) | operation and type names |
2553
2587
  | `typeNamePrefix` / `typeNameSuffix` / `derivedTypeNameMapper` | none | [names of the derived types](#renaming-the-derived-types) — filters, inputs, aggregates, shared inputs |
2554
2588
  | `scalars` / `mapColumnType` | built-in detection | [override a column's GraphQL type](#overriding-a-columns-scalar), by name or by rule |
@@ -2556,7 +2590,7 @@ that explains it, where there is one.
2556
2590
  | `enumValueNameMapper` | the stored value | [names the enum's values](#enum-value-names); `'constant'` for CONSTANT_CASE |
2557
2591
  | `describeColumn` / `describeTable` / `describeRelation` | none | [GraphQL descriptions](#documenting-the-schema) |
2558
2592
  | `deprecateColumn` | none | `@deprecated` on a column's output and optional input fields |
2559
- | `conflictDoNothing` | `false` | PostgreSQL only — `ON CONFLICT DO NOTHING` on inserts, which makes `create<Table>Single` nullable |
2593
+ | `conflictDoNothing` | `false` | **Deprecated** — use [`onConflict: { action: NOTHING }`](#upsert) per request. PostgreSQL and SQLite: `ON CONFLICT DO NOTHING` on every insert, which makes `create<Table>Single` nullable; ignored on MySQL |
2560
2594
  | `complexity` | on | [cost hints](#query-cost) in field extensions; `false` turns them off |
2561
2595
  | `transactions` | `'none'` | `'auto'` wraps a multi-mutation request in one [transaction](#transactions) |
2562
2596
  | `limits` | unbounded | [default and maximum page size](#bounding-page-size), globally or per table |
package/dist/index.cjs CHANGED
@@ -1813,6 +1813,48 @@ const extractRelationJoinColumns = (relEntry, parentTable, targetTable) => {
1813
1813
  };
1814
1814
  };
1815
1815
  /**
1816
+ * The junction table and its two columns for a `.through()` (many-to-many) relation:
1817
+ * `sourceCol` holds the parent's key, `targetCol` the target's. `undefined` for a relation
1818
+ * with no junction; `null` for one whose junction cannot be used here — more than one column
1819
+ * on either side, or columns that are not on the junction table.
1820
+ */
1821
+ const extractJunctionColumns = (relEntry) => {
1822
+ const rel = relEntry.relation ?? relEntry;
1823
+ const junction = rel?.throughTable;
1824
+ if (!junction) return;
1825
+ const sourceEntries = rel.through?.source;
1826
+ const targetEntries = rel.through?.target;
1827
+ if (sourceEntries?.length !== 1 || targetEntries?.length !== 1) return null;
1828
+ const junctionCols = Object.values((0, drizzle_orm.getColumns)(junction));
1829
+ const sourceCol = unwrapRelationColumn(sourceEntries[0]);
1830
+ const targetCol = unwrapRelationColumn(targetEntries[0]);
1831
+ if (!sourceCol || !targetCol || !junctionCols.includes(sourceCol) || !junctionCols.includes(targetCol)) return null;
1832
+ return {
1833
+ table: junction,
1834
+ sourceCol,
1835
+ targetCol
1836
+ };
1837
+ };
1838
+ /**
1839
+ * The relation's own declared `where` as a condition on a query that reads the target table,
1840
+ * or `undefined` when it has none. The eager path gets this from drizzle's `with:`; the batched
1841
+ * field resolver and `<relation>Aggregate` read the target table directly, so they have to add
1842
+ * it themselves or they return rows the relation hides.
1843
+ *
1844
+ * A reversed filter (inherited from the relation on the other side) is written against the
1845
+ * parent table, so it is applied as "the row's parent key belongs to a parent that passes it".
1846
+ * `parentKeyCol` is the parent's join column and `groupCol` the column in the query that holds
1847
+ * the parent key for each row — the target's foreign key, or the junction's source column.
1848
+ */
1849
+ const relationOwnCondition = (relEntry, parentTable, targetTable, parentKeyCol, groupCol) => {
1850
+ const rel = relEntry.relation ?? relEntry;
1851
+ const ownWhere = relationOwnWhere(rel);
1852
+ if (!ownWhere) return;
1853
+ if (!isRelationFilterReversed(rel)) return (0, drizzle_orm.relationsFilterToSQL)(targetTable, ownWhere);
1854
+ const parentFilter = (0, drizzle_orm.relationsFilterToSQL)(parentTable, ownWhere);
1855
+ return drizzle_orm.sql`${groupCol} in (select ${parentKeyCol} from ${parentTable} where ${parentFilter})`;
1856
+ };
1857
+ /**
1816
1858
  * Whether a to-one relation can back a relation orderBy hop. `.through()` (junction)
1817
1859
  * relations are excluded — the ordering subquery only joins the direct target, never the
1818
1860
  * junction table. (Relation *filters* do support `.through()` — this guard is orderBy-only.)
@@ -2976,6 +3018,11 @@ const getUniqueColumnSets = (table, getTableConfig) => {
2976
3018
  //#endregion
2977
3019
  //#region src/util/builders/common/distinct.ts
2978
3020
  /**
3021
+ * Alias the parent key comes back under when the pass runs through a junction table. See
3022
+ * `selectDistinctKeys`' `junction` parameter.
3023
+ */
3024
+ const DISTINCT_PARENT = "__dgql_distinct_parent";
3025
+ /**
2979
3026
  * Keeps the first row of each distinct combination of the requested columns, following the
2980
3027
  * query's own ordering, then applies `limit`/`offset` to what survives — and returns the
2981
3028
  * surviving rows' primary key values in that order.
@@ -2988,9 +3035,14 @@ const getUniqueColumnSets = (table, getTableConfig) => {
2988
3035
  * `partitionBy` prepends extra columns to the window's partition. A to-many relation field
2989
3036
  * uses it to pass the foreign key, which makes the pass distinct *within each parent* rather
2990
3037
  * than across the whole batch — the same meaning `distinct` has on a root list.
3038
+ *
3039
+ * `junction` is for a many-to-many relation: the target is joined to the junction `on` the
3040
+ * given condition, and each returned key also carries the parent it was kept for, under
3041
+ * {@link DISTINCT_PARENT}. A target row linked to several parents can be kept for one and
3042
+ * dropped for another, so a key alone cannot narrow such a batch.
2991
3043
  */
2992
3044
  const selectDistinctKeys = async (params) => {
2993
- const { db, table, tableName, distinct, pkNames, where, orderBy, limit, offset, partitionBy } = params;
3045
+ const { db, table, tableName, distinct, pkNames, where, orderBy, limit, offset, partitionBy, junction } = params;
2994
3046
  const cols = (0, drizzle_orm.getColumns)(table);
2995
3047
  if (!pkNames.length) throw drizzleError(`Table ${tableName} has no primary key, so 'distinct' cannot be applied to it.`, { code: "DRIZZLE_INVALID_DISTINCT" });
2996
3048
  const requestedCols = distinct.map((name) => cols[name]).filter(Boolean);
@@ -2999,12 +3051,17 @@ const selectDistinctKeys = async (params) => {
2999
3051
  const orderEntries = orderBy ? orderByEntries(orderBy) : [];
3000
3052
  const windowOrder = [...orderEntries.flatMap(([column, direction, nulls]) => orderExpressions(cols[column], direction, nulls, cols[column])), ...primaryKeyOrderExprs(table, pkNames)];
3001
3053
  const rowNumber = drizzle_orm.sql`row_number() over (partition by ${drizzle_orm.sql.join(partitionCols, drizzle_orm.sql`, `)} order by ${drizzle_orm.sql.join(windowOrder, drizzle_orm.sql`, `)})`.as(DISTINCT_RN);
3002
- const sub = db.select({
3054
+ const selected = db.select({
3003
3055
  ...cols,
3056
+ ...junction ? { [DISTINCT_PARENT]: junction.parentKey } : {},
3004
3057
  [DISTINCT_RN]: rowNumber
3005
- }).from(table).where(where).as("__dgql_distinct");
3058
+ }).from(table);
3059
+ const sub = (junction ? selected.innerJoin(junction.table, junction.on) : selected).where(where).as("__dgql_distinct");
3006
3060
  const outerOrder = [...orderEntries.flatMap(([column, direction, nulls]) => orderExpressions(sub[column], direction, nulls, cols[column])), ...pkNames.filter((name) => sub[name]).map((name) => (0, drizzle_orm.asc)(sub[name]))];
3007
- let query = db.select(Object.fromEntries(pkNames.map((name) => [name, sub[name]]))).from(sub).where((0, drizzle_orm.eq)(sub[DISTINCT_RN], 1)).orderBy(...outerOrder);
3061
+ let query = db.select({
3062
+ ...Object.fromEntries(pkNames.map((name) => [name, sub[name]])),
3063
+ ...junction ? { [DISTINCT_PARENT]: sub[DISTINCT_PARENT] } : {}
3064
+ }).from(sub).where((0, drizzle_orm.eq)(sub[DISTINCT_RN], 1)).orderBy(...outerOrder);
3008
3065
  if (offset) query = query.offset(offset);
3009
3066
  if (limit != null) query = query.limit(limit);
3010
3067
  return await query;
@@ -4625,16 +4682,17 @@ const identifyRows = (field, args) => {
4625
4682
  * Window functions require PostgreSQL, MySQL >= 8.0, or SQLite >= 3.25.
4626
4683
  * Returns raw rows (NOT remapped); the caller groups + remaps them.
4627
4684
  */
4628
- const batchedPaginatedRelationQuery = async (db, targetTable, foreignCol, whereCondition, orderByArg, limit, offset, pkNames, columns, orderCtx, whereArgs) => {
4685
+ const batchedPaginatedRelationQuery = async (db, targetTable, foreignCol, whereCondition, orderByArg, limit, offset, pkNames, columns, orderCtx, whereArgs, junction) => {
4629
4686
  const cols = columns ?? (0, drizzle_orm.getColumns)(targetTable);
4630
4687
  const orderExprs = [...orderByArg ? extractOrderBy(targetTable, orderByArg, orderCtx, whereArgs) : [], ...primaryKeyOrderExprs(targetTable, pkNames)];
4631
4688
  const orderClause = orderExprs.length ? drizzle_orm.sql` order by ${drizzle_orm.sql.join(orderExprs, drizzle_orm.sql`, `)}` : drizzle_orm.sql``;
4632
4689
  const RN = "__drizzle_graphql_rn";
4633
- const rowNumber = drizzle_orm.sql`row_number() over (partition by ${foreignCol}${orderClause})`.as(RN);
4634
- const sub = db.select({
4690
+ const partitionCol = junction ? junction.sourceCol : foreignCol;
4691
+ const rowNumber = drizzle_orm.sql`row_number() over (partition by ${partitionCol}${orderClause})`.as(RN);
4692
+ const sub = withJunction(db.select({
4635
4693
  ...cols,
4636
4694
  [RN]: rowNumber
4637
- }).from(targetTable).where(whereCondition).as("__paginated");
4695
+ }).from(targetTable), junction, foreignCol).where(whereCondition).as("__paginated");
4638
4696
  const lower = offset ?? 0;
4639
4697
  const windowConds = [(0, drizzle_orm.gt)(sub[RN], lower)];
4640
4698
  if (limit != null) windowConds.push((0, drizzle_orm.lte)(sub[RN], lower + limit));
@@ -4643,6 +4701,14 @@ const batchedPaginatedRelationQuery = async (db, targetTable, foreignCol, whereC
4643
4701
  return rows;
4644
4702
  };
4645
4703
  /**
4704
+ * Alias the parent key is selected under when a relation goes through a junction table. The
4705
+ * key lives on the junction, not the target, so it has to be selected explicitly to group the
4706
+ * rows by parent — and a target row linked to several parents comes back once per parent.
4707
+ */
4708
+ const PARENT_KEY = "__drizzle_graphql_parent";
4709
+ /** `from(target)`, joined to the junction table when the relation has one. */
4710
+ const withJunction = (query, junction, foreignCol) => junction ? query.innerJoin(junction.table, (0, drizzle_orm.eq)(junction.targetCol, foreignCol)) : query;
4711
+ /**
4646
4712
  * Creates a RelationResolverFactory that generates field-level resolvers for each relation.
4647
4713
  * Each resolver:
4648
4714
  * 1. Returns pre-fetched data if the parent resolver already included it (eager path, zero cost).
@@ -4664,6 +4730,10 @@ const createRelationResolverFactory = (db, tables, nullOrdering, filterCtx, limi
4664
4730
  const joinCols = extractRelationJoinColumns(relEntry, parentTable, targetTable);
4665
4731
  if (!joinCols) return;
4666
4732
  const { localColPropName, foreignCol, foreignColPropName } = joinCols;
4733
+ const junction = extractJunctionColumns(relEntry);
4734
+ const groupCol = junction ? junction.sourceCol : foreignCol;
4735
+ const groupKey = junction ? PARENT_KEY : foreignColPropName;
4736
+ const ownCondition = relationOwnCondition(relEntry, parentTable, targetTable, (0, drizzle_orm.getColumns)(parentTable)[localColPropName], groupCol);
4667
4737
  const defaultDeleted = relationDeletedDefault(policies?.softDelete, targetTableName, isOne && relEntry.relation?.optional === false);
4668
4738
  const limitPolicy = isOne ? void 0 : limits?.(targetTableName);
4669
4739
  const targetPkNames = relEntry.targetPkNames ?? [];
@@ -4741,11 +4811,12 @@ const createRelationResolverFactory = (db, tables, nullOrdering, filterCtx, limi
4741
4811
  loaderKey: `${tableName}::${relationName}::${argsKey}`
4742
4812
  };
4743
4813
  });
4814
+ if (junction === null) throw drizzleError(`Relation '${tableName}.${relationName}' goes through a junction with a composite key, which can only be loaded together with its parent. Leave it to eager loading (eagerLoadRelations).`, { code: "DRIZZLE_UNSUPPORTED_RELATION" });
4744
4815
  return getOrCreateLoader(context, loaderKey, async (parentIds) => {
4745
4816
  const executor = resolveExecutor(db, context);
4746
4817
  const uniqueIds = [...new Set(parentIds)];
4747
4818
  const scope = resolveScope(policies, context, filterCtx);
4748
- let whereCondition = withScope(scope, targetTableName, targetTable, (0, drizzle_orm.and)((0, drizzle_orm.inArray)(foreignCol, uniqueIds), whereArg ? extractFilters(targetTable, targetTableName, whereArg, relationFilterCtx(filterCtx, targetTableName)) : void 0, cursorValues ? buildCursorCondition(targetTable, cursorEntries, cursorValues, nullOrdering) : void 0), deleted ?? defaultDeleted);
4819
+ let whereCondition = withScope(scope, targetTableName, targetTable, (0, drizzle_orm.and)((0, drizzle_orm.inArray)(groupCol, uniqueIds), ownCondition, whereArg ? extractFilters(targetTable, targetTableName, whereArg, relationFilterCtx(filterCtx, targetTableName)) : void 0, cursorValues ? buildCursorCondition(targetTable, cursorEntries, cursorValues, nullOrdering) : void 0), deleted ?? defaultDeleted);
4749
4820
  if (distinct) {
4750
4821
  const keys = await selectDistinctKeys({
4751
4822
  db: executor,
@@ -4755,27 +4826,41 @@ const createRelationResolverFactory = (db, tables, nullOrdering, filterCtx, limi
4755
4826
  pkNames: targetPkNames,
4756
4827
  where: whereCondition,
4757
4828
  orderBy: orderByArg,
4758
- partitionBy: [foreignCol]
4829
+ partitionBy: [groupCol],
4830
+ junction: junction ? {
4831
+ table: junction.table,
4832
+ on: (0, drizzle_orm.eq)(junction.targetCol, foreignCol),
4833
+ parentKey: groupCol
4834
+ } : void 0
4759
4835
  });
4760
4836
  if (!keys.length) return parentIds.map(() => isOne ? null : []);
4761
- whereCondition = (0, drizzle_orm.and)(whereCondition, primaryKeyRestriction(targetTable, targetPkNames, keys));
4837
+ whereCondition = (0, drizzle_orm.and)(whereCondition, junction ? (0, drizzle_orm.or)(...keys.map((key) => (0, drizzle_orm.and)((0, drizzle_orm.eq)(groupCol, key[DISTINCT_PARENT]), primaryKeyRestriction(targetTable, targetPkNames, [key])))) : primaryKeyRestriction(targetTable, targetPkNames, keys));
4762
4838
  }
4839
+ const selection = junction ? {
4840
+ ...columns ?? (0, drizzle_orm.getColumns)(targetTable),
4841
+ [PARENT_KEY]: groupCol
4842
+ } : columns;
4763
4843
  let rows;
4764
- if (limit != null || offset != null) rows = await batchedPaginatedRelationQuery(executor, targetTable, foreignCol, whereCondition, orderByArg, limit ?? null, offset ?? null, targetPkNames, columns, relationFilterCtx(filterCtx, targetTableName), whereArg);
4844
+ if (limit != null || offset != null) rows = await batchedPaginatedRelationQuery(executor, targetTable, foreignCol, whereCondition, orderByArg, limit ?? null, offset ?? null, targetPkNames, selection, relationFilterCtx(filterCtx, targetTableName), whereArg, junction);
4765
4845
  else {
4766
- let q = (columns ? executor.select(columns) : executor.select()).from(targetTable).where(whereCondition);
4846
+ let q = withJunction((selection ? executor.select(selection) : executor.select()).from(targetTable), junction, foreignCol).where(whereCondition);
4767
4847
  const orderExprs = cursorEntries ? cursorOrderExprs(targetTable, cursorEntries) : orderByArg ? extractOrderBy(targetTable, orderByArg, relationFilterCtx(filterCtx, targetTableName), whereArg) : [];
4768
4848
  if (orderExprs.length) q = q.orderBy(...orderExprs);
4769
4849
  rows = await q;
4770
4850
  }
4771
4851
  if (cursorEntries) attachRowCursors(rows, cursorEntries);
4852
+ const keyOf = (row) => {
4853
+ const key = String(row[groupKey]);
4854
+ if (junction) delete row[PARENT_KEY];
4855
+ return key;
4856
+ };
4772
4857
  if (isOne) {
4773
- const byKey = new Map(rows.map((row) => [String(row[foreignColPropName]), row]));
4858
+ const byKey = new Map(rows.map((row) => [keyOf(row), row]));
4774
4859
  remapToGraphQLArrayOutput(rows, targetTableName, targetTable);
4775
4860
  return parentIds.map((id) => byKey.get(String(id)) ?? null);
4776
4861
  }
4777
4862
  const grouped = new Map(uniqueIds.map((id) => [String(id), []]));
4778
- for (const row of rows) grouped.get(String(row[foreignColPropName]))?.push(row);
4863
+ for (const row of rows) grouped.get(keyOf(row))?.push(row);
4779
4864
  remapToGraphQLArrayOutput(rows, targetTableName, targetTable);
4780
4865
  return parentIds.map((id) => grouped.get(String(id)) ?? []);
4781
4866
  }).load(localValue);
@@ -5110,7 +5195,7 @@ const generateSelectFields = (tables, tableName, relationMap, fromTableName, fro
5110
5195
  });
5111
5196
  const shell = new graphql.GraphQLObjectType({
5112
5197
  name: typeName,
5113
- description: cacheCtx.docs.describeTable?.(tableName),
5198
+ description: cacheCtx.docs.describeTable?.(tableName, table),
5114
5199
  fields: () => ({
5115
5200
  ...tableFields,
5116
5201
  ...container.fields
@@ -5144,7 +5229,7 @@ const generateSelectFields = (tables, tableName, relationMap, fromTableName, fro
5144
5229
  defaultName: resolveTypeName(targetTableName, typeNameMapper),
5145
5230
  table: targetTableName
5146
5231
  }),
5147
- description: cacheCtx.docs.describeTable?.(targetTableName),
5232
+ description: cacheCtx.docs.describeTable?.(targetTableName, targetTable),
5148
5233
  fields: () => ({
5149
5234
  ...targetTableFields,
5150
5235
  ...capturedTargetContainer.fields
@@ -5295,7 +5380,7 @@ const generateTableTypes = (tableName, tables, relationMap, withReturning, relat
5295
5380
  defaultName: resolveTypeName(tableName, typeNameMapper),
5296
5381
  table: tableName
5297
5382
  }),
5298
- description: cacheCtx.docs.describeTable?.(tableName),
5383
+ description: cacheCtx.docs.describeTable?.(tableName, tables[tableName]),
5299
5384
  fields: {
5300
5385
  ...tableFields,
5301
5386
  ...relationFields
@@ -5862,6 +5947,10 @@ const createRelationAggregateFactory = (db, tables, cacheCtx, typeNameMapper, fi
5862
5947
  return;
5863
5948
  }
5864
5949
  const { localColPropName, foreignCol } = joinCols;
5950
+ const junction = extractJunctionColumns(relEntry);
5951
+ if (junction === null) return;
5952
+ const groupCol = junction ? junction.sourceCol : foreignCol;
5953
+ const ownCondition = relationOwnCondition(relEntry, parentTable, targetTable, (0, drizzle_orm.getColumns)(parentTable)[localColPropName], groupCol);
5865
5954
  const defaultDeleted = relationDeletedDefault(policies?.softDelete, targetTableName, false);
5866
5955
  const targetTypeName = resolveTypeName(targetTableName, typeNameMapper);
5867
5956
  const type = generateAggregateTypes(targetTable, targetTableName, targetTypeName, cacheCtx);
@@ -5892,11 +5981,12 @@ const createRelationAggregateFactory = (db, tables, cacheCtx, typeNameMapper, fi
5892
5981
  const loader = getOrCreateLoader(context, loaderKey, async (parentIds) => {
5893
5982
  const executor = resolveExecutor(db, context);
5894
5983
  const uniqueIds = [...new Set(parentIds)];
5895
- const whereCondition = withScope(resolveScope(policies, context, filterCtx), targetTableName, targetTable, (0, drizzle_orm.and)((0, drizzle_orm.inArray)(foreignCol, uniqueIds), whereArg ? extractFilters(targetTable, targetTableName, whereArg, relationFilterCtx(filterCtx, targetTableName)) : void 0), deleted ?? defaultDeleted);
5896
- const rows = await executor.select({
5897
- [GROUP_KEY]: foreignCol,
5984
+ const whereCondition = withScope(resolveScope(policies, context, filterCtx), targetTableName, targetTable, (0, drizzle_orm.and)((0, drizzle_orm.inArray)(groupCol, uniqueIds), ownCondition, whereArg ? extractFilters(targetTable, targetTableName, whereArg, relationFilterCtx(filterCtx, targetTableName)) : void 0), deleted ?? defaultDeleted);
5985
+ const selected = executor.select({
5986
+ [GROUP_KEY]: groupCol,
5898
5987
  ...request.selection
5899
- }).from(targetTable).where(whereCondition).groupBy(foreignCol);
5988
+ }).from(targetTable);
5989
+ const rows = await (junction ? selected.innerJoin(junction.table, (0, drizzle_orm.eq)(junction.targetCol, foreignCol)) : selected).where(whereCondition).groupBy(groupCol);
5900
5990
  const byKey = new Map(rows.map((row) => [row[GROUP_KEY], row]));
5901
5991
  return parentIds.map((id) => byKey.get(id) ?? {});
5902
5992
  });