@vantreeseba/drizzle-graphql 15.0.0 → 15.1.1

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.d.cts CHANGED
@@ -449,7 +449,7 @@ type DrizzleErrorCode =
449
449
  'DRIZZLE_INVALID_DISTINCT' |
450
450
  /** An `onConflict` named a target or update column the upsert cannot use. */
451
451
  'DRIZZLE_INVALID_ON_CONFLICT' |
452
- /** A written value could not be converted to what its column stores. */
452
+ /** A written value could not be converted to what its column stores, or a `limit` / `offset` was negative. */
453
453
  'DRIZZLE_INVALID_INPUT_VALUE' |
454
454
  /** An update's `set` gave a column that cannot hold null an explicit null. */
455
455
  'DRIZZLE_NOT_NULL' |
@@ -458,7 +458,9 @@ type DrizzleErrorCode =
458
458
  /** A stored value could not be represented by the scalar that transports it. */
459
459
  'DRIZZLE_UNREPRESENTABLE_VALUE' |
460
460
  /** The request's shared mutation transaction was rolled back, so this field never ran. */
461
- 'DRIZZLE_TRANSACTION_ABORTED';
461
+ 'DRIZZLE_TRANSACTION_ABORTED' |
462
+ /** A relation was read on a path it cannot be loaded through (see the message). */
463
+ 'DRIZZLE_UNSUPPORTED_RELATION';
462
464
  /**
463
465
  * What an error was about, published as `extensions.drizzle` beside the code — the runtime
464
466
  * half of the `extensions.drizzle` markers a generated field carries.
@@ -607,8 +609,11 @@ type ColumnDescriber = (column: Column, info: ColumnDocInfo) => string | undefin
607
609
  * `BuildSchemaConfig.deprecateColumn`.
608
610
  */
609
611
  type ColumnDeprecator = (column: Column, info: ColumnDocInfo) => string | undefined;
610
- /** Supplies the GraphQL `description` for a table's object type. See `BuildSchemaConfig.describeTable`. */
611
- type TableDescriber = (tableName: string) => string | undefined;
612
+ /**
613
+ * Supplies the GraphQL `description` for a table's object type. Receives the Drizzle table
614
+ * itself as well as its schema key. See `BuildSchemaConfig.describeTable`.
615
+ */
616
+ type TableDescriber = (tableName: string, table: Table) => string | undefined;
612
617
  /**
613
618
  * Supplies the GraphQL `description` for a relation field. See
614
619
  * `BuildSchemaConfig.describeRelation`.
@@ -2444,6 +2449,17 @@ type BuildSchemaConfig = {
2444
2449
  * ```ts
2445
2450
  * buildSchema(db, { describeTable: (tableName) => tableDocs[tableName] });
2446
2451
  * ```
2452
+ *
2453
+ * The second argument is the Drizzle table itself, so documentation attached to the table
2454
+ * where it is declared can be read straight off it:
2455
+ *
2456
+ * ```ts
2457
+ * const tableDocs = new WeakMap<object, string>();
2458
+ * export const Users = pgTable('users', { ... });
2459
+ * tableDocs.set(Users, 'A person who can sign in.');
2460
+ *
2461
+ * buildSchema(db, { describeTable: (_tableName, table) => tableDocs.get(table) });
2462
+ * ```
2447
2463
  */
2448
2464
  describeTable?: TableDescriber;
2449
2465
  /**
package/dist/index.d.ts CHANGED
@@ -449,7 +449,7 @@ type DrizzleErrorCode =
449
449
  'DRIZZLE_INVALID_DISTINCT' |
450
450
  /** An `onConflict` named a target or update column the upsert cannot use. */
451
451
  'DRIZZLE_INVALID_ON_CONFLICT' |
452
- /** A written value could not be converted to what its column stores. */
452
+ /** A written value could not be converted to what its column stores, or a `limit` / `offset` was negative. */
453
453
  'DRIZZLE_INVALID_INPUT_VALUE' |
454
454
  /** An update's `set` gave a column that cannot hold null an explicit null. */
455
455
  'DRIZZLE_NOT_NULL' |
@@ -458,7 +458,9 @@ type DrizzleErrorCode =
458
458
  /** A stored value could not be represented by the scalar that transports it. */
459
459
  'DRIZZLE_UNREPRESENTABLE_VALUE' |
460
460
  /** The request's shared mutation transaction was rolled back, so this field never ran. */
461
- 'DRIZZLE_TRANSACTION_ABORTED';
461
+ 'DRIZZLE_TRANSACTION_ABORTED' |
462
+ /** A relation was read on a path it cannot be loaded through (see the message). */
463
+ 'DRIZZLE_UNSUPPORTED_RELATION';
462
464
  /**
463
465
  * What an error was about, published as `extensions.drizzle` beside the code — the runtime
464
466
  * half of the `extensions.drizzle` markers a generated field carries.
@@ -607,8 +609,11 @@ type ColumnDescriber = (column: Column, info: ColumnDocInfo) => string | undefin
607
609
  * `BuildSchemaConfig.deprecateColumn`.
608
610
  */
