@vantreeseba/drizzle-graphql 13.1.1 → 15.0.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/dist/index.cjs CHANGED
@@ -184,6 +184,76 @@ const GraphQLDecimalString = new graphql.GraphQLScalarType({
184
184
  return parseNumeric(ast.value);
185
185
  }
186
186
  });
187
+ const fullDatePattern = /^(\d{4})-(\d{2})-(\d{2})$/;
188
+ const daysInMonth = (year, month) => {
189
+ if (month === 2) return year % 4 === 0 && year % 100 !== 0 || year % 400 === 0 ? 29 : 28;
190
+ return [
191
+ 4,
192
+ 6,
193
+ 9,
194
+ 11
195
+ ].includes(month) ? 30 : 31;
196
+ };
197
+ /** Whether `value` is a `YYYY-MM-DD` string naming a day that exists on the calendar. */
198
+ const isCalendarDate = (value) => {
199
+ const match = fullDatePattern.exec(value);
200
+ if (!match) return false;
201
+ const year = Number(match[1]);
202
+ const month = Number(match[2]);
203
+ const day = Number(match[3]);
204
+ return month >= 1 && month <= 12 && day >= 1 && day <= daysInMonth(year, month);
205
+ };
206
+ /**
207
+ * A JS `Date` as the calendar day it stands for. Read in UTC, because that is how drivers
208
+ * build one from a `date` column: `new Date('2026-09-15')` is UTC midnight, so the local
209
+ * getters would name the previous day anywhere west of Greenwich.
210
+ */
211
+ const dateToCalendarDate = (value) => {
212
+ if (Number.isNaN(value.getTime())) return;
213
+ const year = value.getUTCFullYear();
214
+ if (year < 0 || year > 9999) return;
215
+ const month = String(value.getUTCMonth() + 1).padStart(2, "0");
216
+ const day = String(value.getUTCDate()).padStart(2, "0");
217
+ return `${String(year).padStart(4, "0")}-${month}-${day}`;
218
+ };
219
+ const calendarDate = (code, acceptDate) => (value) => {
220
+ if (typeof value === "string") {
221
+ if (!isCalendarDate(value)) throw drizzleError(`Date cannot represent "${value}": expected a calendar date as YYYY-MM-DD`, { code });
222
+ return value;
223
+ }
224
+ if (acceptDate && value instanceof Date) {
225
+ const formatted = dateToCalendarDate(value);
226
+ if (formatted === void 0) throw drizzleError("Date cannot represent an invalid or out-of-range Date", { code });
227
+ return formatted;
228
+ }
229
+ throw drizzleError(`Date cannot represent value: ${JSON.stringify(value)}: expected a YYYY-MM-DD string`, { code });
230
+ };
231
+ const parseCalendarDate = calendarDate(INVALID_INPUT, false);
232
+ /**
233
+ * A calendar day — `date` columns, whichever `mode` drizzle reads them in. Always transported
234
+ * as an RFC 3339 `full-date` string (`YYYY-MM-DD`), in both directions: it has no time and no
235
+ * offset, so there is nothing for a client's timezone to shift. `graphql-scalars`' own
236
+ * `GraphQLDate` is deliberately not used: its `parseValue` returns a JS `Date`, so the same
237
+ * scalar would be a string on the way out and an instant on the way in.
238
+ *
239
+ * Input accepts only a `YYYY-MM-DD` string naming a real day (`2026-02-30` and datetime
240
+ * strings are rejected) and hands it on unchanged. Output also accepts a JS `Date`, read as
241
+ * the UTC day it encodes.
242
+ */
243
+ const GraphQLDate = new graphql.GraphQLScalarType({
244
+ name: "Date",
245
+ description: "A calendar date with no time or offset, transported as an RFC 3339 full-date string (YYYY-MM-DD).",
246
+ specifiedByURL: "https://datatracker.ietf.org/doc/html/rfc3339#section-5.6",
247
+ serialize: calendarDate(UNREPRESENTABLE, true),
248
+ parseValue: parseCalendarDate,
249
+ parseLiteral: (ast) => {
250
+ if (ast.kind !== graphql.Kind.STRING) throw drizzleError(`Date cannot represent a ${ast.kind}`, {
251
+ code: INVALID_INPUT,
252
+ nodes: ast
253
+ });
254
+ return parseCalendarDate(ast.value);
255
+ }
256
+ });
187
257
  //#endregion
188
258
  //#region src/util/case-ops/index.ts
189
259
  const uncapitalize = (input) => input?.length ? `${input[0].toLocaleLowerCase()}${input.length > 1 ? input.slice(1, input.length) : ""}` : input;
