@vantreeseba/drizzle-graphql 14.0.0 → 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.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { Column, One, SQL, Table, aliasedTable, and, arrayContains, arrayOverlaps, asc, avg, count, countDistinct, desc, eq, extractExtendedColumnType, getColumns, getTableAsAliasSQL, gt, gte, ilike, inArray, is, isNotNull, isNull, like, lt, lte, max, min, ne, not, notIlike, notInArray, notLike, or, relationsFilterToSQL, sql, sum } from "drizzle-orm";
2
- import { MySqlAsyncDatabase, MySqlInt, MySqlSerial, MySqlTable, getTableConfig } from "drizzle-orm/mysql-core";
2
+ import { MySqlAsyncDatabase, MySqlDate, MySqlDateString, MySqlDateTime, MySqlDateTimeString, MySqlInt, MySqlSerial, MySqlTable, MySqlTimestamp, MySqlTimestampString, getTableConfig } from "drizzle-orm/mysql-core";
3
3
  import { PgAsyncDatabase, PgDate, PgDateString, PgInteger, PgSerial, PgTable, PgTimestamp, PgTimestampString, PgUUID, getTableConfig as getTableConfig$1 } from "drizzle-orm/pg-core";
4
- import { SQLiteAsyncDatabase, SQLiteInteger, SQLiteTable, getTableConfig as getTableConfig$2 } from "drizzle-orm/sqlite-core";
4
+ import { SQLiteAsyncDatabase, SQLiteInteger, SQLiteTable, SQLiteTimestamp, getTableConfig as getTableConfig$2 } from "drizzle-orm/sqlite-core";
5
5
  import { GraphQLBoolean, GraphQLEnumType, GraphQLError, GraphQLFloat, GraphQLInputObjectType, GraphQLInt, GraphQLList, GraphQLNonNull, GraphQLObjectType, GraphQLScalarType, GraphQLSchema, GraphQLString, Kind, getArgumentValues } from "graphql";
6
- import { GraphQLDate, GraphQLDateTime, GraphQLJSON, GraphQLUUID } from "graphql-scalars";
6
+ import { GraphQLDateTime, GraphQLJSON, GraphQLUUID } from "graphql-scalars";
7
7
  import pluralize from "pluralize";
8
8
  //#region src/util/builders/common/errors.ts
9
9
  /**
@@ -160,6 +160,76 @@ const GraphQLDecimalString = new GraphQLScalarType({
160
160
  return parseNumeric(ast.value);
161
161
  }
162
162
  });
163
+ const fullDatePattern = /^(\d{4})-(\d{2})-(\d{2})$/;
164
+ const daysInMonth = (year, month) => {
165
+ if (month === 2) return year % 4 === 0 && year % 100 !== 0 || year % 400 === 0 ? 29 : 28;
166
+ return [
167
+ 4,
168
+ 6,
169
+ 9,
170
+ 11
171
+ ].includes(month) ? 30 : 31;
172
+ };
173
+ /** Whether `value` is a `YYYY-MM-DD` string naming a day that exists on the calendar. */
174
+ const isCalendarDate = (value) => {
175
+ const match = fullDatePattern.exec(value);
176
+ if (!match) return false;
177
+ const year = Number(match[1]);
178
+ const month = Number(match[2]);
179
+ const day = Number(match[3]);
180
+ return month >= 1 && month <= 12 && day >= 1 && day <= daysInMonth(year, month);
181
+ };
182
+ /**
183
+ * A JS `Date` as the calendar day it stands for. Read in UTC, because that is how drivers
184
+ * build one from a `date` column: `new Date('2026-09-15')` is UTC midnight, so the local
185
+ * getters would name the previous day anywhere west of Greenwich.
186
+ */
187
+ const dateToCalendarDate = (value) => {
188
+ if (Number.isNaN(value.getTime())) return;
189
+ const year = value.getUTCFullYear();
190
+ if (year < 0 || year > 9999) return;
191
+ const month = String(value.getUTCMonth() + 1).padStart(2, "0");
192
+ const day = String(value.getUTCDate()).padStart(2, "0");
193
+ return `${String(year).padStart(4, "0")}-${month}-${day}`;
194
+ };
195
+ const calendarDate = (code, acceptDate) => (value) => {
196
+ if (typeof value === "string") {
197
+ if (!isCalendarDate(value)) throw drizzleError(`Date cannot represent "${value}": expected a calendar date as YYYY-MM-DD`, { code });
198
+ return value;
199
+ }
200
+ if (acceptDate && value instanceof Date) {
201
+ const formatted = dateToCalendarDate(value);
202
+ if (formatted === void 0) throw drizzleError("Date cannot represent an invalid or out-of-range Date", { code });
203
+ return formatted;
204
+ }
205
+ throw drizzleError(`Date cannot represent value: ${JSON.stringify(value)}: expected a YYYY-MM-DD string`, { code });
206
+ };
207
+ const parseCalendarDate = calendarDate(INVALID_INPUT, false);
208
+ /**
209
+ * A calendar day — `date` columns, whichever `mode` drizzle reads them in. Always transported
210
+ * as an RFC 3339 `full-date` string (`YYYY-MM-DD`), in both directions: it has no time and no
211
+ * offset, so there is nothing for a client's timezone to shift. `graphql-scalars`' own
212
+ * `GraphQLDate` is deliberately not used: its `parseValue` returns a JS `Date`, so the same
213
+ * scalar would be a string on the way out and an instant on the way in.
214
+ *
215
+ * Input accepts only a `YYYY-MM-DD` string naming a real day (`2026-02-30` and datetime
216
+ * strings are rejected) and hands it on unchanged. Output also accepts a JS `Date`, read as
217
+ * the UTC day it encodes.
218
+ */
219
+ const GraphQLDate = new GraphQLScalarType({
220
+ name: "Date",
221
+ description: "A calendar date with no time or offset, transported as an RFC 3339 full-date string (YYYY-MM-DD).",
222
+ specifiedByURL: "https://datatracker.ietf.org/doc/html/rfc3339#section-5.6",
223
+ serialize: calendarDate(UNREPRESENTABLE, true),
224
+ parseValue: parseCalendarDate,
225
+ parseLiteral: (ast) => {
226
+ if (ast.kind !== Kind.STRING) throw drizzleError(`Date cannot represent a ${ast.kind}`, {
227
+ code: INVALID_INPUT,
228
+ nodes: ast
229
+ });
230
+ return parseCalendarDate(ast.value);
231
+ }
232
+ });
163
233
  //#endregion
164
234
  //#region src/util/case-ops/index.ts
165
235
  const uncapitalize = (input) => input?.length ? `${input[0].toLocaleLowerCase()}${input.length > 1 ? input.slice(1, input.length) : ""}` : input;