609
611
  type ColumnDeprecator = (column: Column, info: ColumnDocInfo) => string | undefined;
610
- /** Supplies the GraphQL `description` for a table's object type. See `BuildSchemaConfig.describeTable`. */
611
- type TableDescriber = (tableName: string) => string | undefined;
612
+ /**
613
+ * Supplies the GraphQL `description` for a table's object type. Receives the Drizzle table
614
+ * itself as well as its schema key. See `BuildSchemaConfig.describeTable`.
615
+ */
616
+ type TableDescriber = (tableName: string, table: Table) => string | undefined;
612
617
  /**
613
618
  * Supplies the GraphQL `description` for a relation field. See
614
619
  * `BuildSchemaConfig.describeRelation`.
@@ -2444,6 +2449,17 @@ type BuildSchemaConfig = {
2444
2449
  * ```ts
2445
2450
  * buildSchema(db, { describeTable: (tableName) => tableDocs[tableName] });
2446
2451
  * ```
2452
+ *
2453
+ * The second argument is the Drizzle table itself, so documentation attached to the table
2454
+ * where it is declared can be read straight off it:
2455
+ *
2456
+ * ```ts
2457
+ * const tableDocs = new WeakMap<object, string>();
2458
+ * export const Users = pgTable('users', { ... });
2459
+ * tableDocs.set(Users, 'A person who can sign in.');
2460
+ *
2461
+ * buildSchema(db, { describeTable: (_tableName, table) => tableDocs.get(table) });
2462
+ * ```
2447
2463
  */
2448
2464
  describeTable?: TableDescriber;