@@ -217,6 +287,17 @@ const singularizeMapper = (tableName) => ({
217
287
  //#endregion
218
288
  //#region src/util/type-converter/index.ts
219
289
  const allowedNameChars = /^[a-zA-Z0-9_]+$/;
290
+ /** drizzle's `number` constraints whose values always fit a GraphQL `Int` (32-bit signed). */
291
+ const smallIntegerConstraints = /* @__PURE__ */ new Set([
292
+ "int8",
293
+ "uint8",
294
+ "int16",
295
+ "uint16",
296
+ "int24",
297
+ "uint24",
298
+ "int32",
299
+ "year"
300
+ ]);
220
301
  /** A GraphQL `Name` that is also allowed as an enum value — the spec reserves the three literals. */
221
302
  const validEnumValueName = (name) => /^[_a-zA-Z][_a-zA-Z0-9]*$/.test(name) && name !== "true" && name !== "false" && name !== "null";
222
303
  /**
@@ -357,6 +438,52 @@ const geoXyInputType = new graphql.GraphQLInputObjectType({
357
438
  y: { type: graphql.GraphQLFloat }
358
439
  }
359
440
  });
441
+ /**
442
+ * Whether the column holds a calendar day with no time component — a SQL `date`, in either of
443
+ * the modes drizzle reads one in (`'date'` → a JS `Date`, `'string'` → `YYYY-MM-DD`). Every
444
+ * position a column's type appears in — output, create/update input, filter operands,
445
+ * aggregates and group keys — gives such a column the `Date` scalar, whichever mode it uses:
446
+ * the mode is how the server holds the value, not what the value is. SQLite has no date-only
447
+ * column type (its integer timestamps are instants — see {@link isDateTimeColumn}), so only
448
+ * Postgres and MySQL appear here.
449
+ */
450
+ const isDateOnlyColumn = (column) => (0, drizzle_orm.is)(column, drizzle_orm_pg_core.PgDate) || (0, drizzle_orm.is)(column, drizzle_orm_pg_core.PgDateString) || (0, drizzle_orm.is)(column, drizzle_orm_mysql_core.MySqlDate) || (0, drizzle_orm.is)(column, drizzle_orm_mysql_core.MySqlDateString);
451
+ /**
452
+ * Whether the column is a MySQL `timestamp` / `datetime` read in drizzle's `'string'` mode. The
453
+ * driver hands such a column back as MySQL's own `YYYY-MM-DD HH:MM:SS[.fraction]` text, with no
454
+ * offset, and expects that form on write, so the data mappers translate between it and the ISO
455
+ * 8601 instant the `DateTime` scalar carries. The text is read as UTC, the way drizzle's own
456
+ * `'date'`-mode codecs for these columns read it.
457
+ */
458
+ const isMySqlDateTimeStringColumn = (column) => (0, drizzle_orm.is)(column, drizzle_orm_mysql_core.MySqlTimestampString) || (0, drizzle_orm.is)(column, drizzle_orm_mysql_core.MySqlDateTimeString);
459
+ /**
460
+ * Whether the column is a Postgres `timestamp` read in drizzle's `'string'` mode. The driver hands
461
+ * such a column back as Postgres's own text — `YYYY-MM-DD HH:MM:SS[.fraction]`, followed by an
462
+ * offset (`+00`, `-06`, `+05:30`) for `timestamptz` — so the data mappers translate between it
463
+ * and the ISO 8601 instant the `DateTime` scalar carries. Text without an offset (`timestamp`
464
+ * without time zone) is read as UTC, the way drizzle's own `'date'`-mode codec reads it.
465
+ */
466
+ const isPgTimestampStringColumn = (column) => (0, drizzle_orm.is)(column, drizzle_orm_pg_core.PgTimestampString);
467
+ /**
468
+ * Whether the column is a SQLite `integer({ mode: 'timestamp' | 'timestamp_ms' })`. Both modes
469
+ * are one drizzle class, told apart by `mode`: the stored integer counts seconds or milliseconds
470
+ * since the Unix epoch. drizzle converts a JS `Date` to that integer on write (`'timestamp'`
471
+ * floors to the whole second) and back on read, but a value read through a path that skips the
472
+ * column's decoder arrives as the bare integer, which {@link sqliteTimestampToDate} reads.
473
+ */
474
+ const isSQLiteTimestampColumn = (column) => (0, drizzle_orm.is)(column, drizzle_orm_sqlite_core.SQLiteTimestamp);
475
+ /**
476
+ * A SQLite timestamp column's stored integer as the instant it names, using the column's own
477
+ * unit (seconds for `'timestamp'`, milliseconds for `'timestamp_ms'`).
478
+ */
479
+ const sqliteTimestampToDate = (column, value) => new Date(column.mode === "timestamp" ? value * 1e3 : value);
480
+ /**
481
+ * Whether the column holds an instant — a Postgres `timestamp`, a MySQL `timestamp` /
482
+ * `datetime`, in either drizzle mode, or a SQLite integer timestamp in either unit. Every position
483
+ * such a column appears in gives it the `DateTime` scalar and the `DateTimeFilter`, whichever
484
+ * mode it uses, for the same reason as {@link isDateOnlyColumn}.
485
+ */
486
+ const isDateTimeColumn = (column) => (0, drizzle_orm.is)(column, drizzle_orm_pg_core.PgTimestamp) || isPgTimestampStringColumn(column) || (0, drizzle_orm.is)(column, drizzle_orm_mysql_core.MySqlTimestamp) || (0, drizzle_orm.is)(column, drizzle_orm_mysql_core.MySqlDateTime) || isMySqlDateTimeStringColumn(column) || isSQLiteTimestampColumn(column);
360
487
  const columnToGraphQLCore = (column, columnName, tableName, isInput) => {
361
488
  const dimensions = column.dimensions;
362
489
  if (dimensions !== void 0 && dimensions > 0) {
@@ -383,7 +510,11 @@ const columnBaseToGraphQLCore = (column, columnName, tableName, isInput) => {
383
510
  typeLabel: "Boolean"
384
511
  };
385
512
  case "object":
386
- if (column instanceof drizzle_orm_pg_core.PgTimestamp || column instanceof drizzle_orm_pg_core.PgDate) return {
513
+ if (isDateOnlyColumn(column)) return {
514
+ type: GraphQLDate,
515
+ typeLabel: "Date"
516
+ };
517
+ if (isDateTimeColumn(column)) return {
387
518
  type: graphql_scalars.GraphQLDateTime,
388
519
  typeLabel: "DateTime"
389
520
  };
@@ -403,7 +534,7 @@ const columnBaseToGraphQLCore = (column, columnName, tableName, isInput) => {
403
534
  type: GraphQLDecimalString,
404
535
  typeLabel: "Decimal"
405
536
  };
406
- if (column instanceof drizzle_orm_pg_core.PgTimestamp || column instanceof drizzle_orm_pg_core.PgTimestampString) return {
537
+ if (isDateTimeColumn(column)) return {
407
538
  type: graphql_scalars.GraphQLDateTime,
408
539
  typeLabel: "DateTime"
409
540
  };
@@ -411,11 +542,8 @@ const columnBaseToGraphQLCore = (column, columnName, tableName, isInput) => {
411
542
  type: graphql_scalars.GraphQLUUID,
412
543
  typeLabel: "UUID"
413
544
  };
414
- if (column instanceof drizzle_orm_pg_core.PgDateString) return isInput ? {
415
- type: graphql.GraphQLString,
416
- typeLabel: "Date"
417
- } : {
418
- type: graphql_scalars.GraphQLDate,
545
+ if (isDateOnlyColumn(column)) return {
546
+ type: GraphQLDate,
419
547
  typeLabel: "Date"
420
548
  };
421
549
  return {
@@ -426,7 +554,7 @@ const columnBaseToGraphQLCore = (column, columnName, tableName, isInput) => {
426
554
  type: GraphQLBigIntString,
427
555
  typeLabel: "BigInt"
428
556
  };
429
- case "number": return (0, drizzle_orm.is)(column, drizzle_orm_pg_core.PgInteger) || (0, drizzle_orm.is)(column, drizzle_orm_pg_core.PgSerial) || (0, drizzle_orm.is)(column, drizzle_orm_mysql_core.MySqlInt) || (0, drizzle_orm.is)(column, drizzle_orm_mysql_core.MySqlSerial) || (0, drizzle_orm.is)(column, drizzle_orm_sqlite_core.SQLiteInteger) ? {
557
+ case "number": return constraint !== void 0 && smallIntegerConstraints.has(constraint) || (0, drizzle_orm.is)(column, drizzle_orm_pg_core.PgInteger) || (0, drizzle_orm.is)(column, drizzle_orm_pg_core.PgSerial) || (0, drizzle_orm.is)(column, drizzle_orm_mysql_core.MySqlInt) || (0, drizzle_orm.is)(column, drizzle_orm_mysql_core.MySqlSerial) || (0, drizzle_orm.is)(column, drizzle_orm_sqlite_core.SQLiteInteger) ? {
430
558
  type: graphql.GraphQLInt,
431
559
  typeLabel: "Integer"
432
560
  } : {
@@ -602,7 +730,8 @@ const isJsonColumn = (column) => (column.dataType ?? "").includes("json") && col
602
730
  * - "Decimal" → numeric/decimal columns (Decimal-scalar-typed operators, no string pattern operators)
603
731
  * - the enum GraphQL type name → enum columns (still unique per enum; equality and
604
732
  * membership only, like Boolean)
605
- * - "DateTime" → timestamp and date columns (ordering kept, no string pattern operators)
733
+ * - "Date" → date columns, either mode (ordering kept, no string pattern operators)
734
+ * - "DateTime" → timestamp columns (ordering kept, no string pattern operators)
606
735
  * - "Int" → integer/serial columns (no string pattern operators)
607
736
  * - "Float" → real/double columns (no string pattern operators)
608
737
  * - "String" → all other text/varchar columns
@@ -641,8 +770,11 @@ const resolveGenericFilterDescriptor = (column, columnGraphQLType) => {
641
770
  name: "Decimal",
642
771
  kind: "scalar"
643
772
  };
644
- const ct = column.columnType ?? "";
645
- if (ct === "PgTimestamp" || ct === "PgTimestampString" || ct === "PgDate") return {
773
+ if (columnGraphQLType.type === GraphQLDate) return {
774
+ name: "Date",
775
+ kind: "scalar"
776
+ };
777
+ if (isDateTimeColumn(column)) return {
646
778
  name: "DateTime",
647
779
  kind: "scalar"
648
780
  };
@@ -895,7 +1027,7 @@ const BUILTIN_FILTER_SCALARS = /* @__PURE__ */ new Set([
895
1027
  GraphQLDecimalString,
896
1028
  graphql_scalars.GraphQLUUID,
897
1029
  graphql_scalars.GraphQLJSON,
898
- graphql_scalars.GraphQLDate,
1030
+ GraphQLDate,
899
1031
  graphql_scalars.GraphQLDateTime
900
1032
  ]);
901
1033
  /**
@@ -1037,6 +1169,58 @@ const generateColumnFilterValues = (column, tableName, columnName, cacheCtx) =>
1037
1169
  };
1038
1170
  //#endregion
1039
1171
  //#region src/util/data-mappers/index.ts
1172
+ /**
1173
+ * MySQL's own text form for a `timestamp` / `datetime`: `YYYY-MM-DD HH:MM:SS`, an optional
1174
+ * fraction, and no offset. mysql2 returns it for a `'string'`-mode column, and it is what such a
1175
+ * column expects on write.
1176
+ */
1177
+ const MYSQL_DATETIME_TEXT = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2}(?:\.\d+)?)$/;
1178
+ /**
1179
+ * MySQL datetime text, read as UTC, as the ISO 8601 instant the `DateTime` scalar carries.
1180
+ * UTC because that is how drizzle's own `'date'`-mode codecs read the same text, so a column's
1181
+ * two modes agree on the instant. Anything else (a zero date, a value already carrying an
1182
+ * offset) is returned unchanged for the scalar to accept or reject.
1183
+ */
1184
+ const mySqlDateTimeTextToIso = (value) => {
1185
+ const match = MYSQL_DATETIME_TEXT.exec(value);
1186
+ return match ? `${match[1]}T${match[2]}Z` : value;
1187
+ };
1188
+ /**
1189
+ * A JS `Date` as offset-less `YYYY-MM-DD HH:MM:SS.mmm` text in UTC: what a MySQL `'string'`-mode
1190
+ * column and a Postgres `timestamp` (without time zone) `'string'`-mode column are written with.
1191
+ */
1192
+ const dateToUtcWallText = (value) => value.toISOString().slice(0, -1).replace("T", " ");
1193
+ /**
1194
+ * Postgres's text form for a `timestamp` / `timestamptz` (the `ISO` `DateStyle`, the default):
1195
+ * `YYYY-MM-DD HH:MM:SS`, an optional fraction, and — for `timestamptz` only — the session zone's
1196
+ * offset as `±HH`, `±HH:MM` or, for historical local mean time, `±HH:MM:SS`.
1197
+ */
1198
+ const PG_TIMESTAMP_TEXT = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})(\.\d+)?(?:([+-])(\d{2})(?::?(\d{2}))?(?::?(\d{2}))?)?$/;
1199
+ /**
1200
+ * Postgres timestamp text as the RFC 3339 UTC instant (`…Z`) the `DateTime` scalar accepts.
1201
+ *
1202
+ * - With an offset (`timestamptz`), the instant is shifted to UTC. The offset can carry seconds,
1203
+ * which RFC 3339 cannot express, so it is applied here rather than rewritten.
1204
+ * - Without one (`timestamp`), the wall time is read as UTC: that is what drizzle's `'date'`-mode
1205
+ * codec does (`new Date(text + '+0000')`) and what the input side writes, so a column's two
1206
+ * modes agree on the instant.
1207
+ * - The fraction is kept digit for digit. The scalar itself carries milliseconds, so microseconds
1208
+ * are truncated on the wire, never rounded into the next second.
1209
+ *
1210
+ * Anything else (`infinity`, a `BC` date, a five-digit year) is returned unchanged for the scalar
1211
+ * to reject.
1212
+ */
1213
+ const pgTimestampTextToIso = (value) => {
1214
+ const match = PG_TIMESTAMP_TEXT.exec(value);
1215
+ if (!match) return value;
1216
+ const [, date, time, fraction = "", sign, hours, minutes = "0", seconds = "0"] = match;
1217
+ if (!sign) return `${date}T${time}${fraction}Z`;
1218
+ const wallMs = Date.parse(`${date}T${time}Z`);
1219
+ if (Number.isNaN(wallMs)) return value;
1220
+ const offsetSeconds = (sign === "-" ? -1 : 1) * (Number(hours) * 3600 + Number(minutes) * 60 + Number(seconds));
1221
+ const utc = (/* @__PURE__ */ new Date(wallMs - offsetSeconds * 1e3)).toISOString();
1222
+ return utc.length === 24 ? `${utc.slice(0, 19)}${fraction}Z` : value;
1223
+ };
1040
1224
  const remapToGraphQLCore = (key, value, tableName, column, relationMap) => {
1041
1225
  if (Array.isArray(value)) {
1042
1226
  const relations = relationMap?.[tableName];
@@ -1055,7 +1239,14 @@ const remapToGraphQLCore = (key, value, tableName, column, relationMap) => {
1055
1239
  if (!column) return value;
1056
1240
  if (getColumnScalarOverride(column, false)) return value;
1057
1241
  if (isJsonColumn(column)) return value;
1242
+ if (value instanceof Date && isDateOnlyColumn(column)) return dateToCalendarDate(value) ?? value;
1243
+ if (typeof value === "number" && isSQLiteTimestampColumn(column)) {
1244
+ const instant = sqliteTimestampToDate(column, value);
1245
+ return Number.isNaN(instant.getTime()) ? value : instant.toISOString();
1246
+ }
1058
1247
  if (value instanceof Date) return value.toISOString();
1248
+ if (typeof value === "string" && isMySqlDateTimeStringColumn(column)) return mySqlDateTimeTextToIso(value);
1249
+ if (typeof value === "string" && isPgTimestampStringColumn(column)) return pgTimestampTextToIso(value);
1059
1250
  if (value instanceof Buffer) return Array.from(value);
1060
1251
  if (typeof value === "bigint") return value.toString();
1061
1252
  if (Array.isArray(value)) {
@@ -1101,16 +1292,20 @@ const remapFromGraphQLCore = (value, column, columnName) => {
1101
1292
  }
1102
1293
  const dataType = column.dataType ?? "";
1103
1294
  const columnType = column.columnType ?? "";
1104
- if (columnType === "SQLiteTimestamp" || columnType === "SQLiteTimestampMs" || columnType === "MySqlTimestamp" || columnType === "MySqlDateTime" || columnType === "PgTimestamp" || columnType === "PgTimestampString") {
1105
- const formatted = new Date(value);
1295
+ if (isDateTimeColumn(column)) {
1296
+ const isMySql = columnType.startsWith("MySql");
1297
+ const isPgString = isPgTimestampStringColumn(column);
1298
+ const toIso = isMySql ? mySqlDateTimeTextToIso : isPgString ? pgTimestampTextToIso : void 0;
1299
+ const formatted = new Date(toIso && typeof value === "string" ? toIso(value) : value);
1106
1300
  if (Number.isNaN(formatted.getTime())) throw drizzleError(`Field '${columnName}' is not a valid date!`, { code: "DRIZZLE_INVALID_INPUT_VALUE" });
1107
- return formatted;
1301
+ if (isPgString) return column.withTimezone ? formatted.toISOString() : dateToUtcWallText(formatted);
1302
+ return isMySqlDateTimeStringColumn(column) ? dateToUtcWallText(formatted) : formatted;
1108
1303
  }
1109
- if ((columnType === "MySqlDate" || columnType === "PgDate") && typeof value === "string") {
1110
- const dateOnly = value.includes("T") ? value.split("T")[0] : value;
1111
- const check = new Date(dateOnly);
1112
- if (Number.isNaN(check.getTime())) throw drizzleError(`Field '${columnName}' is not a valid date!`, { code: "DRIZZLE_INVALID_INPUT_VALUE" });
1113
- return dateOnly;
1304
+ if (isDateOnlyColumn(column)) {
1305
+ if (Array.isArray(value)) return value.map((element) => remapFromGraphQLCore(element, column, columnName));
1306
+ const day = value instanceof Date ? dateToCalendarDate(value) : value;
1307
+ if (typeof day !== "string" || !isCalendarDate(day)) throw drizzleError(`Field '${columnName}' is not a valid date! Expected a calendar date as YYYY-MM-DD.`, { code: "DRIZZLE_INVALID_INPUT_VALUE" });
1308
+ return day;
1114
1309
  }
1115
1310
  if (dataType.includes("bigint")) try {
1116
1311
  return BigInt(value);
@@ -1466,7 +1661,8 @@ const extractFiltersColumn = (column, columnName, operators) => {
1466
1661
  }
1467
1662
  if (NOT) {
1468
1663
  const extracted = extractFiltersColumn(column, columnName, NOT);
1469
- if (extracted) variants.push((0, drizzle_orm.not)(extracted));
1664
+ const negated = extracted ? (0, drizzle_orm.not)(extracted) : void 0;
1665
+ if (negated) variants.push(negated);
1470
1666
  }
1471
1667
  if (OR?.length) {
1472
1668
  const orVariants = [];
@@ -1479,6 +1675,150 @@ const extractFiltersColumn = (column, columnName, operators) => {
1479
1675
  return variants.length ? variants.length > 1 ? (0, drizzle_orm.and)(...variants) : variants[0] : void 0;
1480
1676
  };
1481
1677
  //#endregion
1678
+ //#region src/util/builders/common/relations.ts
1679
+ /**
1680
+ * Flatten drizzle-orm v1 TablesRelationalConfig into the canonical
1681
+ * Record<tableName, Record<relName, TableNamedRelations>> shape used
1682
+ * throughout common.ts. Both pg.ts and sqlite.ts call this before
1683
+ * passing the relation map to any shared function.
1684
+ */
1685
+ const buildNamedRelations = (relations, tableEntries) => {
1686
+ const namedRelations = {};
1687
+ for (const [relTableName, relConfig] of Object.entries(relations)) {
1688
+ if (!relConfig?.relations) continue;
1689
+ const namedConfig = {};
1690
+ for (const [innerRelName, innerRelValue] of Object.entries(relConfig.relations)) {
1691
+ const targetTable = innerRelValue.targetTable ?? innerRelValue.referencedTable;
1692
+ const directTargetName = innerRelValue.targetTableName;
1693
+ let targetTableName;
1694
+ if (directTargetName) targetTableName = tableEntries.find(([key]) => key === directTargetName)?.[0];
1695
+ else if (targetTable) targetTableName = tableEntries.find(([, tableValue]) => tableValue === targetTable)?.[0];
1696
+ if (!targetTableName) continue;
1697
+ namedConfig[innerRelName] = {
1698
+ relation: innerRelValue,
1699
+ targetTableName
1700
+ };
1701
+ }
1702
+ if (Object.keys(namedConfig).length > 0) namedRelations[relTableName] = namedConfig;
1703
+ }
1704
+ return namedRelations;
1705
+ };
1706
+ /**
1707
+ * Records each relation's target-table primary-key property names on the relation entry,
1708
+ * so the pagination paths (the window-function batch loader and the eager `with:` orderBy
1709
+ * default) can fall back to a deterministic PK order without re-deriving it per request.
1710
+ *
1711
+ * Composite primary keys are only visible through the dialect's getTableConfig, so the
1712
+ * dialect builder passes a `resolvePkNames` that threads the composite column names in.
1713
+ * Mutates the relation entries in place (they are shared with the pruned eager map and
1714
+ * the resolver factory, so attaching once covers every consumer).
1715
+ */
1716
+ const attachTargetPrimaryKeys = (namedRelations, tables, resolvePkNames) => {
1717
+ const cache = /* @__PURE__ */ new Map();
1718
+ for (const rels of Object.values(namedRelations)) for (const relEntry of Object.values(rels)) {
1719
+ const { targetTableName } = relEntry;
1720
+ let pk = cache.get(targetTableName);
1721
+ if (!pk) {
1722
+ const targetTable = tables[targetTableName];
1723
+ pk = targetTable ? resolvePkNames(targetTable) : [];
1724
+ cache.set(targetTableName, pk);
1725
+ }
1726
+ relEntry.targetPkNames = pk;
1727
+ }
1728
+ };
1729
+ /**
1730
+ * The table column behind one entry of a relation's `sourceColumns`, `targetColumns` or
1731
+ * `through` lists.
1732
+ *
1733
+ * drizzle-orm rc.4 stores the table's own Column objects in `sourceColumns` / `targetColumns`
1734
+ * and a `RelationsBuilderColumn` descriptor (`{ _: { column, key, tableName } }`) in `through`.
1735
+ * The rc.5 snapshots store the descriptor in all of them. Both shapes are accepted here, told
1736
+ * apart structurally rather than by importing a class one of those versions does not export.
1737
+ * Anything else yields `undefined`, which callers treat as "join columns not readable".
1738
+ */
1739
+ const unwrapRelationColumn = (entry) => {
1740
+ if ((0, drizzle_orm.is)(entry, drizzle_orm.Column)) return entry;
1741
+ const inner = entry?._?.column;
1742
+ return (0, drizzle_orm.is)(inner, drizzle_orm.Column) ? inner : void 0;
1743
+ };
1744
+ /**
1745
+ * A relation's `sourceColumns` or `targetColumns` as table columns, via
1746
+ * {@link unwrapRelationColumn}. `undefined` when the list is missing, empty, or has an entry
1747
+ * that is not a column in either known shape.
1748
+ */
1749
+ const relationColumns = (relation, side) => {
1750
+ const entries = relation?.[side === "source" ? "sourceColumns" : "targetColumns"];
1751
+ if (!entries?.length) return;
1752
+ const columns = [];
1753
+ for (const entry of entries) {
1754
+ const column = unwrapRelationColumn(entry);
1755
+ if (!column) return;
1756
+ columns.push(column);
1757
+ }
1758
+ return columns;
1759
+ };
1760
+ /**
1761
+ * drizzle-orm's "no filter" sentinel. rc.5 snapshots export it as `EmptyFilter` and use it as
1762
+ * the default `relation.where`; rc.4 has no such export and leaves `where` undefined. It is a
1763
+ * registered symbol, so it can be compared against without importing anything.
1764
+ */
1765
+ const EMPTY_FILTER = Symbol.for("drizzle:EmptyFilter");
1766
+ /**
1767
+ * The relation's own declared `where` filter, or `undefined` when it has none. rc.4 leaves
1768
+ * `where` undefined in that case; rc.5 snapshots set it to the `EmptyFilter` sentinel, which is
1769
+ * truthy and must not be read as a filter.
1770
+ */
1771
+ const relationOwnWhere = (relation) => {
1772
+ const where = relation?.where;
1773
+ return where == null || where === EMPTY_FILTER ? void 0 : where;
1774
+ };
1775
+ /**
1776
+ * Whether the relation's `where` was inherited from its reverse relation, and so is written
1777
+ * against the parent table rather than the target. rc.4 calls this flag `isReversed`; rc.5
1778
+ * snapshots renamed it to `isFilterReversed`. Both are set in the same place with the same
1779
+ * meaning (true only for a relation declared without `from`/`to` and without its own `where`).
1780
+ */
1781
+ const isRelationFilterReversed = (relation) => !!(relation?.isFilterReversed ?? relation?.isReversed);
1782
+ /**
1783
+ * Warns, at build time, that a field was left out because the relation's join columns could
1784
+ * not be matched to its tables. Omitting it silently is how a drizzle-orm change in the shape
1785
+ * of relation columns (issue #179) went unnoticed. A relation that declares no join columns
1786
+ * is not reported: there is nothing that failed to match.
1787
+ */
1788
+ const warnUnresolvedJoin = (relEntry, tableName, relationName, omitted) => {
1789
+ const rel = relEntry.relation ?? relEntry;
1790
+ if (!rel?.sourceColumns?.length || !rel?.targetColumns?.length) return;
1791
+ console.warn(`Drizzle-GraphQL Warning: relation '${tableName}.${relationName}' declares join columns that could not be matched to columns of '${tableName}' and '${relEntry.targetTableName}', so ${omitted} is not generated. The installed drizzle-orm probably stores relation columns in a shape this version of drizzle-graphql does not recognize.`);
1792
+ };
1793
+ /**
1794
+ * Extracts the join column info from a drizzle-orm v1 Relation object.
1795
+ * Returns the JS property name of the local column on the parent table and the
1796
+ * Column object for the foreign column on the target table, or undefined if the
1797
+ * relation internals are not accessible.
1798
+ */
1799
+ const extractRelationJoinColumns = (relEntry, parentTable, targetTable) => {
1800
+ const rel = relEntry.relation ?? relEntry;
1801
+ const sourceCol = relationColumns(rel, "source")?.[0];
1802
+ const targetCol = relationColumns(rel, "target")?.[0];
1803
+ if (!sourceCol || !targetCol) return;
1804
+ const parentCols = (0, drizzle_orm.getColumns)(parentTable);
1805
+ const localColPropName = Object.entries(parentCols).find(([, c]) => c === sourceCol)?.[0];
1806
+ const targetCols = (0, drizzle_orm.getColumns)(targetTable);
1807
+ const foreignColPropName = Object.entries(targetCols).find(([, c]) => c === targetCol)?.[0];
1808
+ if (!localColPropName || !foreignColPropName) return;
1809
+ return {
1810
+ localColPropName,
1811
+ foreignCol: targetCol,
1812
+ foreignColPropName
1813
+ };
1814
+ };
1815
+ /**
1816
+ * Whether a to-one relation can back a relation orderBy hop. `.through()` (junction)
1817
+ * relations are excluded — the ordering subquery only joins the direct target, never the
1818
+ * junction table. (Relation *filters* do support `.through()` — this guard is orderBy-only.)
1819
+ */
1820
+ const isFilterableRelation = (relation) => !relation.through;
1821
+ //#endregion
1482
1822
  //#region src/util/builders/common/exclusions.ts
1483
1823
  /**
1484
1824
  * Columns this build keeps out of the generated schema, keyed by the table object.
@@ -1654,8 +1994,8 @@ const relationFilterCtx = (base, tableKey) => {
1654
1994
  * the parent is an aliased proxy (as it is inside a relational `with:` where callback).
1655
1995
  */
1656
1996
  const buildRelationJoinCondition = (parentTable, relation, aliasedTarget, relationName) => {
1657
- const sourceColumns = relation.sourceColumns;
1658
- const targetColumns = relation.targetColumns;
1997
+ const sourceColumns = relationColumns(relation, "source");
1998
+ const targetColumns = relationColumns(relation, "target");
1659
1999
  if (!sourceColumns?.length || sourceColumns.length !== targetColumns?.length) throw drizzleError(`WHERE ${relationName}: Relation cannot be used as a filter`, { code: "DRIZZLE_INVALID_FILTER" });
1660
2000
  const parentColumns = Object.values((0, drizzle_orm.getColumns)(parentTable));
1661
2001
  const targetColumnsByName = Object.values((0, drizzle_orm.getColumns)(aliasedTarget));
@@ -1679,7 +2019,7 @@ const resolveJunctionColumn = (aliasedThrough, junctionRef, relationName) => {
1679
2019
  const key = junctionRef?._?.key;
1680
2020
  const byKey = key ? throughColumns[key] : void 0;
1681
2021
  if (byKey) return byKey;
1682
- const columnName = junctionRef?._?.column?.name;
2022
+ const columnName = unwrapRelationColumn(junctionRef)?.name;
1683
2023
  const byName = columnName ? Object.values(throughColumns).find((c) => c.name === columnName) : void 0;
1684
2024
  if (!byName) throw drizzleError(`WHERE ${relationName}: Relation cannot be used as a filter`, { code: "DRIZZLE_INVALID_FILTER" });
1685
2025
  return byName;
@@ -1693,8 +2033,8 @@ const resolveJunctionColumn = (aliasedThrough, junctionRef, relationName) => {
1693
2033
  * `through` metadata.
1694
2034
  */
1695
2035
  const buildThroughJoinConditions = (parentTable, relation, aliasedThrough, aliasedTarget, relationName) => {
1696
- const sourceColumns = relation.sourceColumns;
1697
- const targetColumns = relation.targetColumns;
2036
+ const sourceColumns = relationColumns(relation, "source");
2037
+ const targetColumns = relationColumns(relation, "target");
1698
2038
  const through = relation.through;
1699
2039
  if (!sourceColumns?.length || !targetColumns?.length || through?.source.length !== sourceColumns.length || through.target.length !== targetColumns.length) throw drizzleError(`WHERE ${relationName}: Relation cannot be used as a filter`, { code: "DRIZZLE_INVALID_FILTER" });
1700
2040
  const parentColumns = Object.values((0, drizzle_orm.getColumns)(parentTable));
@@ -1746,7 +2086,8 @@ const buildRelationExists = (parentTable, relationName, relEntry, innerFilters,
1746
2086
  fromClause = (0, drizzle_orm.getTableAsAliasSQL)(aliasedTarget);
1747
2087
  joinCondition = buildRelationJoinCondition(parentTable, relation, aliasedTarget, relationName);
1748
2088
  }
1749
- const relationWhere = relation.where ? (0, drizzle_orm.relationsFilterToSQL)(relation.isReversed ? parentTable : aliasedTarget, relation.where) : void 0;
2089
+ const ownWhere = relationOwnWhere(relation);
2090
+ const relationWhere = ownWhere ? (0, drizzle_orm.relationsFilterToSQL)(isRelationFilterReversed(relation) ? parentTable : aliasedTarget, ownWhere) : void 0;
1750
2091
  const inner = innerFilters ? extractFilters(aliasedTarget, targetTableName, innerFilters, {
1751
2092
  ...ctx,
1752
2093
  tableKey: targetTableName,
@@ -1800,7 +2141,8 @@ const extractFilters = (table, tableName, filters, relationCtx) => {
1800
2141
  }
1801
2142
  if (NOT) {
1802
2143
  const extracted = extractFilters(table, tableName, NOT, relationCtx);
1803
- if (extracted) variants.push((0, drizzle_orm.not)(extracted));
2144
+ const negated = extracted ? (0, drizzle_orm.not)(extracted) : void 0;
2145
+ if (negated) variants.push(negated);
1804
2146
  }
1805
2147
  if (OR?.length) {
1806
2148
  const orVariants = [];
@@ -2197,88 +2539,6 @@ const excludedColumnRef = (columnName) => drizzle_orm.sql`excluded.${drizzle_orm
2197
2539
  /** `values(<column>)` — MySQL's equivalent inside ON DUPLICATE KEY UPDATE. */
2198
2540
  const mysqlValuesColumnRef = (columnName) => drizzle_orm.sql`values(${drizzle_orm.sql.identifier(columnName)})`;
2199
2541
  //#endregion
2200
- //#region src/util/builders/common/relations.ts
2201
- /**
2202
- * Flatten drizzle-orm v1 TablesRelationalConfig into the canonical
2203
- * Record<tableName, Record<relName, TableNamedRelations>> shape used
2204
- * throughout common.ts. Both pg.ts and sqlite.ts call this before
2205
- * passing the relation map to any shared function.
2206
- */
2207
- const buildNamedRelations = (relations, tableEntries) => {
2208
- const namedRelations = {};
2209
- for (const [relTableName, relConfig] of Object.entries(relations)) {
2210
- if (!relConfig?.relations) continue;
2211
- const namedConfig = {};
2212
- for (const [innerRelName, innerRelValue] of Object.entries(relConfig.relations)) {
2213
- const targetTable = innerRelValue.targetTable ?? innerRelValue.referencedTable;
2214
- const directTargetName = innerRelValue.targetTableName;
2215
- let targetTableName;
2216
- if (directTargetName) targetTableName = tableEntries.find(([key]) => key === directTargetName)?.[0];
2217
- else if (targetTable) targetTableName = tableEntries.find(([, tableValue]) => tableValue === targetTable)?.[0];
2218
- if (!targetTableName) continue;
2219
- namedConfig[innerRelName] = {
2220
- relation: innerRelValue,
2221
- targetTableName
2222
- };
2223
- }
2224
- if (Object.keys(namedConfig).length > 0) namedRelations[relTableName] = namedConfig;
2225
- }
2226
- return namedRelations;
2227
- };
2228
- /**
2229
- * Records each relation's target-table primary-key property names on the relation entry,
2230
- * so the pagination paths (the window-function batch loader and the eager `with:` orderBy
2231
- * default) can fall back to a deterministic PK order without re-deriving it per request.
2232
- *
2233
- * Composite primary keys are only visible through the dialect's getTableConfig, so the
2234
- * dialect builder passes a `resolvePkNames` that threads the composite column names in.
2235
- * Mutates the relation entries in place (they are shared with the pruned eager map and
2236
- * the resolver factory, so attaching once covers every consumer).
2237
- */
2238
- const attachTargetPrimaryKeys = (namedRelations, tables, resolvePkNames) => {
2239
- const cache = /* @__PURE__ */ new Map();
2240
- for (const rels of Object.values(namedRelations)) for (const relEntry of Object.values(rels)) {
2241
- const { targetTableName } = relEntry;
2242
- let pk = cache.get(targetTableName);
2243
- if (!pk) {
2244
- const targetTable = tables[targetTableName];
2245
- pk = targetTable ? resolvePkNames(targetTable) : [];
2246
- cache.set(targetTableName, pk);
2247
- }
2248
- relEntry.targetPkNames = pk;
2249
- }
2250
- };
2251
- /**
2252
- * Extracts the join column info from a drizzle-orm v1 Relation object.
2253
- * Returns the JS property name of the local column on the parent table and the
2254
- * Column object for the foreign column on the target table, or undefined if the
2255
- * relation internals are not accessible.
2256
- */
2257
- const extractRelationJoinColumns = (relEntry, parentTable, targetTable) => {
2258
- const rel = relEntry.relation ?? relEntry;
2259
- const sourceColumns = rel.sourceColumns;
2260
- const targetColumns = rel.targetColumns;
2261
- if (!sourceColumns?.length || !targetColumns?.length) return;
2262
- const sourceCol = sourceColumns[0];
2263
- const targetCol = targetColumns[0];
2264
- const parentCols = (0, drizzle_orm.getColumns)(parentTable);
2265
- const localColPropName = Object.entries(parentCols).find(([, c]) => c === sourceCol)?.[0];
2266
- const targetCols = (0, drizzle_orm.getColumns)(targetTable);
2267
- const foreignColPropName = Object.entries(targetCols).find(([, c]) => c === targetCol)?.[0];
2268
- if (!localColPropName || !foreignColPropName) return;
2269
- return {
2270
- localColPropName,
2271
- foreignCol: targetCol,
2272
- foreignColPropName
2273
- };
2274
- };
2275
- /**
2276
- * Whether a to-one relation can back a relation orderBy hop. `.through()` (junction)
2277
- * relations are excluded — the ordering subquery only joins the direct target, never the
2278
- * junction table. (Relation *filters* do support `.through()` — this guard is orderBy-only.)
2279
- */
2280
- const isFilterableRelation = (relation) => !relation.through;
2281
- //#endregion
2282
2542
  //#region src/util/builders/common/order-by.ts
2283
2543
  /**
2284
2544
  * Column name / direction / nulls triples from an `orderBy` argument, highest priority
@@ -2324,7 +2584,8 @@ const extendRelationOrderChain = (parentTable, relationName, relEntry, ctx, chai
2324
2584
  ctx.aliases ??= { n: 0 };
2325
2585
  const aliasedTarget = (0, drizzle_orm.aliasedTable)(targetTable, `dgql_ord_${ctx.aliases.n++}`);
2326
2586
  const joinCondition = buildRelationJoinCondition(parentTable, relation, aliasedTarget, relationName);
2327
- const relationWhere = relation.where ? (0, drizzle_orm.relationsFilterToSQL)(relation.isReversed ? parentTable : aliasedTarget, relation.where) : void 0;
2587
+ const ownWhere = relationOwnWhere(relation);
2588
+ const relationWhere = ownWhere ? (0, drizzle_orm.relationsFilterToSQL)(isRelationFilterReversed(relation) ? parentTable : aliasedTarget, ownWhere) : void 0;
2328
2589
  return {
2329
2590
  fromParts: [...chain?.fromParts ?? [], (0, drizzle_orm.getTableAsAliasSQL)(aliasedTarget)],
2330
2591
  conditions: [
@@ -2341,15 +2602,17 @@ const extendRelationOrderChain = (parentTable, relationName, relEntry, ctx, chai
2341
2602
  *
2342
2603
  * A ladder rather than a dialect array function (`array_position` on Postgres, `FIELD()` on
2343
2604
  * MySQL, neither on SQLite) because it is the one form all three share, and because every
2344
- * list value stays a bound parameter. The branch indices are library-generated integers, so
2345
- * they are written into the SQL text — a bound parameter there would leave Postgres unable
2346
- * to infer the CASE result type. Values outside the list sort after every listed one.
2605
+ * list value stays a bound parameter — encoded by the column, as `eq` would, so a value whose
2606
+ * driver form differs from its JS one (a SQLite integer timestamp's `Date`) is bound as stored.
2607
+ * The branch indices are library-generated integers, so they are written into the SQL text — a
2608
+ * bound parameter there would leave Postgres unable to infer the CASE result type. Values
2609
+ * outside the list sort after every listed one.
2347
2610
  */
2348
2611
  const filterOrderExpression = (column, columnName, whereArgs) => {
2349
2612
  const values = whereArgs?.[columnName]?.inArray;
2350
2613
  if (!Array.isArray(values)) throw drizzleError(`ORDER BY ${columnName}: 'matchFilterOrder' needs an 'inArray' filter on the same column in this query's 'where'`, { code: "DRIZZLE_INVALID_ORDER_BY" });
2351
2614
  if (!values.length) return drizzle_orm.sql`0`;
2352
- const branches = values.map((value, index) => drizzle_orm.sql`when ${column} = ${remapFromGraphQLCore(value, column, columnName)} then ${drizzle_orm.sql.raw(String(index))}`);
2615
+ const branches = values.map((value, index) => drizzle_orm.sql`when ${column} = ${drizzle_orm.sql.param(remapFromGraphQLCore(value, column, columnName), column)} then ${drizzle_orm.sql.raw(String(index))}`);
2353
2616
  return drizzle_orm.sql`case ${drizzle_orm.sql.join(branches, drizzle_orm.sql` `)} else ${drizzle_orm.sql.raw(String(values.length))} end`;
2354
2617
  };
2355
2618
  /**
@@ -3315,6 +3578,26 @@ const remapUpdateInput = (values, table, tableName) => {
3315
3578
  } : remapped;
3316
3579
  };
3317
3580
  //#endregion
3581
+ //#region src/util/builders/common/rqb-config.ts
3582
+ /**
3583
+ * A copy of a relational query config with every key whose value is `undefined` left out.
3584
+ *
3585
+ * drizzle-orm rc.5 snapshots test `"where" in params` rather than its value, and throw
3586
+ * `Unexpected 'undefined' in filter value` for `{ where: undefined }`; rc.4 read the value and
3587
+ * treated both the same. The library builds its configs with conditional keys
3588
+ * (`where: cond ? … : undefined`), so every config goes through here before it reaches drizzle.
3589
+ * Only `where` is checked that way today, but leaving out every undefined key costs nothing
3590
+ * and keeps the next key that gets the same treatment from breaking queries.
3591
+ *
3592
+ * Shallow on purpose: nested `with:` entries are built (and passed through here) one level at
3593
+ * a time, and the values of the other keys are drizzle's own (column maps, callbacks, RAW).
3594
+ */
3595
+ const omitUndefinedKeys = (config) => {
3596
+ const out = {};
3597
+ for (const [key, value] of Object.entries(config)) if (value !== void 0) out[key] = value;
3598
+ return out;
3599
+ };
3600
+ //#endregion
3318
3601
  //#region src/util/builders/common/selected-columns.ts
3319
3602
  const rqbCrashTypes = [
3320
3603
  "SQLiteBigInt",
@@ -3443,7 +3726,7 @@ const extractRelationsParams = (relationMap, tables, tableName, info, typeName,
3443
3726
  thisRecord.offset = offset;
3444
3727
  thisRecord.limit = limit;
3445
3728
  thisRecord.with = extractRelationsParams(relationMap, tables, targetTableName, field, relTypeName, options);
3446
- args[relName] = thisRecord;
3729
+ args[relName] = omitUndefinedKeys(thisRecord);
3447
3730
  }
3448
3731
  return args;
3449
3732
  };
@@ -3520,12 +3803,12 @@ const runRelationalSelect = async (opts) => {
3520
3803
  }) : void 0
3521
3804
  };
3522
3805
  if (single) {
3523
- const result = await queryBase.findFirst(params);
3806
+ const result = await queryBase.findFirst(omitUndefinedKeys(params));
3524
3807
  return result ? remapToGraphQLSingleOutput(result, tableName, table, relationMap) : void 0;
3525
3808
  }
3526
3809
  if (cursorEntries && cursorSelected) for (const [column] of cursorEntries) params.columns[column] = true;
3527
3810
  params.limit = distinctKeys ? void 0 : opts.limit;
3528
- const result = await queryBase.findMany(params);
3811
+ const result = await queryBase.findMany(omitUndefinedKeys(params));
3529
3812
  if (cursorEntries && cursorSelected) attachRowCursors(result, cursorEntries);
3530
3813
  return remapToGraphQLArrayOutput(result, tableName, table, relationMap);
3531
3814
  };
@@ -5251,7 +5534,6 @@ const resolveBuildConfig = (config, schema) => {
5251
5534
  const missing = [!tableFeatures.insert ? "insert" : void 0, !tableFeatures.update ? "update" : void 0].filter(Boolean).join(" and ");
5252
5535
  if (missing) noteConflict(`upsert is on while ${missing} is off, so the upsert mutations are a second write path past the operation you turned off`, tableName);
5253
5536
  }
5254
- if (config?.features?.nestedWrites && (!tableFeatures.insert || !tableFeatures.update)) noteConflict("nestedWrites is on while this table's own insert or update is off, so a nested `create` or `connect` under another table's mutation can still write it", tableName);
5255
5537
  if (config?.features?.updateMany !== void 0 && tableFeatures.updateMany && !tableFeatures.update) noteConflict("updateMany is on while update is off, so no batch update is generated", tableName);
5256
5538
  if (config?.features?.groupBy !== void 0 && tableFeatures.groupBy && !tableFeatures.aggregates) noteConflict("groupBy is on while aggregates is off, so no grouped query is generated", tableName);
5257
5539
  if (tableFeatures.countMutations && !tableFeatures.update && !tableFeatures.delete) noteConflict("countMutations is on while update and delete are both off, so no count mutation is generated", tableName);
@@ -5564,13 +5846,21 @@ const GROUP_KEY = "__dgql_group_key";
5564
5846
  * for every other aggregate.
5565
5847
  */
5566
5848
  const createRelationAggregateFactory = (db, tables, cacheCtx, typeNameMapper, filterCtx, policies) => {
5849
+ const warned = /* @__PURE__ */ new Set();
5567
5850
  return ({ tableName, relationName, relEntry }) => {
5568
5851
  const parentTable = tables[tableName];
5569
5852
  const targetTableName = relEntry.targetTableName;
5570
5853
  const targetTable = tables[targetTableName];
5571
5854
  if (!parentTable || !targetTable) return;
5572
5855
  const joinCols = extractRelationJoinColumns(relEntry, parentTable, targetTable);
5573
- if (!joinCols) return;
5856
+ if (!joinCols) {
5857
+ const key = `${tableName}.${relationName}`;
5858
+ if (!warned.has(key)) {
5859
+ warned.add(key);
5860
+ warnUnresolvedJoin(relEntry, tableName, relationName, `its '${relationName}Aggregate' field`);
5861
+ }
5862
+ return;
5863
+ }
5574
5864
  const { localColPropName, foreignCol } = joinCols;
5575
5865
  const defaultDeleted = relationDeletedDefault(policies?.softDelete, targetTableName, false);
5576
5866
  const targetTypeName = resolveTypeName(targetTableName, typeNameMapper);
@@ -5857,6 +6147,12 @@ const generateGroupBy = (db, tableName, table, typeName, fieldName, filterArgs,
5857
6147
  };
5858
6148
  //#endregion
5859
6149
  //#region src/util/builders/nested-writes.ts
6150
+ const ALL_OPS = [
6151
+ "create",
6152
+ "connect",
6153
+ "disconnect",
6154
+ "set"
6155
+ ];
5860
6156
  /**
5861
6157
  * Whether a column can be left out of an INSERT — either the database fills it in or it
5862
6158
  * accepts NULL.
@@ -5868,12 +6164,6 @@ const propNameOf = (table, column) => {
5868
6164
  for (const [propName, candidate] of Object.entries((0, drizzle_orm.getColumns)(table))) if (candidate === column) return propName;
5869
6165
  };
5870
6166
  /**
5871
- * The junction column one entry of a relation's `through` config points at. Drizzle wraps it
5872
- * in a descriptor while the relation is being defined; the raw column is accepted too, so a
5873
- * change of shape there degrades to skipping the relation rather than mis-planning it.
5874
- */
5875
- const throughColumn = (entry) => entry && (0, drizzle_orm.is)(entry, drizzle_orm.Column) ? entry : entry?._?.column;
5876
- /**
5877
6167
  * A `.through()` relation's plan: the junction table plus the two columns it joins on.
5878
6168
  *
5879
6169
  * Nothing about the parent or the target row changes when a many-to-many link is made or
@@ -5884,21 +6174,27 @@ const throughColumn = (entry) => entry && (0, drizzle_orm.is)(entry, drizzle_orm
5884
6174
  * row" guarantee lives in the junction's constraints rather than in the relation; a relation
5885
6175
  * joining on more than one column per side; and a junction carrying a further NOT NULL column
5886
6176
  * with no default, which no `connect` operand could supply a value for.
6177
+ *
6178
+ * `onUnresolved` is called when the relation's columns are of a supported shape but cannot be
6179
+ * matched to the three tables — a drizzle-orm mismatch rather than an unsupported relation.
5887
6180
  */
5888
- const buildThroughPlan = (relationName, relation, table, targetTableName, targetTable) => {
6181
+ const buildThroughPlan = (relationName, relation, table, targetTableName, targetTable, tableNameOf, onUnresolved) => {
5889
6182
  if ((0, drizzle_orm.is)(relation, drizzle_orm.One)) return;
5890
6183
  const junction = relation.throughTable;
5891
6184
  const sourceEntries = relation.through?.source;
5892
6185
  const targetEntries = relation.through?.target;
5893
6186
  if (!junction || sourceEntries?.length !== 1 || targetEntries?.length !== 1) return;
5894
6187
  if (relation.sourceColumns?.length !== 1 || relation.targetColumns?.length !== 1) return;
5895
- const localCol = relation.sourceColumns[0];
5896
- const foreignCol = relation.targetColumns[0];
6188
+ const localCol = unwrapRelationColumn(relation.sourceColumns[0]);
6189
+ const foreignCol = unwrapRelationColumn(relation.targetColumns[0]);
5897
6190
  const localColPropName = propNameOf(table, localCol);
5898
6191
  const foreignColPropName = propNameOf(targetTable, foreignCol);
5899
- const sourceColPropName = propNameOf(junction, throughColumn(sourceEntries[0]));
5900
- const targetColPropName = propNameOf(junction, throughColumn(targetEntries[0]));
5901
- if (!localColPropName || !foreignColPropName || !sourceColPropName || !targetColPropName) return;
6192
+ const sourceColPropName = propNameOf(junction, unwrapRelationColumn(sourceEntries[0]));
6193
+ const targetColPropName = propNameOf(junction, unwrapRelationColumn(targetEntries[0]));
6194
+ if (!localCol || !foreignCol || !localColPropName || !foreignColPropName || !sourceColPropName || !targetColPropName) {
6195
+ onUnresolved();
6196
+ return;
6197
+ }
5902
6198
  const junctionColumns = (0, drizzle_orm.getColumns)(junction);
5903
6199
  const sourceCol = junctionColumns[sourceColPropName];
5904
6200
  const targetCol = junctionColumns[targetColPropName];
@@ -5916,11 +6212,18 @@ const buildThroughPlan = (relationName, relation, table, targetTableName, target
5916
6212
  canDetach: true,
5917
6213
  through: {
5918
6214
  table: junction,
6215
+ tableName: tableNameOf(junction),
5919
6216
  sourceColPropName,
5920
6217
  targetColPropName,
5921
6218
  sourceCol,
5922
6219
  targetCol
5923
- }
6220
+ },
6221
+ targetKeyColumns: { [foreignColPropName]: foreignCol },
6222
+ allowed: /* @__PURE__ */ new Set([
6223
+ "connect",
6224
+ "disconnect",
6225
+ "set"
6226
+ ])
5924
6227
  };
5925
6228
  };
5926
6229
  /**
@@ -5933,7 +6236,7 @@ const buildThroughPlan = (relationName, relation, table, targetTableName, target
5933
6236
  * relations whose join columns cannot be read off the drizzle relation; and relations where
5934
6237
  * neither join column is unique, which leaves no way to tell which side stores the key.
5935
6238
  */
5936
- const buildNestedWritePlans = (tables, relationMap, resolveUniqueSets, resolvePrimaryKeys, extractJoinColumns) => {
6239
+ const buildNestedWritePlans = (tables, relationMap, resolveUniqueSets, resolvePrimaryKeys, extractJoinColumns, tableNameOf = () => void 0) => {
5937
6240
  const uniqueCache = /* @__PURE__ */ new Map();
5938
6241
  const singleColumnUniques = (tableName, table) => {
5939
6242
  let cached = uniqueCache.get(tableName);
@@ -5961,13 +6264,17 @@ const buildNestedWritePlans = (tables, relationMap, resolveUniqueSets, resolvePr
5961
6264
  const targetTableName = relEntry.targetTableName;
5962
6265
  const targetTable = tables[targetTableName];
5963
6266
  if (!targetTable) continue;
6267
+ const omitted = `the '${relationName}' field of its nested-write create and update inputs`;
5964
6268
  if (relation.through) {
5965
- const throughPlan = buildThroughPlan(relationName, relation, table, targetTableName, targetTable);
6269
+ const throughPlan = buildThroughPlan(relationName, relation, table, targetTableName, targetTable, tableNameOf, () => warnUnresolvedJoin(relEntry, tableName, relationName, omitted));
5966
6270
  if (throughPlan) tablePlans[relationName] = throughPlan;
5967
6271
  continue;
5968
6272
  }
5969
6273
  const joinColumns = extractJoinColumns(relEntry, table, targetTable);
5970
- if (!joinColumns) continue;
6274
+ if (!joinColumns) {
6275
+ warnUnresolvedJoin(relEntry, tableName, relationName, omitted);
6276
+ continue;
6277
+ }
5971
6278
  const { localColPropName, foreignCol, foreignColPropName } = joinColumns;
5972
6279
  const localCol = (0, drizzle_orm.getColumns)(table)[localColPropName];
5973
6280
  if (!localCol) continue;
@@ -5983,6 +6290,10 @@ const buildNestedWritePlans = (tables, relationMap, resolveUniqueSets, resolvePr
5983
6290
  else if (foreignIsUnique) fkSide = "parent";
5984
6291
  else if (localIsUnique) fkSide = "child";
5985
6292
  else continue;
6293
+ const fkColumn = fkSide === "child" ? foreignCol : localCol;
6294
+ const targetColumns = (0, drizzle_orm.getColumns)(targetTable);
6295
+ const targetKeyColumns = Object.fromEntries(resolvePrimaryKeys(targetTable).filter((name) => targetColumns[name]).map((name) => [name, targetColumns[name]]));
6296
+ targetKeyColumns[foreignColPropName] = foreignCol;
5986
6297
  tablePlans[relationName] = {
5987
6298
  relationName,
5988
6299
  targetTableName,
@@ -5993,13 +6304,87 @@ const buildNestedWritePlans = (tables, relationMap, resolveUniqueSets, resolvePr
5993
6304
  localCol,
5994
6305
  isOne,
5995
6306
  fkSide,
5996
- canDetach: !(fkSide === "child" ? foreignCol : localCol).notNull
6307
+ canDetach: !fkColumn.notNull,
6308
+ targetKeyColumns,
6309
+ allowed: new Set(!fkColumn.notNull ? isOne ? [
6310
+ "create",
6311
+ "connect",
6312
+ "disconnect"
6313
+ ] : ALL_OPS : ["create", "connect"])
5997
6314
  };
5998
6315
  }
5999
6316
  if (Object.keys(tablePlans).length) plans[tableName] = tablePlans;
6000
6317
  }
6001
6318
  return plans;
6002
6319
  };
6320
+ /**
6321
+ * Which of the target's (or junction's) write features each input of a relation needs. A
6322
+ * parent-side `connect` / `disconnect` only rewrites the parent's own column, so it needs
6323
+ * nothing of the target; a child-side one moves the target's foreign key, which is an update
6324
+ * of the target. A to-one child-side `create` also detaches the row attached before it, when
6325
+ * the key is nullable.
6326
+ */
6327
+ const requiredWrites = (plan, op) => {
6328
+ if (plan.through) {
6329
+ const junction = plan.through.tableName;
6330
+ if (op === "connect") return [{
6331
+ tableName: junction,
6332
+ feature: "insert"
6333
+ }];
6334
+ if (op === "disconnect") return [{
6335
+ tableName: junction,
6336
+ feature: "delete"
6337
+ }];
6338
+ if (op === "set") return [{
6339
+ tableName: junction,
6340
+ feature: "delete"
6341
+ }, {
6342
+ tableName: junction,
6343
+ feature: "insert"
6344
+ }];
6345
+ return [];
6346
+ }
6347
+ const target = plan.targetTableName;
6348
+ if (op === "create") return plan.fkSide === "child" && plan.isOne && plan.canDetach ? [{
6349
+ tableName: target,
6350
+ feature: "insert"
6351
+ }, {
6352
+ tableName: target,
6353
+ feature: "update"
6354
+ }] : [{
6355
+ tableName: target,
6356
+ feature: "insert"
6357
+ }];
6358
+ return plan.fkSide === "child" ? [{
6359
+ tableName: target,
6360
+ feature: "update"
6361
+ }] : [];
6362
+ };
6363
+ /**
6364
+ * Narrows every plan's `allowed` inputs to the ones whose writes the table they land on
6365
+ * permits: its `features.insert` / `update` / `delete`, and for a junction table that it is
6366
+ * not excluded. A plan left with nothing is dropped, so its relation field is not generated.
6367
+ *
6368
+ * Returns one line per input taken out, for the build note.
6369
+ */
6370
+ const gateNestedWritePlans = (plans, featureOf, isExcluded) => {
6371
+ const omitted = [];
6372
+ for (const [tableName, tablePlans] of Object.entries(plans)) {
6373
+ for (const [relationName, plan] of Object.entries(tablePlans)) {
6374
+ for (const op of [...plan.allowed]) {
6375
+ const blocked = requiredWrites(plan, op).filter(({ tableName: target, feature }) => target !== void 0 && (isExcluded(target) || !featureOf(target)[feature]));
6376
+ if (blocked.length) {
6377
+ plan.allowed.delete(op);
6378
+ const reasons = blocked.map(({ tableName: target, feature }) => isExcluded(target) ? `${target} is excluded` : `${target}.${feature} is off`);
6379
+ omitted.push(`${tableName}.${relationName}.${op} (${[...new Set(reasons)].join(", ")})`);
6380
+ }
6381
+ }
6382
+ if (!plan.allowed.size) delete tablePlans[relationName];
6383
+ }
6384
+ if (!Object.keys(tablePlans).length) delete plans[tableName];
6385
+ }
6386
+ return omitted;
6387
+ };
6003
6388
  const NO_COLUMNS = /* @__PURE__ */ new Set();
6004
6389
  /**
6005
6390
  * Builds the nested-write input types for a schema.
@@ -6054,8 +6439,11 @@ const createNestedWriteTypes = (params) => {
6054
6439
  *
6055
6440
  * A many-to-many relation offers no `create`: inserting a row *and* linking it is two
6056
6441
  * writes to two tables, and the link half is what the relation field is for. Its
6057
- * `disconnect` and `set` are offered unconditionally, since breaking a link deletes a
6442
+ * `disconnect` and `set` do not depend on nullability, since breaking a link deletes a
6058
6443
  * junction row rather than nulling a column that might be NOT NULL.
6444
+ *
6445
+ * Each input is also subject to `plan.allowed` — the table it writes has to have that
6446
+ * write turned on (see {@link gateNestedWritePlans}).
6059
6447
  */
6060
6448
  const wrapperType = (tableName, typeName, plan, forUpdate) => {
6061
6449
  const key = `${tableName}.${plan.relationName}.${forUpdate ? "update" : "create"}`;
@@ -6064,57 +6452,52 @@ const createNestedWriteTypes = (params) => {
6064
6452
  const filters = targetFilters(plan);
6065
6453
  const targetTypeName = resolveTypeName(plan.targetTableName, typeNameMapper);
6066
6454
  const fields = {};
6455
+ const offers = (op) => plan.allowed.has(op);
6067
6456
  if (plan.through) {
6068
- if (!filters) return;
6069
- fields["connect"] = {
6457
+ if (filters && offers("connect")) fields["connect"] = {
6070
6458
  type: listOf(filters),
6071
6459
  description: `Link every existing ${targetTypeName} row these match. Already-linked rows are left alone.`
6072
6460
  };
6073
- if (forUpdate) {
6074
- fields["disconnect"] = {
6075
- type: listOf(filters),
6076
- description: `Unlink every linked ${targetTypeName} row these match, leaving the rows in place`
6077
- };
6078
- fields["set"] = {
6079
- type: listOf(filters),
6080
- description: `Replace the whole set: unlink everything linked, then link every ${targetTypeName} row these match. \`set: []\` clears the relation. Applied before \`disconnect\` and \`connect\`.`
6081
- };
6082
- }
6461
+ if (filters && forUpdate && offers("disconnect")) fields["disconnect"] = {
6462
+ type: listOf(filters),
6463
+ description: `Unlink every linked ${targetTypeName} row these match, leaving the rows in place`
6464
+ };
6465
+ if (filters && forUpdate && offers("set")) fields["set"] = {
6466
+ type: listOf(filters),
6467
+ description: `Replace the whole set: unlink everything linked, then link every ${targetTypeName} row these match. \`set: []\` clears the relation. Applied before \`disconnect\` and \`connect\`.`
6468
+ };
6083
6469
  } else if (plan.isOne) {
6084
- fields["create"] = {
6470
+ if (offers("create")) fields["create"] = {
6085
6471
  type: payloadType(tableName, typeName, plan),
6086
6472
  description: `Insert a ${targetTypeName} row and attach it`
6087
6473
  };
6088
- if (filters) fields["connect"] = {
6474
+ if (filters && offers("connect")) fields["connect"] = {
6089
6475
  type: filters,
6090
6476
  description: `Attach the existing ${targetTypeName} row this matches. Must match exactly one row.`
6091
6477
  };
6092
- if (forUpdate && plan.canDetach) fields["disconnect"] = {
6478
+ if (forUpdate && offers("disconnect")) fields["disconnect"] = {
6093
6479
  type: graphql.GraphQLBoolean,
6094
6480
  description: `Detach the currently attached ${targetTypeName} row, leaving it in place`
6095
6481
  };
6096
6482
  } else {
6097
- fields["create"] = {
6483
+ if (offers("create")) fields["create"] = {
6098
6484
  type: listOf(payloadType(tableName, typeName, plan)),
6099
6485
  description: `Insert these ${targetTypeName} rows and attach them`
6100
6486
  };
6101
- if (filters) {
6102
- fields["connect"] = {
6103
- type: listOf(filters),
6104
- description: `Attach every existing ${targetTypeName} row these match`
6105
- };
6106
- if (forUpdate && plan.canDetach) {
6107
- fields["disconnect"] = {
6108
- type: listOf(filters),
6109
- description: `Detach every attached ${targetTypeName} row these match, leaving the rows in place`
6110
- };
6111
- fields["set"] = {
6112
- type: listOf(filters),
6113
- description: `Replace the whole set: detach everything attached, then attach every ${targetTypeName} row these match. Applied before \`disconnect\`, \`connect\` and \`create\`.`
6114
- };
6115
- }
6116
- }
6487
+ if (filters && offers("connect")) fields["connect"] = {
6488
+ type: listOf(filters),
6489
+ description: `Attach every existing ${targetTypeName} row these match`
6490
+ };
6491
+ if (filters && forUpdate && offers("disconnect")) fields["disconnect"] = {
6492
+ type: listOf(filters),
6493
+ description: `Detach every attached ${targetTypeName} row these match, leaving the rows in place`
6494
+ };
6495
+ if (filters && forUpdate && offers("set")) fields["set"] = {
6496
+ type: listOf(filters),
6497
+ description: `Replace the whole set: detach everything attached, then attach every ${targetTypeName} row these match. Applied before \`disconnect\`, \`connect\` and \`create\`.`
6498
+ };
6117
6499
  }
6500
+ if (!Object.keys(fields).length) return;
6118
6501
  const type = new graphql.GraphQLInputObjectType({
6119
6502
  name: cacheCtx.typeName({
6120
6503
  kind: "nestedWriteInput",
@@ -6146,7 +6529,7 @@ const createNestedWriteTypes = (params) => {
6146
6529
  const tablePlans = plans[tableName];
6147
6530
  if (!tablePlans) return NO_COLUMNS;
6148
6531
  const relaxed = /* @__PURE__ */ new Set();
6149
- for (const plan of Object.values(tablePlans)) if (plan.fkSide === "parent" && !isOptionalOnInsert(plan.localCol)) relaxed.add(plan.localColPropName);
6532
+ for (const plan of Object.values(tablePlans)) if (plan.fkSide === "parent" && (plan.allowed.has("create") || plan.allowed.has("connect")) && !isOptionalOnInsert(plan.localCol)) relaxed.add(plan.localColPropName);
6150
6533
  return relaxed.size ? relaxed : NO_COLUMNS;
6151
6534
  }
6152
6535
  };
@@ -6157,8 +6540,99 @@ const createNestedWriteTypes = (params) => {
6157
6540
  */
6158
6541
  const mergedOps = (entries) => Object.assign({}, ...entries.map((entry) => entry.ops));
6159
6542
  const asArray = (value) => Array.isArray(value) ? value : value === void 0 ? [] : [value];
6543
+ const supplied = (value) => value !== void 0 && value !== null;
6544
+ /**
6545
+ * The hook calls one relation field makes. Every step is registered before the field's first
6546
+ * statement, so each `before` runs ahead of all of them and each `after` once the last has
6547
+ * returned. A table without hooks registers nothing, and its statements run exactly as they
6548
+ * did before hooks reached nested writes — no RETURNING clause is added.
6549
+ */
6550
+ const createHookSteps = (onWrite, executor, request, parentTable, plan) => {
6551
+ const steps = /* @__PURE__ */ new Map();
6552
+ const keyOf = (op, operation) => `${op}:${operation}`;
6553
+ const payload = (step) => ({
6554
+ table: step.tableName,
6555
+ operation: step.operation,
6556
+ single: plan.isOne,
6557
+ args: request?.args,
6558
+ context: request?.context,
6559
+ info: request?.info,
6560
+ tx: executor,
6561
+ nested: {
6562
+ parentTable,
6563
+ relation: plan.relationName,
6564
+ op: step.op
6565
+ }
6566
+ });
6567
+ return {
6568
+ /** Registers the hook call for `op`'s `operation` statements, if that table has hooks. */
6569
+ add: (op, operation) => {
6570
+ const through = plan.through;
6571
+ const tableName = through ? through.tableName : plan.targetTableName;
6572
+ const hooks = tableName ? onWrite?.(tableName, operation) : void 0;
6573
+ if (!tableName || !hooks || steps.has(keyOf(op, operation))) return;
6574
+ const keyColumns = through ? {
6575
+ [through.sourceColPropName]: through.sourceCol,
6576
+ [through.targetColPropName]: through.targetCol
6577
+ } : { ...plan.targetKeyColumns };
6578
+ steps.set(keyOf(op, operation), {
6579
+ tableName,
6580
+ operation,
6581
+ op,
6582
+ hooks,
6583
+ returning: withHookColumns(hooks, keyColumns).returning,
6584
+ rows: []
6585
+ });
6586
+ },
6587
+ get: (op, operation) => steps.get(keyOf(op, operation)),
6588
+ /** Runs one statement, returning its rows to the step it belongs to when there is one. */
6589
+ run: async (query, op, operation) => {
6590
+ const step = steps.get(keyOf(op, operation));
6591
+ if (!step) {
6592
+ await query;
6593
+ return;
6594
+ }
6595
+ step.rows.push(...await query.returning(step.returning));
6596
+ },
6597
+ before: async () => {
6598
+ for (const step of steps.values()) await runWriteHook(step.hooks, "before", payload(step));
6599
+ },
6600
+ after: async () => {
6601
+ for (const step of steps.values()) await runWriteHook(step.hooks, "after", {
6602
+ ...payload(step),
6603
+ rows: step.rows
6604
+ });
6605
+ }
6606
+ };
6607
+ };
6608
+ /**
6609
+ * Registers the hook calls a child-side relation field will make, in the order its
6610
+ * statements run, so the `before` hooks fire in that order too. Mirrors the branches of
6611
+ * `applyChildSide` and `applyThroughOps` exactly: a step registered for an input that runs no
6612
+ * statement would fire with no rows, and one missing would miss them.
6613
+ */
6614
+ const registerChildSteps = (steps, plan, op) => {
6615
+ if (plan.through) {
6616
+ if (supplied(op["set"])) {
6617
+ steps.add("set", "delete");
6618
+ steps.add("set", "insert");
6619
+ }
6620
+ if (supplied(op["disconnect"])) steps.add("disconnect", "delete");
6621
+ if (supplied(op["connect"])) steps.add("connect", "insert");
6622
+ return;
6623
+ }
6624
+ if (op["set"] !== void 0) steps.add("set", "update");
6625
+ if (op["disconnect"] !== void 0 && (!plan.isOne || op["disconnect"] === true)) steps.add("disconnect", "update");
6626
+ const attachedBy = attachingOp(op);
6627
+ if (plan.isOne && attachedBy && plan.canDetach && op["set"] === void 0) steps.add(attachedBy, "update");
6628
+ if (op["connect"] !== void 0) steps.add("connect", "update");
6629
+ if (op["create"] !== void 0) steps.add("create", "insert");
6630
+ };
6631
+ /** The input that attaches a row to a to-one, which detaches whatever was attached before. */
6632
+ const attachingOp = (op) => op["connect"] !== void 0 ? "connect" : op["create"] !== void 0 ? "create" : void 0;
6160
6633
  const createNestedWriteRuntime = (params) => {
6161
6634
  const { plans, filterCtx, policies, contextValues } = params;
6635
+ const onWrite = policies?.onWrite;
6162
6636
  /**
6163
6637
  * A `connect` / `disconnect` / `set` operand compiled to SQL. An operand that compiles to
6164
6638
  * nothing is rejected: `connect: {}` would otherwise attach every row in the table.
@@ -6169,12 +6643,12 @@ const createNestedWriteRuntime = (params) => {
6169
6643
  return withScope(scope, plan.targetTableName, plan.targetTable, condition);
6170
6644
  };
6171
6645
  const assertSingleOperation = (plan, op) => {
6172
- const supplied = [
6646
+ const given = [
6173
6647
  "create",
6174
6648
  "connect",
6175
6649
  "disconnect"
6176
- ].filter((key) => op[key] !== void 0 && op[key] !== null);
6177
- if (supplied.length > 1) throw drizzleError(`Drizzle-GraphQL Error: '${plan.relationName}' takes one of ${supplied.join(", ")} at a time — it holds a single row.`, { code: "DRIZZLE_NESTED_WRITE_INVALID" });
6650
+ ].filter((key) => supplied(op[key]));
6651
+ if (given.length > 1) throw drizzleError(`Drizzle-GraphQL Error: '${plan.relationName}' takes one of ${given.join(", ")} at a time — it holds a single row.`, { code: "DRIZZLE_NESTED_WRITE_INVALID" });
6178
6652
  };
6179
6653
  /** The values the junction stores for the target rows one operand selects. */
6180
6654
  const selectedTargetKeys = async (executor, plan, filter, operation, scope) => {
@@ -6189,9 +6663,8 @@ const createNestedWriteRuntime = (params) => {
6189
6663
  * error on some of them — the read and the write share the surrounding transaction, so no
6190
6664
  * other writer can slip a duplicate in between them.
6191
6665
  */
6192
- const applyThroughOps = async (executor, plan, key, op, scope) => {
6666
+ const applyThroughOps = async (executor, plan, key, op, scope, steps) => {
6193
6667
  const through = plan.through;
6194
- const unlinkAll = () => executor.delete(through.table).where((0, drizzle_orm.eq)(through.sourceCol, key));
6195
6668
  const link = async (operands, operation) => {
6196
6669
  const existing = new Set((await executor.select({ [through.targetColPropName]: through.targetCol }).from(through.table).where((0, drizzle_orm.eq)(through.sourceCol, key))).map((row) => row[through.targetColPropName]));
6197
6670
  const rows = [];
@@ -6202,19 +6675,19 @@ const createNestedWriteRuntime = (params) => {
6202
6675
  [through.targetColPropName]: targetKey
6203
6676
  });
6204
6677
  }
6205
- if (rows.length) await executor.insert(through.table).values(rows);
6678
+ if (rows.length) await steps.run(executor.insert(through.table).values(rows), operation, "insert");
6206
6679
  };
6207
- if (op["set"] !== void 0 && op["set"] !== null) {
6208
- await unlinkAll();
6680
+ if (supplied(op["set"])) {
6681
+ await steps.run(executor.delete(through.table).where((0, drizzle_orm.eq)(through.sourceCol, key)), "set", "delete");
6209
6682
  await link(asArray(op["set"]), "set");
6210
6683
  }
6211
- if (op["disconnect"] !== void 0 && op["disconnect"] !== null) for (const filter of asArray(op["disconnect"])) {
6684
+ if (supplied(op["disconnect"])) for (const filter of asArray(op["disconnect"])) {
6212
6685
  const targetKeys = await selectedTargetKeys(executor, plan, filter, "disconnect", scope);
6213
- if (targetKeys.length) await executor.delete(through.table).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(through.sourceCol, key), (0, drizzle_orm.inArray)(through.targetCol, targetKeys)));
6686
+ if (targetKeys.length) await steps.run(executor.delete(through.table).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(through.sourceCol, key), (0, drizzle_orm.inArray)(through.targetCol, targetKeys))), "disconnect", "delete");
6214
6687
  }
6215
- if (op["connect"] !== void 0 && op["connect"] !== null) await link(asArray(op["connect"]), "connect");
6688
+ if (supplied(op["connect"])) await link(asArray(op["connect"]), "connect");
6216
6689
  };
6217
- const detachAll = async (executor, plan, key, scope) => executor.update(plan.targetTable).set({ [plan.foreignColPropName]: null }).where(withScope(scope, plan.targetTableName, plan.targetTable, (0, drizzle_orm.eq)(plan.foreignCol, key)));
6690
+ const detachAll = (executor, plan, key, scope, steps, op) => steps.run(executor.update(plan.targetTable).set({ [plan.foreignColPropName]: null }).where(withScope(scope, plan.targetTableName, plan.targetTable, (0, drizzle_orm.eq)(plan.foreignCol, key))), op, "update");
6218
6691
  return {
6219
6692
  enabled: (tableName) => !!plans[tableName],
6220
6693
  split: (tableName, values) => {
@@ -6243,19 +6716,27 @@ const createNestedWriteRuntime = (params) => {
6243
6716
  }
6244
6717
  return columns;
6245
6718
  },
6246
- applyParentSide: async (executor, tableName, ops, context) => {
6719
+ applyParentSide: async (executor, tableName, ops, request) => {
6247
6720
  const tablePlans = plans[tableName];
6248
6721
  const patch = {};
6249
6722
  if (!tablePlans) return patch;
6723
+ const context = request?.context;
6250
6724
  const scope = resolveScope(policies, context, filterCtx);
6251
6725
  for (const [relationName, op] of Object.entries(ops)) {
6252
6726
  const plan = tablePlans[relationName];
6253
6727
  if (plan?.fkSide !== "parent") continue;
6254
6728
  assertSingleOperation(plan, op);
6255
6729
  if (op["create"] !== void 0) {
6730
+ const steps = createHookSteps(onWrite, executor, request, tableName, plan);
6731
+ steps.add("create", "insert");
6732
+ await steps.before();
6256
6733
  const values = applyContextValues(remapFromGraphQLSingleInput({ ...op["create"] }, plan.targetTable), contextValues?.(plan.targetTableName), context);
6257
- const created = (await executor.insert(plan.targetTable).values(values).returning({ [plan.foreignColPropName]: plan.foreignCol }))[0];
6734
+ const step = steps.get("create", "insert");
6735
+ const inserted = await executor.insert(plan.targetTable).values(values).returning(step ? step.returning : { [plan.foreignColPropName]: plan.foreignCol });
6736
+ step?.rows.push(...inserted);
6737
+ const created = inserted[0];
6258
6738
  if (!created) throw drizzleError(`Drizzle-GraphQL Error: 'create' on '${relationName}' inserted no row, so there is nothing to attach.`, { code: "DRIZZLE_NESTED_WRITE_INVALID" });
6739
+ await steps.after();
6259
6740
  patch[plan.localColPropName] = created[plan.foreignColPropName];
6260
6741
  } else if (op["connect"] !== void 0) {
6261
6742
  const rows = await executor.select({ [plan.foreignColPropName]: plan.foreignCol }).from(plan.targetTable).where(conditionOf(plan, op["connect"], "connect", scope));
@@ -6265,41 +6746,46 @@ const createNestedWriteRuntime = (params) => {
6265
6746
  }
6266
6747
  return patch;
6267
6748
  },
6268
- applyChildSide: async (executor, tableName, ops, parentRows, context) => {
6749
+ applyChildSide: async (executor, tableName, ops, parentRows, request) => {
6269
6750
  const tablePlans = plans[tableName];
6270
6751
  if (!tablePlans) return;
6752
+ const context = request?.context;
6271
6753
  const scope = resolveScope(policies, context, filterCtx);
6272
6754
  for (const [relationName, op] of Object.entries(ops)) {
6273
6755
  const plan = tablePlans[relationName];
6274
6756
  if (plan?.fkSide !== "child") continue;
6275
6757
  if (plan.isOne) assertSingleOperation(plan, op);
6758
+ const steps = createHookSteps(onWrite, executor, request, tableName, plan);
6759
+ registerChildSteps(steps, plan, op);
6760
+ await steps.before();
6276
6761
  for (const parentRow of parentRows) {
6277
6762
  const key = parentRow?.[plan.localColPropName];
6278
6763
  if (key === void 0 || key === null) throw drizzleError(`Drizzle-GraphQL Error: cannot write through '${relationName}': the ${tableName} row has no '${plan.localColPropName}' value to attach to.`, { code: "DRIZZLE_NESTED_WRITE_INVALID" });
6279
6764
  if (plan.through) {
6280
- await applyThroughOps(executor, plan, key, op, scope);
6765
+ await applyThroughOps(executor, plan, key, op, scope, steps);
6281
6766
  continue;
6282
6767
  }
6283
6768
  if (op["set"] !== void 0) {
6284
- await detachAll(executor, plan, key, scope);
6285
- for (const filter of asArray(op["set"])) await executor.update(plan.targetTable).set({ [plan.foreignColPropName]: key }).where(conditionOf(plan, filter, "set", scope));
6769
+ await detachAll(executor, plan, key, scope, steps, "set");
6770
+ for (const filter of asArray(op["set"])) await steps.run(executor.update(plan.targetTable).set({ [plan.foreignColPropName]: key }).where(conditionOf(plan, filter, "set", scope)), "set", "update");
6286
6771
  }
6287
6772
  if (op["disconnect"] !== void 0) {
6288
6773
  if (plan.isOne) {
6289
- if (op["disconnect"] === true) await detachAll(executor, plan, key, scope);
6290
- } else for (const filter of asArray(op["disconnect"])) await executor.update(plan.targetTable).set({ [plan.foreignColPropName]: null }).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(plan.foreignCol, key), conditionOf(plan, filter, "disconnect", scope)));
6774
+ if (op["disconnect"] === true) await detachAll(executor, plan, key, scope, steps, "disconnect");
6775
+ } else for (const filter of asArray(op["disconnect"])) await steps.run(executor.update(plan.targetTable).set({ [plan.foreignColPropName]: null }).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(plan.foreignCol, key), conditionOf(plan, filter, "disconnect", scope))), "disconnect", "update");
6291
6776
  }
6292
- const attaches = op["connect"] !== void 0 || op["create"] !== void 0;
6293
- if (plan.isOne && attaches && plan.canDetach && op["set"] === void 0) await detachAll(executor, plan, key, scope);
6294
- if (op["connect"] !== void 0) for (const filter of asArray(op["connect"])) await executor.update(plan.targetTable).set({ [plan.foreignColPropName]: key }).where(conditionOf(plan, filter, "connect", scope));
6777
+ const attachedBy = attachingOp(op);
6778
+ if (plan.isOne && attachedBy && plan.canDetach && op["set"] === void 0) await detachAll(executor, plan, key, scope, steps, attachedBy);
6779
+ if (op["connect"] !== void 0) for (const filter of asArray(op["connect"])) await steps.run(executor.update(plan.targetTable).set({ [plan.foreignColPropName]: key }).where(conditionOf(plan, filter, "connect", scope)), "connect", "update");
6295
6780
  if (op["create"] !== void 0) {
6296
6781
  const rows = asArray(op["create"]).map((row) => ({
6297
6782
  ...applyContextValues(remapFromGraphQLSingleInput({ ...row }, plan.targetTable), contextValues?.(plan.targetTableName), context),
6298
6783
  [plan.foreignColPropName]: key
6299
6784
  }));
6300
- if (rows.length) await executor.insert(plan.targetTable).values(rows);
6785
+ if (rows.length) await steps.run(executor.insert(plan.targetTable).values(rows), "create", "insert");
6301
6786
  }
6302
6787
  }
6788
+ await steps.after();
6303
6789
  }
6304
6790
  }
6305
6791
  };
@@ -6309,21 +6795,25 @@ const createNestedWriteRuntime = (params) => {
6309
6795
  * inside a single transaction — a savepoint when the caller already runs on one, so a failing
6310
6796
  * entry rolls the whole batch back.
6311
6797
  *
6798
+ * That transaction is also the `tx` the target tables' `onWrite` hooks run on. Every nested
6799
+ * write opens it, so a hook on a nested target has the whole tree to roll back even when the
6800
+ * root field carries no hook of its own and was not forced into a transaction for one.
6801
+ *
6312
6802
  * Entries are written one at a time rather than as a single multi-row statement: a nested
6313
6803
  * operation attaches to the row its own entry produced, so the two have to stay correlated.
6314
6804
  * The batch path is untouched when no entry carries a nested operation.
6315
6805
  */
6316
6806
  const writeWithNestedOps = async (params) => {
6317
- const { executor, runtime, tableName, entries, remapValues, write, context } = params;
6807
+ const { executor, runtime, tableName, entries, remapValues, write, request } = params;
6318
6808
  return executor.transaction(async (tx) => {
6319
6809
  const rows = [];
6320
6810
  for (const entry of entries) {
6321
- const patch = await runtime.applyParentSide(tx, tableName, entry.ops, context);
6811
+ const patch = await runtime.applyParentSide(tx, tableName, entry.ops, request);
6322
6812
  const written = await write(tx, {
6323
6813
  ...remapValues(entry.columns),
6324
6814
  ...patch
6325
6815
  });
6326
- await runtime.applyChildSide(tx, tableName, entry.ops, written, context);
6816
+ await runtime.applyChildSide(tx, tableName, entry.ops, written, request);
6327
6817
  rows.push(...written);
6328
6818
  }
6329
6819
  return rows;
@@ -6335,14 +6825,14 @@ const writeWithNestedOps = async (params) => {
6335
6825
  * attaches to each row that came back.
6336
6826
  */
6337
6827
  const updateWithNestedOps = async (params) => {
6338
- const { executor, runtime, tableName, columns, ops, remapValues, write, context } = params;
6828
+ const { executor, runtime, tableName, columns, ops, remapValues, write, request } = params;
6339
6829
  return executor.transaction(async (tx) => {
6340
- const patch = await runtime.applyParentSide(tx, tableName, ops, context);
6830
+ const patch = await runtime.applyParentSide(tx, tableName, ops, request);
6341
6831
  const rows = await write(tx, {
6342
6832
  ...remapValues(columns),
6343
6833
  ...patch
6344
6834
  });
6345
- await runtime.applyChildSide(tx, tableName, ops, rows, context);
6835
+ await runtime.applyChildSide(tx, tableName, ops, rows, request);
6346
6836
  return rows;
6347
6837
  });
6348
6838
  };
@@ -6543,7 +7033,14 @@ const createSchemaBuilder = (adapter) => {
6543
7033
  uniqueKeysOf: (tableName) => uniqueKeys[tableName]
6544
7034
  };
6545
7035
  adapter.preflight?.(db, options);
6546
- const nestedPlans = features.nestedWrites ? buildNestedWritePlans(tables, namedRelations, (target) => uniqueColumnSets(target), (target) => primaryKeyPropNames(target), extractRelationJoinColumns) : void 0;
7036
+ const nestedPlans = features.nestedWrites ? buildNestedWritePlans(tables, namedRelations, (target) => uniqueColumnSets(target), (target) => primaryKeyPropNames(target), extractRelationJoinColumns, (junction) => schemaEntries.find(([, value]) => value === junction)?.[0]) : void 0;
7037
+ if (nestedPlans) {
7038
+ const omitted = gateNestedWritePlans(nestedPlans, featureOf, (tableName) => excludedTables.has(tableName));
7039
+ if (omitted.length) {
7040
+ const listed = omitted.length > 5 ? `${omitted.slice(0, 5).join(", ")} and ${omitted.length - 5} more` : omitted.join(", ");
7041
+ console.warn(`Drizzle-GraphQL Warning: config.features — nestedWrites leaves out the nested inputs whose table has that write turned off: ${listed}.`);
7042
+ }
7043
+ }
6547
7044
  const nestedTypes = nestedPlans ? createNestedWriteTypes({
6548
7045
  plans: nestedPlans,
6549
7046
  cacheCtx,
@@ -7216,7 +7713,11 @@ const buildWriteResolvers = (primaryKeyPropNames) => {
7216
7713
  entries: nestedEntries,
7217
7714
  remapValues: (values) => applyContextValues(remapFromGraphQLSingleInput(values, table), contextColumns, context),
7218
7715
  write: (tx, values) => runInsert(tx, [values]),
7219
- context
7716
+ request: {
7717
+ context,
7718
+ args,
7719
+ info
7720
+ }
7220
7721
  }) : await runInsert(executor, input);
7221
7722
  await after(written);
7222
7723
  const result = output(written);
@@ -7305,7 +7806,11 @@ const buildWriteResolvers = (primaryKeyPropNames) => {
7305
7806
  entries: nestedEntries,
7306
7807
  remapValues: (values) => applyContextValues(remapFromGraphQLSingleInput(values, table), contextColumns, context),
7307
7808
  write: (tx, values) => runUpsert(tx, [values]),
7308
- context
7809
+ request: {
7810
+ context,
7811
+ args,
7812
+ info
7813
+ }
7309
7814
  }) : await runUpsert(executor, input);
7310
7815
  await after(written);
7311
7816
  const result = output(written);
@@ -7378,7 +7883,11 @@ const buildWriteResolvers = (primaryKeyPropNames) => {
7378
7883
  ops: nestedOps,
7379
7884
  remapValues: (values) => values,
7380
7885
  write: runUpdate,
7381
- context
7886
+ request: {
7887
+ context,
7888
+ args,
7889
+ info
7890
+ }
7382
7891
  }) : await runUpdate(executor, input);
7383
7892
  await after(written);
7384
7893
  const result = output(written);
@@ -7808,18 +8317,18 @@ const createSchemaDataGenerator = (adapter) => {
7808
8317
  * the shared multi-mutation transaction opened by `runMutation` — this opens a savepoint, so
7809
8318
  * the batch stays atomic without breaking the outer transaction.
7810
8319
  */
7811
- const runUpdateManyBatch = ({ executor, entries, table, returning, nested, tableName, context }) => executor.transaction(async (tx) => {
8320
+ const runUpdateManyBatch = ({ executor, entries, table, returning, nested, tableName, request }) => executor.transaction(async (tx) => {
7812
8321
  const results = [];
7813
8322
  for (const entry of entries) {
7814
8323
  const values = entry.ops ? {
7815
8324
  ...entry.set,
7816
- ...await nested.applyParentSide(tx, tableName, entry.ops, context)
8325
+ ...await nested.applyParentSide(tx, tableName, entry.ops, request)
7817
8326
  } : entry.set;
7818
8327
  const writes = Object.keys(values).length > 0;
7819
8328
  let query = writes ? tx.update(table).set(values) : tx.select(returning).from(table);
7820
8329
  if (entry.filters) query = query.where(entry.filters);
7821
8330
  const rows = await (writes ? query.returning(returning) : query);
7822
- if (entry.ops) await nested.applyChildSide(tx, tableName, entry.ops, rows, context);
8331
+ if (entry.ops) await nested.applyChildSide(tx, tableName, entry.ops, rows, request);
7823
8332
  results.push(rows);
7824
8333
  }
7825
8334
  return results;
@@ -7892,7 +8401,11 @@ const createUpdateManyGenerator = (primaryKeyPropNames, runBatch = runUpdateMany
7892
8401
  anyNested,
7893
8402
  nested,
7894
8403
  tableName,
7895
- context
8404
+ request: {
8405
+ context,
8406
+ args,
8407
+ info
8408
+ }
7896
8409
  });
7897
8410
  const written = perEntry.flat();
7898
8411
  await after(written);
@@ -7941,7 +8454,7 @@ const sqlitePrimaryKeyPropNames = (table) => getPrimaryKeyPropNamesFromConfig(ta
7941
8454
  * would run after the COMMIT. The driver kind is probed from the first statement's result
7942
8455
  * rather than declared, so the same resolver serves both driver families.
7943
8456
  */
7944
- const runSqliteUpdateManyBatch = ({ executor, entries, table, returning, anyNested, nested, tableName, context }) => {
8457
+ const runSqliteUpdateManyBatch = ({ executor, entries, table, returning, anyNested, nested, tableName, request }) => {
7945
8458
  const runEntry = (tx, entry) => {
7946
8459
  let query = tx.update(table).set(entry.set);
7947
8460
  if (entry.filters) query = query.where(entry.filters);
@@ -7950,13 +8463,13 @@ const runSqliteUpdateManyBatch = ({ executor, entries, table, returning, anyNest
7950
8463
  const runNestedEntry = async (tx, entry) => {
7951
8464
  const values = entry.ops ? {
7952
8465
  ...entry.set,
7953
- ...await nested.applyParentSide(tx, tableName, entry.ops, context)
8466
+ ...await nested.applyParentSide(tx, tableName, entry.ops, request)
7954
8467
  } : entry.set;
7955
8468
  const writes = Object.keys(values).length > 0;
7956
8469
  let query = writes ? tx.update(table).set(values) : tx.select(returning).from(table);
7957
8470
  if (entry.filters) query = query.where(entry.filters);
7958
8471
  const rows = await (writes ? query.returning(returning) : query);
7959
- if (entry.ops) await nested.applyChildSide(tx, tableName, entry.ops, rows, context);
8472
+ if (entry.ops) await nested.applyChildSide(tx, tableName, entry.ops, rows, request);
7960
8473
  return rows;
7961
8474
  };
7962
8475
  return executor.transaction((tx) => {
@@ -8167,7 +8680,7 @@ const buildSchema = (db, config) => {
8167
8680
  };
8168
8681
  //#endregion
8169
8682
  exports.GraphQLBigIntString = GraphQLBigIntString;
8170
- exports.GraphQLDate = graphql_scalars.GraphQLDate;
8683
+ exports.GraphQLDate = GraphQLDate;
8171
8684
  exports.GraphQLDateTime = graphql_scalars.GraphQLDateTime;
8172
8685
  exports.GraphQLDecimalString = GraphQLDecimalString;
8173
8686
  exports.GraphQLJSON = graphql_scalars.GraphQLJSON;