@@ -193,6 +263,17 @@ const singularizeMapper = (tableName) => ({
193
263
  //#endregion
194
264
  //#region src/util/type-converter/index.ts
195
265
  const allowedNameChars = /^[a-zA-Z0-9_]+$/;
266
+ /** drizzle's `number` constraints whose values always fit a GraphQL `Int` (32-bit signed). */
267
+ const smallIntegerConstraints = /* @__PURE__ */ new Set([
268
+ "int8",
269
+ "uint8",
270
+ "int16",
271
+ "uint16",
272
+ "int24",
273
+ "uint24",
274
+ "int32",
275
+ "year"
276
+ ]);
196
277
  /** A GraphQL `Name` that is also allowed as an enum value — the spec reserves the three literals. */
197
278
  const validEnumValueName = (name) => /^[_a-zA-Z][_a-zA-Z0-9]*$/.test(name) && name !== "true" && name !== "false" && name !== "null";
198
279
  /**
@@ -333,6 +414,52 @@ const geoXyInputType = new GraphQLInputObjectType({
333
414
  y: { type: GraphQLFloat }
334
415
  }
335
416
  });
417
+ /**
418
+ * Whether the column holds a calendar day with no time component — a SQL `date`, in either of
419
+ * the modes drizzle reads one in (`'date'` → a JS `Date`, `'string'` → `YYYY-MM-DD`). Every
420
+ * position a column's type appears in — output, create/update input, filter operands,
421
+ * aggregates and group keys — gives such a column the `Date` scalar, whichever mode it uses:
422
+ * the mode is how the server holds the value, not what the value is. SQLite has no date-only
423
+ * column type (its integer timestamps are instants — see {@link isDateTimeColumn}), so only
424
+ * Postgres and MySQL appear here.
425
+ */
426
+ const isDateOnlyColumn = (column) => is(column, PgDate) || is(column, PgDateString) || is(column, MySqlDate) || is(column, MySqlDateString);
427
+ /**
428
+ * Whether the column is a MySQL `timestamp` / `datetime` read in drizzle's `'string'` mode. The
429
+ * driver hands such a column back as MySQL's own `YYYY-MM-DD HH:MM:SS[.fraction]` text, with no
430
+ * offset, and expects that form on write, so the data mappers translate between it and the ISO
431
+ * 8601 instant the `DateTime` scalar carries. The text is read as UTC, the way drizzle's own
432
+ * `'date'`-mode codecs for these columns read it.
433
+ */
434
+ const isMySqlDateTimeStringColumn = (column) => is(column, MySqlTimestampString) || is(column, MySqlDateTimeString);
435
+ /**
436
+ * Whether the column is a Postgres `timestamp` read in drizzle's `'string'` mode. The driver hands
437
+ * such a column back as Postgres's own text — `YYYY-MM-DD HH:MM:SS[.fraction]`, followed by an
438
+ * offset (`+00`, `-06`, `+05:30`) for `timestamptz` — so the data mappers translate between it
439
+ * and the ISO 8601 instant the `DateTime` scalar carries. Text without an offset (`timestamp`
440
+ * without time zone) is read as UTC, the way drizzle's own `'date'`-mode codec reads it.
441
+ */
442
+ const isPgTimestampStringColumn = (column) => is(column, PgTimestampString);
443
+ /**
444
+ * Whether the column is a SQLite `integer({ mode: 'timestamp' | 'timestamp_ms' })`. Both modes
445
+ * are one drizzle class, told apart by `mode`: the stored integer counts seconds or milliseconds
446
+ * since the Unix epoch. drizzle converts a JS `Date` to that integer on write (`'timestamp'`
447
+ * floors to the whole second) and back on read, but a value read through a path that skips the
448
+ * column's decoder arrives as the bare integer, which {@link sqliteTimestampToDate} reads.
449
+ */
450
+ const isSQLiteTimestampColumn = (column) => is(column, SQLiteTimestamp);
451
+ /**
452
+ * A SQLite timestamp column's stored integer as the instant it names, using the column's own
453
+ * unit (seconds for `'timestamp'`, milliseconds for `'timestamp_ms'`).
454
+ */
455
+ const sqliteTimestampToDate = (column, value) => new Date(column.mode === "timestamp" ? value * 1e3 : value);
456
+ /**
457
+ * Whether the column holds an instant — a Postgres `timestamp`, a MySQL `timestamp` /
458
+ * `datetime`, in either drizzle mode, or a SQLite integer timestamp in either unit. Every position
459
+ * such a column appears in gives it the `DateTime` scalar and the `DateTimeFilter`, whichever
460
+ * mode it uses, for the same reason as {@link isDateOnlyColumn}.
461
+ */
462
+ const isDateTimeColumn = (column) => is(column, PgTimestamp) || isPgTimestampStringColumn(column) || is(column, MySqlTimestamp) || is(column, MySqlDateTime) || isMySqlDateTimeStringColumn(column) || isSQLiteTimestampColumn(column);
336
463
  const columnToGraphQLCore = (column, columnName, tableName, isInput) => {
337
464
  const dimensions = column.dimensions;
338
465
  if (dimensions !== void 0 && dimensions > 0) {
@@ -359,7 +486,11 @@ const columnBaseToGraphQLCore = (column, columnName, tableName, isInput) => {
359
486
  typeLabel: "Boolean"
360
487
  };
361
488
  case "object":
362
- if (column instanceof PgTimestamp || column instanceof PgDate) return {
489
+ if (isDateOnlyColumn(column)) return {
490
+ type: GraphQLDate,
491
+ typeLabel: "Date"
492
+ };
493
+ if (isDateTimeColumn(column)) return {
363
494
  type: GraphQLDateTime,
364
495
  typeLabel: "DateTime"
365
496
  };
@@ -379,7 +510,7 @@ const columnBaseToGraphQLCore = (column, columnName, tableName, isInput) => {
379
510
  type: GraphQLDecimalString,
380
511
  typeLabel: "Decimal"
381
512
  };
382
- if (column instanceof PgTimestamp || column instanceof PgTimestampString) return {
513
+ if (isDateTimeColumn(column)) return {
383
514
  type: GraphQLDateTime,
384
515
  typeLabel: "DateTime"
385
516
  };
@@ -387,10 +518,7 @@ const columnBaseToGraphQLCore = (column, columnName, tableName, isInput) => {
387
518
  type: GraphQLUUID,
388
519
  typeLabel: "UUID"
389
520
  };
390
- if (column instanceof PgDateString) return isInput ? {
391
- type: GraphQLString,
392
- typeLabel: "Date"
393
- } : {
521
+ if (isDateOnlyColumn(column)) return {
394
522
  type: GraphQLDate,
395
523
  typeLabel: "Date"
396
524
  };
@@ -402,7 +530,7 @@ const columnBaseToGraphQLCore = (column, columnName, tableName, isInput) => {
402
530
  type: GraphQLBigIntString,
403
531
  typeLabel: "BigInt"
404
532
  };
405
- case "number": return is(column, PgInteger) || is(column, PgSerial) || is(column, MySqlInt) || is(column, MySqlSerial) || is(column, SQLiteInteger) ? {
533
+ case "number": return constraint !== void 0 && smallIntegerConstraints.has(constraint) || is(column, PgInteger) || is(column, PgSerial) || is(column, MySqlInt) || is(column, MySqlSerial) || is(column, SQLiteInteger) ? {
406
534
  type: GraphQLInt,
407
535
  typeLabel: "Integer"
408
536
  } : {
@@ -578,7 +706,8 @@ const isJsonColumn = (column) => (column.dataType ?? "").includes("json") && col
578
706
  * - "Decimal" → numeric/decimal columns (Decimal-scalar-typed operators, no string pattern operators)
579
707
  * - the enum GraphQL type name → enum columns (still unique per enum; equality and
580
708
  * membership only, like Boolean)
581
- * - "DateTime" → timestamp and date columns (ordering kept, no string pattern operators)
709
+ * - "Date" → date columns, either mode (ordering kept, no string pattern operators)
710
+ * - "DateTime" → timestamp columns (ordering kept, no string pattern operators)
582
711
  * - "Int" → integer/serial columns (no string pattern operators)
583
712
  * - "Float" → real/double columns (no string pattern operators)
584
713
  * - "String" → all other text/varchar columns
@@ -617,8 +746,11 @@ const resolveGenericFilterDescriptor = (column, columnGraphQLType) => {
617
746
  name: "Decimal",
618
747
  kind: "scalar"
619
748
  };
620
- const ct = column.columnType ?? "";
621
- if (ct === "PgTimestamp" || ct === "PgTimestampString" || ct === "PgDate") return {
749
+ if (columnGraphQLType.type === GraphQLDate) return {
750
+ name: "Date",
751
+ kind: "scalar"
752
+ };
753
+ if (isDateTimeColumn(column)) return {
622
754
  name: "DateTime",
623
755
  kind: "scalar"
624
756
  };
@@ -1013,6 +1145,58 @@ const generateColumnFilterValues = (column, tableName, columnName, cacheCtx) =>
1013
1145
  };
1014
1146
  //#endregion
1015
1147
  //#region src/util/data-mappers/index.ts
1148
+ /**
1149
+ * MySQL's own text form for a `timestamp` / `datetime`: `YYYY-MM-DD HH:MM:SS`, an optional
1150
+ * fraction, and no offset. mysql2 returns it for a `'string'`-mode column, and it is what such a
1151
+ * column expects on write.
1152
+ */
1153
+ const MYSQL_DATETIME_TEXT = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2}(?:\.\d+)?)$/;
1154
+ /**
1155
+ * MySQL datetime text, read as UTC, as the ISO 8601 instant the `DateTime` scalar carries.
1156
+ * UTC because that is how drizzle's own `'date'`-mode codecs read the same text, so a column's
1157
+ * two modes agree on the instant. Anything else (a zero date, a value already carrying an
1158
+ * offset) is returned unchanged for the scalar to accept or reject.
1159
+ */
1160
+ const mySqlDateTimeTextToIso = (value) => {
1161
+ const match = MYSQL_DATETIME_TEXT.exec(value);
1162
+ return match ? `${match[1]}T${match[2]}Z` : value;
1163
+ };
1164
+ /**
1165
+ * A JS `Date` as offset-less `YYYY-MM-DD HH:MM:SS.mmm` text in UTC: what a MySQL `'string'`-mode
1166
+ * column and a Postgres `timestamp` (without time zone) `'string'`-mode column are written with.
1167
+ */
1168
+ const dateToUtcWallText = (value) => value.toISOString().slice(0, -1).replace("T", " ");
1169
+ /**
1170
+ * Postgres's text form for a `timestamp` / `timestamptz` (the `ISO` `DateStyle`, the default):
1171
+ * `YYYY-MM-DD HH:MM:SS`, an optional fraction, and — for `timestamptz` only — the session zone's
1172
+ * offset as `±HH`, `±HH:MM` or, for historical local mean time, `±HH:MM:SS`.
1173
+ */
1174
+ const PG_TIMESTAMP_TEXT = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})(\.\d+)?(?:([+-])(\d{2})(?::?(\d{2}))?(?::?(\d{2}))?)?$/;
1175
+ /**
1176
+ * Postgres timestamp text as the RFC 3339 UTC instant (`…Z`) the `DateTime` scalar accepts.
1177
+ *
1178
+ * - With an offset (`timestamptz`), the instant is shifted to UTC. The offset can carry seconds,
1179
+ * which RFC 3339 cannot express, so it is applied here rather than rewritten.
1180
+ * - Without one (`timestamp`), the wall time is read as UTC: that is what drizzle's `'date'`-mode
1181
+ * codec does (`new Date(text + '+0000')`) and what the input side writes, so a column's two
1182
+ * modes agree on the instant.
1183
+ * - The fraction is kept digit for digit. The scalar itself carries milliseconds, so microseconds
1184
+ * are truncated on the wire, never rounded into the next second.
1185
+ *
1186
+ * Anything else (`infinity`, a `BC` date, a five-digit year) is returned unchanged for the scalar
1187
+ * to reject.
1188
+ */
1189
+ const pgTimestampTextToIso = (value) => {
1190
+ const match = PG_TIMESTAMP_TEXT.exec(value);
1191
+ if (!match) return value;
1192
+ const [, date, time, fraction = "", sign, hours, minutes = "0", seconds = "0"] = match;
1193
+ if (!sign) return `${date}T${time}${fraction}Z`;
1194
+ const wallMs = Date.parse(`${date}T${time}Z`);
1195
+ if (Number.isNaN(wallMs)) return value;
1196
+ const offsetSeconds = (sign === "-" ? -1 : 1) * (Number(hours) * 3600 + Number(minutes) * 60 + Number(seconds));
1197
+ const utc = (/* @__PURE__ */ new Date(wallMs - offsetSeconds * 1e3)).toISOString();
1198
+ return utc.length === 24 ? `${utc.slice(0, 19)}${fraction}Z` : value;
1199
+ };
1016
1200
  const remapToGraphQLCore = (key, value, tableName, column, relationMap) => {
1017
1201
  if (Array.isArray(value)) {
1018
1202
  const relations = relationMap?.[tableName];
@@ -1031,7 +1215,14 @@ const remapToGraphQLCore = (key, value, tableName, column, relationMap) => {
1031
1215
  if (!column) return value;
1032
1216
  if (getColumnScalarOverride(column, false)) return value;
1033
1217
  if (isJsonColumn(column)) return value;
1218
+ if (value instanceof Date && isDateOnlyColumn(column)) return dateToCalendarDate(value) ?? value;
1219
+ if (typeof value === "number" && isSQLiteTimestampColumn(column)) {
1220
+ const instant = sqliteTimestampToDate(column, value);
1221
+ return Number.isNaN(instant.getTime()) ? value : instant.toISOString();
1222
+ }
1034
1223
  if (value instanceof Date) return value.toISOString();
1224
+ if (typeof value === "string" && isMySqlDateTimeStringColumn(column)) return mySqlDateTimeTextToIso(value);
1225
+ if (typeof value === "string" && isPgTimestampStringColumn(column)) return pgTimestampTextToIso(value);
1035
1226
  if (value instanceof Buffer) return Array.from(value);
1036
1227
  if (typeof value === "bigint") return value.toString();
1037
1228
  if (Array.isArray(value)) {
@@ -1077,16 +1268,20 @@ const remapFromGraphQLCore = (value, column, columnName) => {
1077
1268
  }
1078
1269
  const dataType = column.dataType ?? "";
1079
1270
  const columnType = column.columnType ?? "";
1080
- if (columnType === "SQLiteTimestamp" || columnType === "SQLiteTimestampMs" || columnType === "MySqlTimestamp" || columnType === "MySqlDateTime" || columnType === "PgTimestamp" || columnType === "PgTimestampString") {
1081
- const formatted = new Date(value);
1271
+ if (isDateTimeColumn(column)) {
1272
+ const isMySql = columnType.startsWith("MySql");
1273
+ const isPgString = isPgTimestampStringColumn(column);
1274
+ const toIso = isMySql ? mySqlDateTimeTextToIso : isPgString ? pgTimestampTextToIso : void 0;
1275
+ const formatted = new Date(toIso && typeof value === "string" ? toIso(value) : value);
1082
1276
  if (Number.isNaN(formatted.getTime())) throw drizzleError(`Field '${columnName}' is not a valid date!`, { code: "DRIZZLE_INVALID_INPUT_VALUE" });
1083
- return formatted;
1277
+ if (isPgString) return column.withTimezone ? formatted.toISOString() : dateToUtcWallText(formatted);
1278
+ return isMySqlDateTimeStringColumn(column) ? dateToUtcWallText(formatted) : formatted;
1084
1279
  }
1085
- if ((columnType === "MySqlDate" || columnType === "PgDate") && typeof value === "string") {
1086
- const dateOnly = value.includes("T") ? value.split("T")[0] : value;
1087
- const check = new Date(dateOnly);
1088
- if (Number.isNaN(check.getTime())) throw drizzleError(`Field '${columnName}' is not a valid date!`, { code: "DRIZZLE_INVALID_INPUT_VALUE" });
1089
- return dateOnly;
1280
+ if (isDateOnlyColumn(column)) {
1281
+ if (Array.isArray(value)) return value.map((element) => remapFromGraphQLCore(element, column, columnName));
1282
+ const day = value instanceof Date ? dateToCalendarDate(value) : value;
1283
+ 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" });
1284
+ return day;
1090
1285
  }
1091
1286
  if (dataType.includes("bigint")) try {
1092
1287
  return BigInt(value);
@@ -1442,7 +1637,8 @@ const extractFiltersColumn = (column, columnName, operators) => {
1442
1637
  }
1443
1638
  if (NOT) {
1444
1639
  const extracted = extractFiltersColumn(column, columnName, NOT);
1445
- if (extracted) variants.push(not(extracted));
1640
+ const negated = extracted ? not(extracted) : void 0;
1641
+ if (negated) variants.push(negated);
1446
1642
  }
1447
1643
  if (OR?.length) {
1448
1644
  const orVariants = [];
@@ -1455,6 +1651,150 @@ const extractFiltersColumn = (column, columnName, operators) => {
1455
1651
  return variants.length ? variants.length > 1 ? and(...variants) : variants[0] : void 0;
1456
1652
  };
1457
1653
  //#endregion
1654
+ //#region src/util/builders/common/relations.ts
1655
+ /**
1656
+ * Flatten drizzle-orm v1 TablesRelationalConfig into the canonical
1657
+ * Record<tableName, Record<relName, TableNamedRelations>> shape used
1658
+ * throughout common.ts. Both pg.ts and sqlite.ts call this before
1659
+ * passing the relation map to any shared function.
1660
+ */
1661
+ const buildNamedRelations = (relations, tableEntries) => {
1662
+ const namedRelations = {};
1663
+ for (const [relTableName, relConfig] of Object.entries(relations)) {
1664
+ if (!relConfig?.relations) continue;
1665
+ const namedConfig = {};
1666
+ for (const [innerRelName, innerRelValue] of Object.entries(relConfig.relations)) {
1667
+ const targetTable = innerRelValue.targetTable ?? innerRelValue.referencedTable;
1668
+ const directTargetName = innerRelValue.targetTableName;
1669
+ let targetTableName;
1670
+ if (directTargetName) targetTableName = tableEntries.find(([key]) => key === directTargetName)?.[0];
1671
+ else if (targetTable) targetTableName = tableEntries.find(([, tableValue]) => tableValue === targetTable)?.[0];
1672
+ if (!targetTableName) continue;
1673
+ namedConfig[innerRelName] = {
1674
+ relation: innerRelValue,
1675
+ targetTableName
1676
+ };
1677
+ }
1678
+ if (Object.keys(namedConfig).length > 0) namedRelations[relTableName] = namedConfig;
1679
+ }
1680
+ return namedRelations;
1681
+ };
1682
+ /**
1683
+ * Records each relation's target-table primary-key property names on the relation entry,
1684
+ * so the pagination paths (the window-function batch loader and the eager `with:` orderBy
1685
+ * default) can fall back to a deterministic PK order without re-deriving it per request.
1686
+ *
1687
+ * Composite primary keys are only visible through the dialect's getTableConfig, so the
1688
+ * dialect builder passes a `resolvePkNames` that threads the composite column names in.
1689
+ * Mutates the relation entries in place (they are shared with the pruned eager map and
1690
+ * the resolver factory, so attaching once covers every consumer).
1691
+ */
1692
+ const attachTargetPrimaryKeys = (namedRelations, tables, resolvePkNames) => {
1693
+ const cache = /* @__PURE__ */ new Map();
1694
+ for (const rels of Object.values(namedRelations)) for (const relEntry of Object.values(rels)) {
1695
+ const { targetTableName } = relEntry;
1696
+ let pk = cache.get(targetTableName);
1697
+ if (!pk) {
1698
+ const targetTable = tables[targetTableName];
1699
+ pk = targetTable ? resolvePkNames(targetTable) : [];
1700
+ cache.set(targetTableName, pk);
1701
+ }
1702
+ relEntry.targetPkNames = pk;
1703
+ }
1704
+ };
1705
+ /**
1706
+ * The table column behind one entry of a relation's `sourceColumns`, `targetColumns` or
1707
+ * `through` lists.
1708
+ *
1709
+ * drizzle-orm rc.4 stores the table's own Column objects in `sourceColumns` / `targetColumns`
1710
+ * and a `RelationsBuilderColumn` descriptor (`{ _: { column, key, tableName } }`) in `through`.
1711
+ * The rc.5 snapshots store the descriptor in all of them. Both shapes are accepted here, told
1712
+ * apart structurally rather than by importing a class one of those versions does not export.
1713
+ * Anything else yields `undefined`, which callers treat as "join columns not readable".
1714
+ */
1715
+ const unwrapRelationColumn = (entry) => {
1716
+ if (is(entry, Column)) return entry;
1717
+ const inner = entry?._?.column;
1718
+ return is(inner, Column) ? inner : void 0;
1719
+ };
1720
+ /**
1721
+ * A relation's `sourceColumns` or `targetColumns` as table columns, via
1722
+ * {@link unwrapRelationColumn}. `undefined` when the list is missing, empty, or has an entry
1723
+ * that is not a column in either known shape.
1724
+ */
1725
+ const relationColumns = (relation, side) => {
1726
+ const entries = relation?.[side === "source" ? "sourceColumns" : "targetColumns"];
1727
+ if (!entries?.length) return;
1728
+ const columns = [];
1729
+ for (const entry of entries) {
1730
+ const column = unwrapRelationColumn(entry);
1731
+ if (!column) return;
1732
+ columns.push(column);
1733
+ }
1734
+ return columns;
1735
+ };
1736
+ /**
1737
+ * drizzle-orm's "no filter" sentinel. rc.5 snapshots export it as `EmptyFilter` and use it as
1738
+ * the default `relation.where`; rc.4 has no such export and leaves `where` undefined. It is a
1739
+ * registered symbol, so it can be compared against without importing anything.
1740
+ */
1741
+ const EMPTY_FILTER = Symbol.for("drizzle:EmptyFilter");
1742
+ /**
1743
+ * The relation's own declared `where` filter, or `undefined` when it has none. rc.4 leaves
1744
+ * `where` undefined in that case; rc.5 snapshots set it to the `EmptyFilter` sentinel, which is
1745
+ * truthy and must not be read as a filter.
1746
+ */
1747
+ const relationOwnWhere = (relation) => {
1748
+ const where = relation?.where;
1749
+ return where == null || where === EMPTY_FILTER ? void 0 : where;
1750
+ };
1751
+ /**
1752
+ * Whether the relation's `where` was inherited from its reverse relation, and so is written
1753
+ * against the parent table rather than the target. rc.4 calls this flag `isReversed`; rc.5
1754
+ * snapshots renamed it to `isFilterReversed`. Both are set in the same place with the same
1755
+ * meaning (true only for a relation declared without `from`/`to` and without its own `where`).
1756
+ */
1757
+ const isRelationFilterReversed = (relation) => !!(relation?.isFilterReversed ?? relation?.isReversed);
1758
+ /**
1759
+ * Warns, at build time, that a field was left out because the relation's join columns could
1760
+ * not be matched to its tables. Omitting it silently is how a drizzle-orm change in the shape
1761
+ * of relation columns (issue #179) went unnoticed. A relation that declares no join columns
1762
+ * is not reported: there is nothing that failed to match.
1763
+ */
1764
+ const warnUnresolvedJoin = (relEntry, tableName, relationName, omitted) => {
1765
+ const rel = relEntry.relation ?? relEntry;
1766
+ if (!rel?.sourceColumns?.length || !rel?.targetColumns?.length) return;
1767
+ 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.`);
1768
+ };
1769
+ /**
1770
+ * Extracts the join column info from a drizzle-orm v1 Relation object.
1771
+ * Returns the JS property name of the local column on the parent table and the
1772
+ * Column object for the foreign column on the target table, or undefined if the
1773
+ * relation internals are not accessible.
1774
+ */
1775
+ const extractRelationJoinColumns = (relEntry, parentTable, targetTable) => {
1776
+ const rel = relEntry.relation ?? relEntry;
1777
+ const sourceCol = relationColumns(rel, "source")?.[0];
1778
+ const targetCol = relationColumns(rel, "target")?.[0];
1779
+ if (!sourceCol || !targetCol) return;
1780
+ const parentCols = getColumns(parentTable);
1781
+ const localColPropName = Object.entries(parentCols).find(([, c]) => c === sourceCol)?.[0];
1782
+ const targetCols = getColumns(targetTable);
1783
+ const foreignColPropName = Object.entries(targetCols).find(([, c]) => c === targetCol)?.[0];
1784
+ if (!localColPropName || !foreignColPropName) return;
1785
+ return {
1786
+ localColPropName,
1787
+ foreignCol: targetCol,
1788
+ foreignColPropName
1789
+ };
1790
+ };
1791
+ /**
1792
+ * Whether a to-one relation can back a relation orderBy hop. `.through()` (junction)
1793
+ * relations are excluded — the ordering subquery only joins the direct target, never the
1794
+ * junction table. (Relation *filters* do support `.through()` — this guard is orderBy-only.)
1795
+ */
1796
+ const isFilterableRelation = (relation) => !relation.through;
1797
+ //#endregion
1458
1798
  //#region src/util/builders/common/exclusions.ts
1459
1799
  /**
1460
1800
  * Columns this build keeps out of the generated schema, keyed by the table object.
@@ -1630,8 +1970,8 @@ const relationFilterCtx = (base, tableKey) => {
1630
1970
  * the parent is an aliased proxy (as it is inside a relational `with:` where callback).
1631
1971
  */
1632
1972
  const buildRelationJoinCondition = (parentTable, relation, aliasedTarget, relationName) => {
1633
- const sourceColumns = relation.sourceColumns;
1634
- const targetColumns = relation.targetColumns;
1973
+ const sourceColumns = relationColumns(relation, "source");
1974
+ const targetColumns = relationColumns(relation, "target");
1635
1975
  if (!sourceColumns?.length || sourceColumns.length !== targetColumns?.length) throw drizzleError(`WHERE ${relationName}: Relation cannot be used as a filter`, { code: "DRIZZLE_INVALID_FILTER" });
1636
1976
  const parentColumns = Object.values(getColumns(parentTable));
1637
1977
  const targetColumnsByName = Object.values(getColumns(aliasedTarget));
@@ -1655,7 +1995,7 @@ const resolveJunctionColumn = (aliasedThrough, junctionRef, relationName) => {
1655
1995
  const key = junctionRef?._?.key;
1656
1996
  const byKey = key ? throughColumns[key] : void 0;
1657
1997
  if (byKey) return byKey;
1658
- const columnName = junctionRef?._?.column?.name;
1998
+ const columnName = unwrapRelationColumn(junctionRef)?.name;
1659
1999
  const byName = columnName ? Object.values(throughColumns).find((c) => c.name === columnName) : void 0;
1660
2000
  if (!byName) throw drizzleError(`WHERE ${relationName}: Relation cannot be used as a filter`, { code: "DRIZZLE_INVALID_FILTER" });
1661
2001
  return byName;
@@ -1669,8 +2009,8 @@ const resolveJunctionColumn = (aliasedThrough, junctionRef, relationName) => {
1669
2009
  * `through` metadata.
1670
2010
  */
1671
2011
  const buildThroughJoinConditions = (parentTable, relation, aliasedThrough, aliasedTarget, relationName) => {
1672
- const sourceColumns = relation.sourceColumns;
1673
- const targetColumns = relation.targetColumns;
2012
+ const sourceColumns = relationColumns(relation, "source");
2013
+ const targetColumns = relationColumns(relation, "target");
1674
2014
  const through = relation.through;
1675
2015
  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" });
1676
2016
  const parentColumns = Object.values(getColumns(parentTable));
@@ -1722,7 +2062,8 @@ const buildRelationExists = (parentTable, relationName, relEntry, innerFilters,
1722
2062
  fromClause = getTableAsAliasSQL(aliasedTarget);
1723
2063
  joinCondition = buildRelationJoinCondition(parentTable, relation, aliasedTarget, relationName);
1724
2064
  }
1725
- const relationWhere = relation.where ? relationsFilterToSQL(relation.isReversed ? parentTable : aliasedTarget, relation.where) : void 0;
2065
+ const ownWhere = relationOwnWhere(relation);
2066
+ const relationWhere = ownWhere ? relationsFilterToSQL(isRelationFilterReversed(relation) ? parentTable : aliasedTarget, ownWhere) : void 0;
1726
2067
  const inner = innerFilters ? extractFilters(aliasedTarget, targetTableName, innerFilters, {
1727
2068
  ...ctx,
1728
2069
  tableKey: targetTableName,
@@ -1776,7 +2117,8 @@ const extractFilters = (table, tableName, filters, relationCtx) => {
1776
2117
  }
1777
2118
  if (NOT) {
1778
2119
  const extracted = extractFilters(table, tableName, NOT, relationCtx);
1779
- if (extracted) variants.push(not(extracted));
2120
+ const negated = extracted ? not(extracted) : void 0;
2121
+ if (negated) variants.push(negated);
1780
2122
  }
1781
2123
  if (OR?.length) {
1782
2124
  const orVariants = [];
@@ -2173,88 +2515,6 @@ const excludedColumnRef = (columnName) => sql`excluded.${sql.identifier(columnNa
2173
2515
  /** `values(<column>)` — MySQL's equivalent inside ON DUPLICATE KEY UPDATE. */
2174
2516
  const mysqlValuesColumnRef = (columnName) => sql`values(${sql.identifier(columnName)})`;
2175
2517
  //#endregion
2176
- //#region src/util/builders/common/relations.ts
2177
- /**
2178
- * Flatten drizzle-orm v1 TablesRelationalConfig into the canonical
2179
- * Record<tableName, Record<relName, TableNamedRelations>> shape used
2180
- * throughout common.ts. Both pg.ts and sqlite.ts call this before
2181
- * passing the relation map to any shared function.
2182
- */
2183
- const buildNamedRelations = (relations, tableEntries) => {
2184
- const namedRelations = {};
2185
- for (const [relTableName, relConfig] of Object.entries(relations)) {
2186
- if (!relConfig?.relations) continue;
2187
- const namedConfig = {};
2188
- for (const [innerRelName, innerRelValue] of Object.entries(relConfig.relations)) {
2189
- const targetTable = innerRelValue.targetTable ?? innerRelValue.referencedTable;
2190
- const directTargetName = innerRelValue.targetTableName;
2191
- let targetTableName;
2192
- if (directTargetName) targetTableName = tableEntries.find(([key]) => key === directTargetName)?.[0];
2193
- else if (targetTable) targetTableName = tableEntries.find(([, tableValue]) => tableValue === targetTable)?.[0];
2194
- if (!targetTableName) continue;
2195
- namedConfig[innerRelName] = {
2196
- relation: innerRelValue,
2197
- targetTableName
2198
- };
2199
- }
2200
- if (Object.keys(namedConfig).length > 0) namedRelations[relTableName] = namedConfig;
2201
- }
2202
- return namedRelations;
2203
- };
2204
- /**
2205
- * Records each relation's target-table primary-key property names on the relation entry,
2206
- * so the pagination paths (the window-function batch loader and the eager `with:` orderBy
2207
- * default) can fall back to a deterministic PK order without re-deriving it per request.
2208
- *
2209
- * Composite primary keys are only visible through the dialect's getTableConfig, so the
2210
- * dialect builder passes a `resolvePkNames` that threads the composite column names in.
2211
- * Mutates the relation entries in place (they are shared with the pruned eager map and
2212
- * the resolver factory, so attaching once covers every consumer).
2213
- */
2214
- const attachTargetPrimaryKeys = (namedRelations, tables, resolvePkNames) => {
2215
- const cache = /* @__PURE__ */ new Map();
2216
- for (const rels of Object.values(namedRelations)) for (const relEntry of Object.values(rels)) {
2217
- const { targetTableName } = relEntry;
2218
- let pk = cache.get(targetTableName);
2219
- if (!pk) {
2220
- const targetTable = tables[targetTableName];
2221
- pk = targetTable ? resolvePkNames(targetTable) : [];
2222
- cache.set(targetTableName, pk);
2223
- }
2224
- relEntry.targetPkNames = pk;
2225
- }
2226
- };
2227
- /**
2228
- * Extracts the join column info from a drizzle-orm v1 Relation object.
2229
- * Returns the JS property name of the local column on the parent table and the
2230
- * Column object for the foreign column on the target table, or undefined if the
2231
- * relation internals are not accessible.
2232
- */
2233
- const extractRelationJoinColumns = (relEntry, parentTable, targetTable) => {
2234
- const rel = relEntry.relation ?? relEntry;
2235
- const sourceColumns = rel.sourceColumns;
2236
- const targetColumns = rel.targetColumns;
2237
- if (!sourceColumns?.length || !targetColumns?.length) return;
2238
- const sourceCol = sourceColumns[0];
2239
- const targetCol = targetColumns[0];
2240
- const parentCols = getColumns(parentTable);
2241
- const localColPropName = Object.entries(parentCols).find(([, c]) => c === sourceCol)?.[0];
2242
- const targetCols = getColumns(targetTable);
2243
- const foreignColPropName = Object.entries(targetCols).find(([, c]) => c === targetCol)?.[0];
2244
- if (!localColPropName || !foreignColPropName) return;
2245
- return {
2246
- localColPropName,
2247
- foreignCol: targetCol,
2248
- foreignColPropName
2249
- };
2250
- };
2251
- /**
2252
- * Whether a to-one relation can back a relation orderBy hop. `.through()` (junction)
2253
- * relations are excluded — the ordering subquery only joins the direct target, never the
2254
- * junction table. (Relation *filters* do support `.through()` — this guard is orderBy-only.)
2255
- */
2256
- const isFilterableRelation = (relation) => !relation.through;
2257
- //#endregion
2258
2518
  //#region src/util/builders/common/order-by.ts
2259
2519
  /**
2260
2520
  * Column name / direction / nulls triples from an `orderBy` argument, highest priority
@@ -2300,7 +2560,8 @@ const extendRelationOrderChain = (parentTable, relationName, relEntry, ctx, chai
2300
2560
  ctx.aliases ??= { n: 0 };
2301
2561
  const aliasedTarget = aliasedTable(targetTable, `dgql_ord_${ctx.aliases.n++}`);
2302
2562
  const joinCondition = buildRelationJoinCondition(parentTable, relation, aliasedTarget, relationName);
2303
- const relationWhere = relation.where ? relationsFilterToSQL(relation.isReversed ? parentTable : aliasedTarget, relation.where) : void 0;
2563
+ const ownWhere = relationOwnWhere(relation);
2564
+ const relationWhere = ownWhere ? relationsFilterToSQL(isRelationFilterReversed(relation) ? parentTable : aliasedTarget, ownWhere) : void 0;
2304
2565
  return {
2305
2566
  fromParts: [...chain?.fromParts ?? [], getTableAsAliasSQL(aliasedTarget)],
2306
2567
  conditions: [
@@ -2317,15 +2578,17 @@ const extendRelationOrderChain = (parentTable, relationName, relEntry, ctx, chai
2317
2578
  *
2318
2579
  * A ladder rather than a dialect array function (`array_position` on Postgres, `FIELD()` on
2319
2580
  * MySQL, neither on SQLite) because it is the one form all three share, and because every
2320
- * list value stays a bound parameter. The branch indices are library-generated integers, so
2321
- * they are written into the SQL text — a bound parameter there would leave Postgres unable
2322
- * to infer the CASE result type. Values outside the list sort after every listed one.
2581
+ * list value stays a bound parameter — encoded by the column, as `eq` would, so a value whose
2582
+ * driver form differs from its JS one (a SQLite integer timestamp's `Date`) is bound as stored.
2583
+ * The branch indices are library-generated integers, so they are written into the SQL text — a
2584
+ * bound parameter there would leave Postgres unable to infer the CASE result type. Values
2585
+ * outside the list sort after every listed one.
2323
2586
  */
2324
2587
  const filterOrderExpression = (column, columnName, whereArgs) => {
2325
2588
  const values = whereArgs?.[columnName]?.inArray;
2326
2589
  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" });
2327
2590
  if (!values.length) return sql`0`;
2328
- const branches = values.map((value, index) => sql`when ${column} = ${remapFromGraphQLCore(value, column, columnName)} then ${sql.raw(String(index))}`);
2591
+ const branches = values.map((value, index) => sql`when ${column} = ${sql.param(remapFromGraphQLCore(value, column, columnName), column)} then ${sql.raw(String(index))}`);
2329
2592
  return sql`case ${sql.join(branches, sql` `)} else ${sql.raw(String(values.length))} end`;
2330
2593
  };
2331
2594
  /**
@@ -3291,6 +3554,26 @@ const remapUpdateInput = (values, table, tableName) => {
3291
3554
  } : remapped;
3292
3555
  };
3293
3556
  //#endregion
3557
+ //#region src/util/builders/common/rqb-config.ts
3558
+ /**
3559
+ * A copy of a relational query config with every key whose value is `undefined` left out.
3560
+ *
3561
+ * drizzle-orm rc.5 snapshots test `"where" in params` rather than its value, and throw
3562
+ * `Unexpected 'undefined' in filter value` for `{ where: undefined }`; rc.4 read the value and
3563
+ * treated both the same. The library builds its configs with conditional keys
3564
+ * (`where: cond ? … : undefined`), so every config goes through here before it reaches drizzle.
3565
+ * Only `where` is checked that way today, but leaving out every undefined key costs nothing
3566
+ * and keeps the next key that gets the same treatment from breaking queries.
3567
+ *
3568
+ * Shallow on purpose: nested `with:` entries are built (and passed through here) one level at
3569
+ * a time, and the values of the other keys are drizzle's own (column maps, callbacks, RAW).
3570
+ */
3571
+ const omitUndefinedKeys = (config) => {
3572
+ const out = {};
3573
+ for (const [key, value] of Object.entries(config)) if (value !== void 0) out[key] = value;
3574
+ return out;
3575
+ };
3576
+ //#endregion
3294
3577
  //#region src/util/builders/common/selected-columns.ts
3295
3578
  const rqbCrashTypes = [
3296
3579
  "SQLiteBigInt",
@@ -3419,7 +3702,7 @@ const extractRelationsParams = (relationMap, tables, tableName, info, typeName,
3419
3702
  thisRecord.offset = offset;
3420
3703
  thisRecord.limit = limit;
3421
3704
  thisRecord.with = extractRelationsParams(relationMap, tables, targetTableName, field, relTypeName, options);
3422
- args[relName] = thisRecord;
3705
+ args[relName] = omitUndefinedKeys(thisRecord);
3423
3706
  }
3424
3707
  return args;
3425
3708
  };
@@ -3496,12 +3779,12 @@ const runRelationalSelect = async (opts) => {
3496
3779
  }) : void 0
3497
3780
  };
3498
3781
  if (single) {
3499
- const result = await queryBase.findFirst(params);
3782
+ const result = await queryBase.findFirst(omitUndefinedKeys(params));
3500
3783
  return result ? remapToGraphQLSingleOutput(result, tableName, table, relationMap) : void 0;
3501
3784
  }
3502
3785
  if (cursorEntries && cursorSelected) for (const [column] of cursorEntries) params.columns[column] = true;
3503
3786
  params.limit = distinctKeys ? void 0 : opts.limit;
3504
- const result = await queryBase.findMany(params);
3787
+ const result = await queryBase.findMany(omitUndefinedKeys(params));
3505
3788
  if (cursorEntries && cursorSelected) attachRowCursors(result, cursorEntries);
3506
3789
  return remapToGraphQLArrayOutput(result, tableName, table, relationMap);
3507
3790
  };
@@ -5539,13 +5822,21 @@ const GROUP_KEY = "__dgql_group_key";
5539
5822
  * for every other aggregate.
5540
5823
  */
5541
5824
  const createRelationAggregateFactory = (db, tables, cacheCtx, typeNameMapper, filterCtx, policies) => {
5825
+ const warned = /* @__PURE__ */ new Set();
5542
5826
  return ({ tableName, relationName, relEntry }) => {
5543
5827
  const parentTable = tables[tableName];
5544
5828
  const targetTableName = relEntry.targetTableName;
5545
5829
  const targetTable = tables[targetTableName];
5546
5830
  if (!parentTable || !targetTable) return;
5547
5831
  const joinCols = extractRelationJoinColumns(relEntry, parentTable, targetTable);
5548
- if (!joinCols) return;
5832
+ if (!joinCols) {
5833
+ const key = `${tableName}.${relationName}`;
5834
+ if (!warned.has(key)) {
5835
+ warned.add(key);
5836
+ warnUnresolvedJoin(relEntry, tableName, relationName, `its '${relationName}Aggregate' field`);
5837
+ }
5838
+ return;
5839
+ }
5549
5840
  const { localColPropName, foreignCol } = joinCols;
5550
5841
  const defaultDeleted = relationDeletedDefault(policies?.softDelete, targetTableName, false);
5551
5842
  const targetTypeName = resolveTypeName(targetTableName, typeNameMapper);
@@ -5849,12 +6140,6 @@ const propNameOf = (table, column) => {
5849
6140
  for (const [propName, candidate] of Object.entries(getColumns(table))) if (candidate === column) return propName;
5850
6141
  };
5851
6142
  /**
5852
- * The junction column one entry of a relation's `through` config points at. Drizzle wraps it
5853
- * in a descriptor while the relation is being defined; the raw column is accepted too, so a
5854
- * change of shape there degrades to skipping the relation rather than mis-planning it.
5855
- */
5856
- const throughColumn = (entry) => entry && is(entry, Column) ? entry : entry?._?.column;
5857
- /**
5858
6143
  * A `.through()` relation's plan: the junction table plus the two columns it joins on.
5859
6144
  *
5860
6145
  * Nothing about the parent or the target row changes when a many-to-many link is made or
@@ -5865,21 +6150,27 @@ const throughColumn = (entry) => entry && is(entry, Column) ? entry : entry?._?.
5865
6150
  * row" guarantee lives in the junction's constraints rather than in the relation; a relation
5866
6151
  * joining on more than one column per side; and a junction carrying a further NOT NULL column
5867
6152
  * with no default, which no `connect` operand could supply a value for.
6153
+ *
6154
+ * `onUnresolved` is called when the relation's columns are of a supported shape but cannot be
6155
+ * matched to the three tables — a drizzle-orm mismatch rather than an unsupported relation.
5868
6156
  */
5869
- const buildThroughPlan = (relationName, relation, table, targetTableName, targetTable, tableNameOf) => {
6157
+ const buildThroughPlan = (relationName, relation, table, targetTableName, targetTable, tableNameOf, onUnresolved) => {
5870
6158
  if (is(relation, One)) return;
5871
6159
  const junction = relation.throughTable;
5872
6160
  const sourceEntries = relation.through?.source;
5873
6161
  const targetEntries = relation.through?.target;
5874
6162
  if (!junction || sourceEntries?.length !== 1 || targetEntries?.length !== 1) return;
5875
6163
  if (relation.sourceColumns?.length !== 1 || relation.targetColumns?.length !== 1) return;
5876
- const localCol = relation.sourceColumns[0];
5877
- const foreignCol = relation.targetColumns[0];
6164
+ const localCol = unwrapRelationColumn(relation.sourceColumns[0]);
6165
+ const foreignCol = unwrapRelationColumn(relation.targetColumns[0]);
5878
6166
  const localColPropName = propNameOf(table, localCol);
5879
6167
  const foreignColPropName = propNameOf(targetTable, foreignCol);
5880
- const sourceColPropName = propNameOf(junction, throughColumn(sourceEntries[0]));
5881
- const targetColPropName = propNameOf(junction, throughColumn(targetEntries[0]));
5882
- if (!localColPropName || !foreignColPropName || !sourceColPropName || !targetColPropName) return;
6168
+ const sourceColPropName = propNameOf(junction, unwrapRelationColumn(sourceEntries[0]));
6169
+ const targetColPropName = propNameOf(junction, unwrapRelationColumn(targetEntries[0]));
6170
+ if (!localCol || !foreignCol || !localColPropName || !foreignColPropName || !sourceColPropName || !targetColPropName) {
6171
+ onUnresolved();
6172
+ return;
6173
+ }
5883
6174
  const junctionColumns = getColumns(junction);
5884
6175
  const sourceCol = junctionColumns[sourceColPropName];
5885
6176
  const targetCol = junctionColumns[targetColPropName];
@@ -5949,13 +6240,17 @@ const buildNestedWritePlans = (tables, relationMap, resolveUniqueSets, resolvePr
5949
6240
  const targetTableName = relEntry.targetTableName;
5950
6241
  const targetTable = tables[targetTableName];
5951
6242
  if (!targetTable) continue;
6243
+ const omitted = `the '${relationName}' field of its nested-write create and update inputs`;
5952
6244
  if (relation.through) {
5953
- const throughPlan = buildThroughPlan(relationName, relation, table, targetTableName, targetTable, tableNameOf);
6245
+ const throughPlan = buildThroughPlan(relationName, relation, table, targetTableName, targetTable, tableNameOf, () => warnUnresolvedJoin(relEntry, tableName, relationName, omitted));
5954
6246
  if (throughPlan) tablePlans[relationName] = throughPlan;
5955
6247
  continue;
5956
6248
  }
5957
6249
  const joinColumns = extractJoinColumns(relEntry, table, targetTable);
5958
- if (!joinColumns) continue;
6250
+ if (!joinColumns) {
6251
+ warnUnresolvedJoin(relEntry, tableName, relationName, omitted);
6252
+ continue;
6253
+ }
5959
6254
  const { localColPropName, foreignCol, foreignColPropName } = joinColumns;
5960
6255
  const localCol = getColumns(table)[localColPropName];
5961
6256
  if (!localCol) continue;