2449
2465
  /**
package/dist/index.js CHANGED
@@ -1592,13 +1592,20 @@ const nullableOps = {
1592
1592
  isNull,
1593
1593
  isNotNull
1594
1594
  };
1595
+ /** Operators whose operand is an on/off flag rather than a value: `false` adds no condition. */
1596
+ const FLAG_OPERATORS = /* @__PURE__ */ new Set([
1597
+ "isNull",
1598
+ "isNotNull",
1599
+ "isEmpty"
1600
+ ]);
1595
1601
  const extractFiltersColumn = (column, columnName, operators) => {
1596
1602
  const { OR, AND, NOT, insensitive, ...restOperators } = operators;
1597
1603
  const foldCase = insensitive === true;
1598
1604
  const entries = Object.entries(restOperators);
1599
1605
  const variants = [];
1600
1606
  for (const [operatorName, operatorValue] of entries) {
1601
- if (operatorValue === null || operatorValue === false) continue;
1607
+ if (operatorValue === null || operatorValue === void 0) continue;
1608
+ if (operatorValue === false && FLAG_OPERATORS.has(operatorName)) continue;
1602
1609
  if (operatorName in singleValueOps) {
1603
1610
  const singleValue = remapFromGraphQLCore(operatorValue, column, columnName);
1604
1611
  variants.push(foldCase ? lowerComparison(column, operatorName, singleValue) : singleValueOps[operatorName](column, singleValue));
@@ -1789,6 +1796,48 @@ const extractRelationJoinColumns = (relEntry, parentTable, targetTable) => {
1789
1796
  };
1790
1797
  };
1791
1798
  /**
1799
+ * The junction table and its two columns for a `.through()` (many-to-many) relation:
1800
+ * `sourceCol` holds the parent's key, `targetCol` the target's. `undefined` for a relation
1801
+ * with no junction; `null` for one whose junction cannot be used here — more than one column
1802
+ * on either side, or columns that are not on the junction table.
1803
+ */
1804
+ const extractJunctionColumns = (relEntry) => {
1805
+ const rel = relEntry.relation ?? relEntry;
1806
+ const junction = rel?.throughTable;
1807
+ if (!junction) return;
1808
+ const sourceEntries = rel.through?.source;
1809
+ const targetEntries = rel.through?.target;
1810
+ if (sourceEntries?.length !== 1 || targetEntries?.length !== 1) return null;
1811
+ const junctionCols = Object.values(getColumns(junction));
1812
+ const sourceCol = unwrapRelationColumn(sourceEntries[0]);
1813
+ const targetCol = unwrapRelationColumn(targetEntries[0]);
1814
+ if (!sourceCol || !targetCol || !junctionCols.includes(sourceCol) || !junctionCols.includes(targetCol)) return null;
1815
+ return {
1816
+ table: junction,
1817
+ sourceCol,
1818
+ targetCol
1819
+ };
1820
+ };
1821
+ /**
1822
+ * The relation's own declared `where` as a condition on a query that reads the target table,
1823
+ * or `undefined` when it has none. The eager path gets this from drizzle's `with:`; the batched
1824
+ * field resolver and `<relation>Aggregate` read the target table directly, so they have to add
1825
+ * it themselves or they return rows the relation hides.
1826
+ *
1827
+ * A reversed filter (inherited from the relation on the other side) is written against the
1828
+ * parent table, so it is applied as "the row's parent key belongs to a parent that passes it".
1829
+ * `parentKeyCol` is the parent's join column and `groupCol` the column in the query that holds
1830
+ * the parent key for each row — the target's foreign key, or the junction's source column.
1831
+ */
1832
+ const relationOwnCondition = (relEntry, parentTable, targetTable, parentKeyCol, groupCol) => {
1833
+ const rel = relEntry.relation ?? relEntry;
1834
+ const ownWhere = relationOwnWhere(rel);
1835
+ if (!ownWhere) return;
1836
+ if (!isRelationFilterReversed(rel)) return relationsFilterToSQL(targetTable, ownWhere);
1837
+ const parentFilter = relationsFilterToSQL(parentTable, ownWhere);
1838
+ return sql`${groupCol} in (select ${parentKeyCol} from ${parentTable} where ${parentFilter})`;
1839
+ };
1840
+ /**
1792
1841
  * Whether a to-one relation can back a relation orderBy hop. `.through()` (junction)
1793
1842
  * relations are excluded — the ordering subquery only joins the direct target, never the
1794
1843
  * junction table. (Relation *filters* do support `.through()` — this guard is orderBy-only.)
@@ -2690,6 +2739,45 @@ const cursorOrderExprs = (table, entries) => {
2690
2739
  return entries.flatMap(([column, direction, nulls]) => orderExpressions(cols[column], direction, nulls ?? void 0, cols[column]));
2691
2740
  };
2692
2741
  /**
2742
+ * Property a row carries an ordering column's full-precision text under, when the column holds
2743
+ * more than a JS Date can. Namespaced like {@link ROW_CURSOR_PROP}, and removed from the row
2744
+ * once its cursor is encoded.
2745
+ */
2746
+ const preciseTextProp = (column) => `__drizzle_graphql_cursor_text_${column}`;
2747
+ /**
2748
+ * Whether a column reads into a JS Date (milliseconds) while the database keeps finer
2749
+ * fractional seconds: a PostgreSQL `timestamp` / `timestamptz` in `mode: 'date'` (precision 6
2750
+ * unless it says otherwise) or a MySQL `datetime` / `timestamp` in `mode: 'date'` with
2751
+ * `fsp` above 3. String-mode columns already arrive as the database's own text, and `time` is
2752
+ * always a string, so neither loses anything.
2753
+ */
2754
+ const outgrowsJsDate = (column) => {
2755
+ if (is(column, PgTimestamp)) return column.precision === void 0 || column.precision > 3;
2756
+ if (is(column, MySqlDateTime) || is(column, MySqlTimestamp)) return (column.fsp ?? 0) > 3;
2757
+ return false;
2758
+ };
2759
+ /**
2760
+ * Extra SQL expressions a cursor-computing query selects alongside the row: for each ordering
2761
+ * column whose values a JS Date would truncate, the value as the database's own text. The
2762
+ * cursor then encodes that text rather than the Date, and `after` compares the column against
2763
+ * it — so two rows a microsecond apart stay distinct positions. `table` may be the aliased RQB
2764
+ * proxy. Empty when no ordering column needs it.
2765
+ */
2766
+ const cursorPreciseExtras = (table, entries) => {
2767
+ const cols = getColumns(table);
2768
+ const extras = {};
2769
+ for (const [name] of entries) {
2770
+ const column = cols[name];
2771
+ if (column && outgrowsJsDate(column)) extras[preciseTextProp(name)] = is(column, PgTimestamp) ? sql`${column}::text` : sql`cast(${column} as char)`;
2772
+ }
2773
+ return extras;
2774
+ };
2775
+ /**
2776
+ * {@link cursorPreciseExtras} aliased under their property names, for a `select({...})` that
2777
+ * may be wrapped in a subquery (where a bare SQL expression would have no name).
2778
+ */
2779
+ const cursorPreciseSelection = (table, entries) => Object.fromEntries(Object.entries(cursorPreciseExtras(table, entries)).map(([prop, expression]) => [prop, expression.as(prop)]));
2780
+ /**
2693
2781
  * Serializes one ordering-tuple value into a JSON-safe shape. Dates and bigints don't survive
2694
2782
  * JSON.stringify losslessly (bigint throws, Date turns into an untagged string), so they are
2695
2783
  * tagged; decimals already arrive from the driver as strings, which are lossless as-is.
@@ -2724,7 +2812,11 @@ const cursorEncoder = (entries) => {
2724
2812
  const spec = JSON.stringify(entries);
2725
2813
  const columns = entries.map(([column]) => column);
2726
2814
  return (row) => {
2727
- const values = JSON.stringify(columns.map((column) => encodeCursorValue(row[column] ?? null)));
2815
+ const values = JSON.stringify(columns.map((column) => {
2816
+ const value = row[column] ?? null;
2817
+ const precise = row[preciseTextProp(column)];
2818
+ return value !== null && typeof precise === "string" ? precise : encodeCursorValue(value);
2819
+ }));
2728
2820
  return Buffer.from(`{"o":${spec},"v":${values}}`, "utf8").toString("base64url");
2729
2821
  };
2730
2822
  };
@@ -2817,7 +2909,11 @@ const selectsCursorField = (info, table) => {
2817
2909
  */
2818
2910
  const attachRowCursors = (rows, entries) => {
2819
2911
  const encode = cursorEncoder(entries);
2820
- for (const row of rows) row[ROW_CURSOR_PROP] = encode(row);
2912
+ const preciseProps = entries.map(([column]) => preciseTextProp(column));
2913
+ for (const row of rows) {
2914
+ row[ROW_CURSOR_PROP] = encode(row);
2915
+ for (const prop of preciseProps) delete row[prop];
2916
+ }
2821
2917
  };
2822
2918
  /** Resolver for the `cursor` meta field: reads the value a list resolver attached, if any. */
2823
2919
  const rowCursorResolver = (source) => source?.[ROW_CURSOR_PROP] ?? null;
@@ -2952,6 +3048,11 @@ const getUniqueColumnSets = (table, getTableConfig) => {
2952
3048
  //#endregion
2953
3049
  //#region src/util/builders/common/distinct.ts
2954
3050
  /**
3051
+ * Alias the parent key comes back under when the pass runs through a junction table. See
3052
+ * `selectDistinctKeys`' `junction` parameter.
3053
+ */
3054
+ const DISTINCT_PARENT = "__dgql_distinct_parent";
3055
+ /**
2955
3056
  * Keeps the first row of each distinct combination of the requested columns, following the
2956
3057
  * query's own ordering, then applies `limit`/`offset` to what survives — and returns the
2957
3058
  * surviving rows' primary key values in that order.
@@ -2964,9 +3065,14 @@ const getUniqueColumnSets = (table, getTableConfig) => {
2964
3065
  * `partitionBy` prepends extra columns to the window's partition. A to-many relation field
2965
3066
  * uses it to pass the foreign key, which makes the pass distinct *within each parent* rather
2966
3067
  * than across the whole batch — the same meaning `distinct` has on a root list.
3068
+ *
3069
+ * `junction` is for a many-to-many relation: the target is joined to the junction `on` the
3070
+ * given condition, and each returned key also carries the parent it was kept for, under
3071
+ * {@link DISTINCT_PARENT}. A target row linked to several parents can be kept for one and
3072
+ * dropped for another, so a key alone cannot narrow such a batch.
2967
3073
  */
2968
3074
  const selectDistinctKeys = async (params) => {
2969
- const { db, table, tableName, distinct, pkNames, where, orderBy, limit, offset, partitionBy } = params;
3075
+ const { db, table, tableName, distinct, pkNames, where, orderBy, limit, offset, partitionBy, junction } = params;
2970
3076
  const cols = getColumns(table);
2971
3077
  if (!pkNames.length) throw drizzleError(`Table ${tableName} has no primary key, so 'distinct' cannot be applied to it.`, { code: "DRIZZLE_INVALID_DISTINCT" });
2972
3078
  const requestedCols = distinct.map((name) => cols[name]).filter(Boolean);
@@ -2975,12 +3081,17 @@ const selectDistinctKeys = async (params) => {
2975
3081
  const orderEntries = orderBy ? orderByEntries(orderBy) : [];
2976
3082
  const windowOrder = [...orderEntries.flatMap(([column, direction, nulls]) => orderExpressions(cols[column], direction, nulls, cols[column])), ...primaryKeyOrderExprs(table, pkNames)];
2977
3083
  const rowNumber = sql`row_number() over (partition by ${sql.join(partitionCols, sql`, `)} order by ${sql.join(windowOrder, sql`, `)})`.as(DISTINCT_RN);
2978
- const sub = db.select({
3084
+ const selected = db.select({
2979
3085
  ...cols,
3086
+ ...junction ? { [DISTINCT_PARENT]: junction.parentKey } : {},
2980
3087
  [DISTINCT_RN]: rowNumber
2981
- }).from(table).where(where).as("__dgql_distinct");
3088
+ }).from(table);
3089
+ const sub = (junction ? selected.innerJoin(junction.table, junction.on) : selected).where(where).as("__dgql_distinct");
2982
3090
  const outerOrder = [...orderEntries.flatMap(([column, direction, nulls]) => orderExpressions(sub[column], direction, nulls, cols[column])), ...pkNames.filter((name) => sub[name]).map((name) => asc(sub[name]))];
2983
- let query = db.select(Object.fromEntries(pkNames.map((name) => [name, sub[name]]))).from(sub).where(eq(sub[DISTINCT_RN], 1)).orderBy(...outerOrder);
3091
+ let query = db.select({
3092
+ ...Object.fromEntries(pkNames.map((name) => [name, sub[name]])),
3093
+ ...junction ? { [DISTINCT_PARENT]: sub[DISTINCT_PARENT] } : {}
3094
+ }).from(sub).where(eq(sub[DISTINCT_RN], 1)).orderBy(...outerOrder);
2984
3095
  if (offset) query = query.offset(offset);
2985
3096
  if (limit != null) query = query.limit(limit);
2986
3097
  return await query;
@@ -3160,6 +3271,21 @@ const withDefaultOrderBy = (args, tableName, defaults) => {
3160
3271
  } : args;
3161
3272
  };
3162
3273
  /**
3274
+ * Rejects a negative `limit` or `offset`. Neither is a page size or a position, and the
3275
+ * dialects disagree on what one means: SQLite reads `LIMIT -1` as "no limit" — which would walk
3276
+ * straight past `maxLimit` — while PostgreSQL and MySQL fail with a raw database error. Applied
3277
+ * whether or not the table has a limit policy.
3278
+ */
3279
+ const assertPagingNotNegative = (paging, errorCtx) => {
3280
+ for (const name of ["limit", "offset"]) {
3281
+ const value = paging[name];
3282
+ if (value != null && value < 0) throw drizzleError(`'${name}' must not be negative, got ${value}.`, {
3283
+ code: "DRIZZLE_INVALID_INPUT_VALUE",
3284
+ ...errorCtx
3285
+ });
3286
+ }
3287
+ };
3288
+ /**
3163
3289
  * The `limit` a query actually runs with, given the policy for the table it reads.
3164
3290
  *
3165
3291
  * A request that passes no `limit` falls back to `defaultLimit`, or to `maxLimit` when only
@@ -3171,6 +3297,7 @@ const withDefaultOrderBy = (args, tableName, defaults) => {
3171
3297
  * fail a client's query over.
3172
3298
  */
3173
3299
  const applyLimitPolicy = (limit, policy, errorCtx) => {
3300
+ assertPagingNotNegative({ limit }, errorCtx);
3174
3301
  if (!policy) return limit ?? void 0;
3175
3302
  const { defaultLimit, maxLimit, clampToMax } = policy;
3176
3303
  if (limit == null) {
@@ -3688,6 +3815,11 @@ const extractRelationsParams = (relationMap, tables, tableName, info, typeName,
3688
3815
  thisRecord.columns = columns;
3689
3816
  const relationArgs = is(relEntry.relation, One) ? field.args : withDefaultOrderBy(field.args ?? {}, targetTableName, defaultOrderBy);
3690
3817
  const offset = relationArgs?.offset ?? void 0;
3818
+ assertPagingNotNegative({ offset }, {
3819
+ table: targetTableName,
3820
+ operation: "relation",
3821
+ relation: relName
3822
+ });
3691
3823
  const limit = is(relEntry.relation, One) ? relationArgs?.limit ?? void 0 : applyLimitPolicy(relationArgs?.limit, limits?.(targetTableName), {
3692
3824
  table: targetTableName,
3693
3825
  operation: "relation",
@@ -3731,6 +3863,10 @@ const pruneNonEagerRelations = (relationMap, shouldEagerLoad) => {
3731
3863
  const runRelationalSelect = async (opts) => {
3732
3864
  const { queryBase, tables, tableName, table, relationMap, typeName, typeNameMapper, parsedInfo, offset, orderBy, where, single, filterCtx, pkNames, after, scope, deleted } = opts;
3733
3865
  const distinct = opts.distinct?.length ? opts.distinct : void 0;
3866
+ assertPagingNotNegative({
3867
+ limit: opts.limit,
3868
+ offset
3869
+ });
3734
3870
  const cursorSelected = !single && isCursorFieldSelected(parsedInfo.fieldsByTypeName[typeName], table);
3735
3871
  let cursorEntries;
3736
3872
  if (!single && (after != null || cursorSelected)) {
@@ -3782,7 +3918,11 @@ const runRelationalSelect = async (opts) => {
3782
3918
  const result = await queryBase.findFirst(omitUndefinedKeys(params));
3783
3919
  return result ? remapToGraphQLSingleOutput(result, tableName, table, relationMap) : void 0;
3784
3920
  }
3785
- if (cursorEntries && cursorSelected) for (const [column] of cursorEntries) params.columns[column] = true;
3921
+ if (cursorEntries && cursorSelected) {
3922
+ for (const [column] of cursorEntries) params.columns[column] = true;
3923
+ const preciseProps = Object.keys(cursorPreciseExtras(table, cursorEntries));
3924
+ if (preciseProps.length) params.extras = Object.fromEntries(preciseProps.map((prop) => [prop, (aliasedTable) => cursorPreciseExtras(aliasedTable, cursorEntries)[prop]]));
3925
+ }
3786
3926
  params.limit = distinctKeys ? void 0 : opts.limit;
3787
3927
  const result = await queryBase.findMany(omitUndefinedKeys(params));
3788
3928
  if (cursorEntries && cursorSelected) attachRowCursors(result, cursorEntries);
@@ -4601,16 +4741,17 @@ const identifyRows = (field, args) => {
4601
4741
  * Window functions require PostgreSQL, MySQL >= 8.0, or SQLite >= 3.25.
4602
4742
  * Returns raw rows (NOT remapped); the caller groups + remaps them.
4603
4743
  */
4604
- const batchedPaginatedRelationQuery = async (db, targetTable, foreignCol, whereCondition, orderByArg, limit, offset, pkNames, columns, orderCtx, whereArgs) => {
4744
+ const batchedPaginatedRelationQuery = async (db, targetTable, foreignCol, whereCondition, orderByArg, limit, offset, pkNames, columns, orderCtx, whereArgs, junction) => {
4605
4745
  const cols = columns ?? getColumns(targetTable);
4606
4746
  const orderExprs = [...orderByArg ? extractOrderBy(targetTable, orderByArg, orderCtx, whereArgs) : [], ...primaryKeyOrderExprs(targetTable, pkNames)];
4607
4747
  const orderClause = orderExprs.length ? sql` order by ${sql.join(orderExprs, sql`, `)}` : sql``;
4608
4748
  const RN = "__drizzle_graphql_rn";
4609
- const rowNumber = sql`row_number() over (partition by ${foreignCol}${orderClause})`.as(RN);
4610
- const sub = db.select({
4749
+ const partitionCol = junction ? junction.sourceCol : foreignCol;
4750
+ const rowNumber = sql`row_number() over (partition by ${partitionCol}${orderClause})`.as(RN);
4751
+ const sub = withJunction(db.select({
4611
4752
  ...cols,
4612
4753
  [RN]: rowNumber
4613
- }).from(targetTable).where(whereCondition).as("__paginated");
4754
+ }).from(targetTable), junction, foreignCol).where(whereCondition).as("__paginated");
4614
4755
  const lower = offset ?? 0;
4615
4756
  const windowConds = [gt(sub[RN], lower)];
4616
4757
  if (limit != null) windowConds.push(lte(sub[RN], lower + limit));
@@ -4619,6 +4760,14 @@ const batchedPaginatedRelationQuery = async (db, targetTable, foreignCol, whereC
4619
4760
  return rows;
4620
4761
  };
4621
4762
  /**
4763
+ * Alias the parent key is selected under when a relation goes through a junction table. The
4764
+ * key lives on the junction, not the target, so it has to be selected explicitly to group the
4765
+ * rows by parent — and a target row linked to several parents comes back once per parent.
4766
+ */
4767
+ const PARENT_KEY = "__drizzle_graphql_parent";
4768
+ /** `from(target)`, joined to the junction table when the relation has one. */
4769
+ const withJunction = (query, junction, foreignCol) => junction ? query.innerJoin(junction.table, eq(junction.targetCol, foreignCol)) : query;
4770
+ /**
4622
4771
  * Creates a RelationResolverFactory that generates field-level resolvers for each relation.
4623
4772
  * Each resolver:
4624
4773
  * 1. Returns pre-fetched data if the parent resolver already included it (eager path, zero cost).
@@ -4640,6 +4789,10 @@ const createRelationResolverFactory = (db, tables, nullOrdering, filterCtx, limi
4640
4789
  const joinCols = extractRelationJoinColumns(relEntry, parentTable, targetTable);
4641
4790
  if (!joinCols) return;
4642
4791
  const { localColPropName, foreignCol, foreignColPropName } = joinCols;
4792
+ const junction = extractJunctionColumns(relEntry);
4793
+ const groupCol = junction ? junction.sourceCol : foreignCol;
4794
+ const groupKey = junction ? PARENT_KEY : foreignColPropName;
4795
+ const ownCondition = relationOwnCondition(relEntry, parentTable, targetTable, getColumns(parentTable)[localColPropName], groupCol);
4643
4796
  const defaultDeleted = relationDeletedDefault(policies?.softDelete, targetTableName, isOne && relEntry.relation?.optional === false);
4644
4797
  const limitPolicy = isOne ? void 0 : limits?.(targetTableName);
4645
4798
  const targetPkNames = relEntry.targetPkNames ?? [];
@@ -4673,7 +4826,10 @@ const createRelationResolverFactory = (db, tables, nullOrdering, filterCtx, limi
4673
4826
  const column = targetColumns[columnName];
4674
4827
  if (column) columns[columnName] = column;
4675
4828
  }
4676
- return columns;
4829
+ return cursorEntries ? {
4830
+ ...columns,
4831
+ ...cursorPreciseSelection(targetTable, cursorEntries)
4832
+ } : columns;
4677
4833
  };
4678
4834
  const resolve = async (parent, args, context, info) => {
4679
4835
  if (parent[relationName] !== void 0) return parent[relationName];
@@ -4683,6 +4839,7 @@ const createRelationResolverFactory = (db, tables, nullOrdering, filterCtx, limi
4683
4839
  const { orderByArg, limit, distinct, cursorEntries, cursorValues, columns, loaderKey } = getOrCreateRequestValue(context, info?.fieldNodes?.[0], `relation:${tableName}::${relationName}`, () => {
4684
4840
  const orderByArg = isOne ? args?.orderBy : withDefaultOrderBy(args ?? {}, targetTableName, policies?.defaultOrderBy).orderBy;
4685
4841
  const limit = applyLimitPolicy(requestedLimit, limitPolicy, errorCtx);
4842
+ assertPagingNotNegative({ offset }, errorCtx);
4686
4843
  const distinct = (args ?? {}).distinct?.length ? (args ?? {}).distinct : void 0;
4687
4844
  const cursorSelected = !isOne && !!info && selectsCursorField(info, targetTable);
4688
4845
  let cursorEntries;
@@ -4717,11 +4874,12 @@ const createRelationResolverFactory = (db, tables, nullOrdering, filterCtx, limi
4717
4874
  loaderKey: `${tableName}::${relationName}::${argsKey}`
4718
4875
  };
4719
4876
  });
4877
+ if (junction === null) throw drizzleError(`Relation '${tableName}.${relationName}' goes through a junction with a composite key, which can only be loaded together with its parent. Leave it to eager loading (eagerLoadRelations).`, { code: "DRIZZLE_UNSUPPORTED_RELATION" });
4720
4878
  return getOrCreateLoader(context, loaderKey, async (parentIds) => {
4721
4879
  const executor = resolveExecutor(db, context);
4722
4880
  const uniqueIds = [...new Set(parentIds)];
4723
4881
  const scope = resolveScope(policies, context, filterCtx);
4724
- let whereCondition = withScope(scope, targetTableName, targetTable, and(inArray(foreignCol, uniqueIds), whereArg ? extractFilters(targetTable, targetTableName, whereArg, relationFilterCtx(filterCtx, targetTableName)) : void 0, cursorValues ? buildCursorCondition(targetTable, cursorEntries, cursorValues, nullOrdering) : void 0), deleted ?? defaultDeleted);
4882
+ let whereCondition = withScope(scope, targetTableName, targetTable, and(inArray(groupCol, uniqueIds), ownCondition, whereArg ? extractFilters(targetTable, targetTableName, whereArg, relationFilterCtx(filterCtx, targetTableName)) : void 0, cursorValues ? buildCursorCondition(targetTable, cursorEntries, cursorValues, nullOrdering) : void 0), deleted ?? defaultDeleted);
4725
4883
  if (distinct) {
4726
4884
  const keys = await selectDistinctKeys({
4727
4885
  db: executor,
@@ -4731,27 +4889,41 @@ const createRelationResolverFactory = (db, tables, nullOrdering, filterCtx, limi
4731
4889
  pkNames: targetPkNames,
4732
4890
  where: whereCondition,
4733
4891
  orderBy: orderByArg,
4734
- partitionBy: [foreignCol]
4892
+ partitionBy: [groupCol],
4893
+ junction: junction ? {
4894
+ table: junction.table,
4895
+ on: eq(junction.targetCol, foreignCol),
4896
+ parentKey: groupCol
4897
+ } : void 0
4735
4898
  });
4736
4899
  if (!keys.length) return parentIds.map(() => isOne ? null : []);
4737
- whereCondition = and(whereCondition, primaryKeyRestriction(targetTable, targetPkNames, keys));
4900
+ whereCondition = and(whereCondition, junction ? or(...keys.map((key) => and(eq(groupCol, key[DISTINCT_PARENT]), primaryKeyRestriction(targetTable, targetPkNames, [key])))) : primaryKeyRestriction(targetTable, targetPkNames, keys));
4738
4901
  }
4902
+ const selection = junction ? {
4903
+ ...columns ?? getColumns(targetTable),
4904
+ [PARENT_KEY]: groupCol
4905
+ } : columns;
4739
4906
  let rows;
4740
- if (limit != null || offset != null) rows = await batchedPaginatedRelationQuery(executor, targetTable, foreignCol, whereCondition, orderByArg, limit ?? null, offset ?? null, targetPkNames, columns, relationFilterCtx(filterCtx, targetTableName), whereArg);
4907
+ if (limit != null || offset != null) rows = await batchedPaginatedRelationQuery(executor, targetTable, foreignCol, whereCondition, orderByArg, limit ?? null, offset ?? null, targetPkNames, selection, relationFilterCtx(filterCtx, targetTableName), whereArg, junction);
4741
4908
  else {
4742
- let q = (columns ? executor.select(columns) : executor.select()).from(targetTable).where(whereCondition);
4909
+ let q = withJunction((selection ? executor.select(selection) : executor.select()).from(targetTable), junction, foreignCol).where(whereCondition);
4743
4910
  const orderExprs = cursorEntries ? cursorOrderExprs(targetTable, cursorEntries) : orderByArg ? extractOrderBy(targetTable, orderByArg, relationFilterCtx(filterCtx, targetTableName), whereArg) : [];
4744
4911
  if (orderExprs.length) q = q.orderBy(...orderExprs);
4745
4912
  rows = await q;
4746
4913
  }
4747
4914
  if (cursorEntries) attachRowCursors(rows, cursorEntries);
4915
+ const keyOf = (row) => {
4916
+ const key = String(row[groupKey]);
4917
+ if (junction) delete row[PARENT_KEY];
4918
+ return key;
4919
+ };
4748
4920
  if (isOne) {
4749
- const byKey = new Map(rows.map((row) => [String(row[foreignColPropName]), row]));
4921
+ const byKey = new Map(rows.map((row) => [keyOf(row), row]));
4750
4922
  remapToGraphQLArrayOutput(rows, targetTableName, targetTable);
4751
4923
  return parentIds.map((id) => byKey.get(String(id)) ?? null);
4752
4924
  }
4753
4925
  const grouped = new Map(uniqueIds.map((id) => [String(id), []]));
4754
- for (const row of rows) grouped.get(String(row[foreignColPropName]))?.push(row);
4926
+ for (const row of rows) grouped.get(keyOf(row))?.push(row);
4755
4927
  remapToGraphQLArrayOutput(rows, targetTableName, targetTable);
4756
4928
  return parentIds.map((id) => grouped.get(String(id)) ?? []);
4757
4929
  }).load(localValue);
@@ -5086,7 +5258,7 @@ const generateSelectFields = (tables, tableName, relationMap, fromTableName, fro
5086
5258
  });
5087
5259
  const shell = new GraphQLObjectType({
5088
5260
  name: typeName,
5089
- description: cacheCtx.docs.describeTable?.(tableName),
5261
+ description: cacheCtx.docs.describeTable?.(tableName, table),
5090
5262
  fields: () => ({
5091
5263
  ...tableFields,
5092
5264
  ...container.fields
@@ -5120,7 +5292,7 @@ const generateSelectFields = (tables, tableName, relationMap, fromTableName, fro
5120
5292
  defaultName: resolveTypeName(targetTableName, typeNameMapper),
5121
5293
  table: targetTableName
5122
5294
  }),
5123
- description: cacheCtx.docs.describeTable?.(targetTableName),
5295
+ description: cacheCtx.docs.describeTable?.(targetTableName, targetTable),
5124
5296
  fields: () => ({
5125
5297
  ...targetTableFields,
5126
5298
  ...capturedTargetContainer.fields
@@ -5271,7 +5443,7 @@ const generateTableTypes = (tableName, tables, relationMap, withReturning, relat
5271
5443
  defaultName: resolveTypeName(tableName, typeNameMapper),
5272
5444
  table: tableName
5273
5445
  }),
5274
- description: cacheCtx.docs.describeTable?.(tableName),
5446
+ description: cacheCtx.docs.describeTable?.(tableName, tables[tableName]),
5275
5447
  fields: {
5276
5448
  ...tableFields,
5277
5449
  ...relationFields
@@ -5838,6 +6010,10 @@ const createRelationAggregateFactory = (db, tables, cacheCtx, typeNameMapper, fi
5838
6010
  return;
5839
6011
  }
5840
6012
  const { localColPropName, foreignCol } = joinCols;
6013
+ const junction = extractJunctionColumns(relEntry);
6014
+ if (junction === null) return;
6015
+ const groupCol = junction ? junction.sourceCol : foreignCol;
6016
+ const ownCondition = relationOwnCondition(relEntry, parentTable, targetTable, getColumns(parentTable)[localColPropName], groupCol);
5841
6017
  const defaultDeleted = relationDeletedDefault(policies?.softDelete, targetTableName, false);
5842
6018
  const targetTypeName = resolveTypeName(targetTableName, typeNameMapper);
5843
6019
  const type = generateAggregateTypes(targetTable, targetTableName, targetTypeName, cacheCtx);
@@ -5868,11 +6044,12 @@ const createRelationAggregateFactory = (db, tables, cacheCtx, typeNameMapper, fi
5868
6044
  const loader = getOrCreateLoader(context, loaderKey, async (parentIds) => {
5869
6045
  const executor = resolveExecutor(db, context);
5870
6046
  const uniqueIds = [...new Set(parentIds)];
5871
- const whereCondition = withScope(resolveScope(policies, context, filterCtx), targetTableName, targetTable, and(inArray(foreignCol, uniqueIds), whereArg ? extractFilters(targetTable, targetTableName, whereArg, relationFilterCtx(filterCtx, targetTableName)) : void 0), deleted ?? defaultDeleted);
5872
- const rows = await executor.select({
5873
- [GROUP_KEY]: foreignCol,
6047
+ const whereCondition = withScope(resolveScope(policies, context, filterCtx), targetTableName, targetTable, and(inArray(groupCol, uniqueIds), ownCondition, whereArg ? extractFilters(targetTable, targetTableName, whereArg, relationFilterCtx(filterCtx, targetTableName)) : void 0), deleted ?? defaultDeleted);
6048
+ const selected = executor.select({
6049
+ [GROUP_KEY]: groupCol,
5874
6050
  ...request.selection
5875
- }).from(targetTable).where(whereCondition).groupBy(foreignCol);
6051
+ }).from(targetTable);
6052
+ const rows = await (junction ? selected.innerJoin(junction.table, eq(junction.targetCol, foreignCol)) : selected).where(whereCondition).groupBy(groupCol);
5876
6053
  const byKey = new Map(rows.map((row) => [row[GROUP_KEY], row]));
5877
6054
  return parentIds.map((id) => byKey.get(id) ?? {});
5878
6055
  });