@vantreeseba/drizzle-graphql 7.2.0 → 7.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -402,6 +402,124 @@ A **to-many** relation takes a `some` / `none` / `every` wrapper
402
402
  `users(where: { roles: { some: { name: { eq: "admin" } } } })` works the same as a direct
403
403
  to-many relation
404
404
 
405
+ ## Matching lists of values
406
+
407
+ `inArray` / `notInArray` take a list of candidate values and compile to SQL `IN` / `NOT IN`.
408
+ Two things about them are worth stating explicitly.
409
+
410
+ **An empty list is answered, not rejected.** A caller building a filter from a variable often
411
+ ends up with an empty list, and that list has a well-defined meaning: nothing is `IN ()` and
412
+ everything is `NOT IN ()`.
413
+
414
+ ```graphql
415
+ {
416
+ # no rows
417
+ posts(where: { id: { inArray: [] } }) {
418
+ id
419
+ }
420
+
421
+ # every row
422
+ posts(where: { id: { notInArray: [] } }) {
423
+ id
424
+ }
425
+ }
426
+ ```
427
+
428
+ The same rule applies to the Postgres array-column operators: `hasSome: []` matches nothing
429
+ (overlapping with no elements is never true) and `hasEvery: []` matches everything (every
430
+ array contains all zero of them).
431
+
432
+ **The list's own order can be the sort order.** SQL returns `IN` matches in whatever order it
433
+ likes, so a caller that fetches rows by a ranked list of ids normally has to re-sort them
434
+ client-side. Setting `matchFilterOrder` on an `orderBy` entry sorts by the row's position in
435
+ that column's `inArray` list instead of by the column's value:
436
+
437
+ ```graphql
438
+ {
439
+ posts(
440
+ where: { id: { inArray: [7, 3, 12] } }
441
+ orderBy: { id: { direction: asc, priority: 1, matchFilterOrder: true } }
442
+ ) {
443
+ id # → 7, 3, 12
444
+ }
445
+ }
446
+ ```
447
+
448
+ - `direction: asc` keeps the list's order; `desc` reverses it
449
+ - It needs an `inArray` filter on the same column, at the top level of `where` — an
450
+ `inArray` buried inside an `OR` branch raises an error rather than being guessed at
451
+ - It interleaves with the other `orderBy` entries by `priority` like any ordering key, and
452
+ works on relation fields as well as root lists
453
+ - It compiles to a `CASE` ladder over the list values, so it behaves identically on all
454
+ three dialects and every value stays a bound parameter
455
+ - It cannot be combined with `after` or `distinct`: a row's position in the request's own
456
+ filter list is not a value of the row, so it cannot be encoded into a cursor or rebuilt
457
+ against a subquery. Selecting `cursor` under it yields `null` rather than an error
458
+
459
+ ## Filtering inside JSON documents
460
+
461
+ `json` / `jsonb` columns get a `path` filter that compares the value at one path inside the
462
+ document, alongside the whole-document `eq` / `ne` / `contains` operators:
463
+
464
+ ```graphql
465
+ {
466
+ users(where: { meta: { path: { path: ["profile", "level"], gt: 2 } } }) {
467
+ id
468
+ }
469
+ }
470
+ ```
471
+
472
+ `path` walks the document key by key from the root; an all-digits key indexes an array
473
+ (`["tags", "0"]`). Several predicates may be given at once and are `AND`ed:
474
+
475
+ ```graphql
476
+ {
477
+ users(
478
+ where: {
479
+ meta: {
480
+ path: [
481
+ { path: ["profile", "role"], eq: "admin" }
482
+ { path: ["profile", "level"], gte: 3 }
483
+ ]
484
+ }
485
+ }
486
+ ) {
487
+ id
488
+ }
489
+ }
490
+ ```
491
+
492
+ Available operators are `eq` / `ne` / `lt` / `lte` / `gt` / `gte`, the safe string matchers
493
+ `startsWith` / `endsWith` / `contains` (plus their case-insensitive `i`-prefixed forms), and
494
+ `isNull` / `isNotNull`. A missing key and a JSON `null` both read as null.
495
+
496
+ **How the value is compared** is decided by the operand: a GraphQL number compares
497
+ numerically, a boolean as a boolean, and anything else as text. Pass `as: TEXT | NUMBER |
498
+ BOOLEAN` when the operand's type doesn't match the document's — comparing a numeric field
499
+ against a `String` variable, say:
500
+
501
+ ```graphql
502
+ {
503
+ users(where: { meta: { path: { path: ["profile", "level"], as: NUMBER, eq: "3" } } }) {
504
+ id
505
+ }
506
+ }
507
+ ```
508
+
509
+ A value of the wrong shape never matches rather than failing the query: `gt: 0` against a
510
+ document whose path holds `"admin"` returns no rows on every dialect. That takes a guard on
511
+ each one — Postgres errors outright on a bad `::numeric`, MySQL quietly casts a non-numeric
512
+ string to `0`, and SQLite's cross-type ordering would otherwise sort every string above every
513
+ number.
514
+
515
+ Note that `contains` means different things at the two levels, because a path names a scalar:
516
+ on the column it is structural JSON containment (Postgres `@>` / MySQL `JSON_CONTAINS`),
517
+ inside a `path` filter it is substring matching on the extracted value.
518
+
519
+ Path filters combine with `AND` / `OR` / `NOT` like any other operator, and are accepted
520
+ everywhere a filter is — list, single and aggregate queries, `update` / `delete`, and the
521
+ `where` argument on a relation field.
522
+
405
523
  ## Aggregate queries
406
524
 
407
525
  Every table also gets an aggregate query field — `<tableName>Aggregate` (e.g. `usersAggregate`),
@@ -642,8 +760,8 @@ field, and passing it back as `after` resumes strictly after that row.
642
760
  - Dates and bigints round-trip losslessly through the cursor
643
761
  - A cursor is only valid under the ordering it was issued for — reusing it with a
644
762
  different `orderBy` (or a malformed/corrupted cursor) returns a `GraphQLError`
645
- - `after` cannot be combined with `distinct`, and a table with no primary key cannot use
646
- cursor pagination (its `cursor` field resolves to `null`)
763
+ - `after` cannot be combined with `distinct` or with `matchFilterOrder`, and a table with
764
+ no primary key cannot use cursor pagination (its `cursor` field resolves to `null`)
647
765
  - If a table has a real column named `cursor`, the column wins and the meta field is
648
766
  skipped
649
767
 
package/dist/README.md CHANGED
@@ -402,6 +402,124 @@ A **to-many** relation takes a `some` / `none` / `every` wrapper
402
402
  `users(where: { roles: { some: { name: { eq: "admin" } } } })` works the same as a direct
403
403
  to-many relation
404
404
 
405
+ ## Matching lists of values
406
+
407
+ `inArray` / `notInArray` take a list of candidate values and compile to SQL `IN` / `NOT IN`.
408
+ Two things about them are worth stating explicitly.
409
+
410
+ **An empty list is answered, not rejected.** A caller building a filter from a variable often
411
+ ends up with an empty list, and that list has a well-defined meaning: nothing is `IN ()` and
412
+ everything is `NOT IN ()`.
413
+
414
+ ```graphql
415
+ {
416
+ # no rows
417
+ posts(where: { id: { inArray: [] } }) {
418
+ id
419
+ }
420
+
421
+ # every row
422
+ posts(where: { id: { notInArray: [] } }) {
423
+ id
424
+ }
425
+ }
426
+ ```
427
+
428
+ The same rule applies to the Postgres array-column operators: `hasSome: []` matches nothing
429
+ (overlapping with no elements is never true) and `hasEvery: []` matches everything (every
430
+ array contains all zero of them).
431
+
432
+ **The list's own order can be the sort order.** SQL returns `IN` matches in whatever order it
433
+ likes, so a caller that fetches rows by a ranked list of ids normally has to re-sort them
434
+ client-side. Setting `matchFilterOrder` on an `orderBy` entry sorts by the row's position in
435
+ that column's `inArray` list instead of by the column's value:
436
+
437
+ ```graphql
438
+ {
439
+ posts(
440
+ where: { id: { inArray: [7, 3, 12] } }
441
+ orderBy: { id: { direction: asc, priority: 1, matchFilterOrder: true } }
442
+ ) {
443
+ id # → 7, 3, 12
444
+ }
445
+ }
446
+ ```
447
+
448
+ - `direction: asc` keeps the list's order; `desc` reverses it
449
+ - It needs an `inArray` filter on the same column, at the top level of `where` — an
450
+ `inArray` buried inside an `OR` branch raises an error rather than being guessed at
451
+ - It interleaves with the other `orderBy` entries by `priority` like any ordering key, and
452
+ works on relation fields as well as root lists
453
+ - It compiles to a `CASE` ladder over the list values, so it behaves identically on all
454
+ three dialects and every value stays a bound parameter
455
+ - It cannot be combined with `after` or `distinct`: a row's position in the request's own
456
+ filter list is not a value of the row, so it cannot be encoded into a cursor or rebuilt
457
+ against a subquery. Selecting `cursor` under it yields `null` rather than an error
458
+
459
+ ## Filtering inside JSON documents
460
+
461
+ `json` / `jsonb` columns get a `path` filter that compares the value at one path inside the
462
+ document, alongside the whole-document `eq` / `ne` / `contains` operators:
463
+
464
+ ```graphql
465
+ {
466
+ users(where: { meta: { path: { path: ["profile", "level"], gt: 2 } } }) {
467
+ id
468
+ }
469
+ }
470
+ ```
471
+
472
+ `path` walks the document key by key from the root; an all-digits key indexes an array
473
+ (`["tags", "0"]`). Several predicates may be given at once and are `AND`ed:
474
+
475
+ ```graphql
476
+ {
477
+ users(
478
+ where: {
479
+ meta: {
480
+ path: [
481
+ { path: ["profile", "role"], eq: "admin" }
482
+ { path: ["profile", "level"], gte: 3 }
483
+ ]
484
+ }
485
+ }
486
+ ) {
487
+ id
488
+ }
489
+ }
490
+ ```
491
+
492
+ Available operators are `eq` / `ne` / `lt` / `lte` / `gt` / `gte`, the safe string matchers
493
+ `startsWith` / `endsWith` / `contains` (plus their case-insensitive `i`-prefixed forms), and
494
+ `isNull` / `isNotNull`. A missing key and a JSON `null` both read as null.
495
+
496
+ **How the value is compared** is decided by the operand: a GraphQL number compares
497
+ numerically, a boolean as a boolean, and anything else as text. Pass `as: TEXT | NUMBER |
498
+ BOOLEAN` when the operand's type doesn't match the document's — comparing a numeric field
499
+ against a `String` variable, say:
500
+
501
+ ```graphql
502
+ {
503
+ users(where: { meta: { path: { path: ["profile", "level"], as: NUMBER, eq: "3" } } }) {
504
+ id
505
+ }
506
+ }
507
+ ```
508
+
509
+ A value of the wrong shape never matches rather than failing the query: `gt: 0` against a
510
+ document whose path holds `"admin"` returns no rows on every dialect. That takes a guard on
511
+ each one — Postgres errors outright on a bad `::numeric`, MySQL quietly casts a non-numeric
512
+ string to `0`, and SQLite's cross-type ordering would otherwise sort every string above every
513
+ number.
514
+
515
+ Note that `contains` means different things at the two levels, because a path names a scalar:
516
+ on the column it is structural JSON containment (Postgres `@>` / MySQL `JSON_CONTAINS`),
517
+ inside a `path` filter it is substring matching on the extracted value.
518
+
519
+ Path filters combine with `AND` / `OR` / `NOT` like any other operator, and are accepted
520
+ everywhere a filter is — list, single and aggregate queries, `update` / `delete`, and the
521
+ `where` argument on a relation field.
522
+
405
523
  ## Aggregate queries
406
524
 
407
525
  Every table also gets an aggregate query field — `<tableName>Aggregate` (e.g. `usersAggregate`),
@@ -642,8 +760,8 @@ field, and passing it back as `after` resumes strictly after that row.
642
760
  - Dates and bigints round-trip losslessly through the cursor
643
761
  - A cursor is only valid under the ordering it was issued for — reusing it with a
644
762
  different `orderBy` (or a malformed/corrupted cursor) returns a `GraphQLError`
645
- - `after` cannot be combined with `distinct`, and a table with no primary key cannot use
646
- cursor pagination (its `cursor` field resolves to `null`)
763
+ - `after` cannot be combined with `distinct` or with `matchFilterOrder`, and a table with
764
+ no primary key cannot use cursor pagination (its `cursor` field resolves to `null`)
647
765
  - If a table has a real column named `cursor`, the column wins and the meta field is
648
766
  skipped
649
767
 
package/dist/index.cjs CHANGED
@@ -996,10 +996,10 @@ var runMutation = async (db, context, info, txCtx, body) => {
996
996
  }
997
997
  return runOnSharedTx(state, body);
998
998
  };
999
- var batchedPaginatedRelationQuery = async (db, targetTable, foreignCol, whereCondition, orderByArg, limit, offset, pkNames, orderCtx) => {
999
+ var batchedPaginatedRelationQuery = async (db, targetTable, foreignCol, whereCondition, orderByArg, limit, offset, pkNames, orderCtx, whereArgs) => {
1000
1000
  const cols = (0, import_drizzle_orm4.getColumns)(targetTable);
1001
1001
  const orderExprs = [
1002
- ...orderByArg ? extractOrderBy(targetTable, orderByArg, orderCtx) : [],
1002
+ ...orderByArg ? extractOrderBy(targetTable, orderByArg, orderCtx, whereArgs) : [],
1003
1003
  ...primaryKeyOrderExprs(targetTable, pkNames)
1004
1004
  ];
1005
1005
  const orderClause = orderExprs.length ? import_drizzle_orm4.sql` order by ${import_drizzle_orm4.sql.join(orderExprs, import_drizzle_orm4.sql`, `)}` : import_drizzle_orm4.sql``;
@@ -1110,11 +1110,12 @@ var createRelationResolverFactory = (db, tables, nullOrdering, filterCtx, limits
1110
1110
  limit ?? null,
1111
1111
  offset ?? null,
1112
1112
  targetPkNames,
1113
- relationFilterCtx(filterCtx, targetTableName)
1113
+ relationFilterCtx(filterCtx, targetTableName),
1114
+ whereArg
1114
1115
  );
1115
1116
  } else {
1116
1117
  let q = executor.select().from(targetTable).where(whereCondition);
1117
- const orderExprs = cursorEntries ? cursorOrderExprs(targetTable, cursorEntries) : orderByArg ? extractOrderBy(targetTable, orderByArg, relationFilterCtx(filterCtx, targetTableName)) : [];
1118
+ const orderExprs = cursorEntries ? cursorOrderExprs(targetTable, cursorEntries) : orderByArg ? extractOrderBy(targetTable, orderByArg, relationFilterCtx(filterCtx, targetTableName), whereArg) : [];
1118
1119
  if (orderExprs.length) {
1119
1120
  q = q.orderBy(...orderExprs);
1120
1121
  }
@@ -1325,6 +1326,10 @@ var innerOrder = new import_graphql5.GraphQLInputObjectType({
1325
1326
  nulls: {
1326
1327
  type: orderNulls,
1327
1328
  description: "Where NULL values sort. Defaults to the database's own rule (PostgreSQL: last on asc, first on desc; MySQL/SQLite: first on asc, last on desc)"
1329
+ },
1330
+ matchFilterOrder: {
1331
+ type: import_graphql5.GraphQLBoolean,
1332
+ description: "Sort by this column's position in the `inArray` list the same request's `where` gives it, rather than by the column's own value \u2014 `direction: asc` keeps the list's order, `desc` reverses it. Requires an `inArray` filter on the same column at the top level of `where`, and cannot be combined with `after` or `distinct`."
1328
1333
  }
1329
1334
  }
1330
1335
  });
@@ -1390,10 +1395,59 @@ var jsonFilterFields = (column, colType) => {
1390
1395
  description: "Value structurally contains this JSON (Postgres `@>` / MySQL JSON_CONTAINS)"
1391
1396
  }
1392
1397
  } : {},
1398
+ path: {
1399
+ type: new import_graphql5.GraphQLList(new import_graphql5.GraphQLNonNull(jsonPathFilter)),
1400
+ description: "Compares the value at one path inside the document. Several entries are ANDed; a single object may be passed without the list brackets."
1401
+ },
1393
1402
  isNull: { type: import_graphql5.GraphQLBoolean },
1394
1403
  isNotNull: { type: import_graphql5.GraphQLBoolean }
1395
1404
  };
1396
1405
  };
1406
+ var jsonPathCast = new import_graphql5.GraphQLEnumType({
1407
+ name: "JSONPathCast",
1408
+ description: "How to read the value at a JSON path before comparing it",
1409
+ values: {
1410
+ TEXT: { value: "text", description: "Compare as text (lexicographic ordering)" },
1411
+ NUMBER: { value: "number", description: "Compare as a number; a non-numeric value never matches" },
1412
+ BOOLEAN: { value: "boolean", description: "Compare as a boolean" }
1413
+ }
1414
+ });
1415
+ var jsonPathFilter = new import_graphql5.GraphQLInputObjectType({
1416
+ name: "JSONPathFilter",
1417
+ fields: {
1418
+ path: {
1419
+ type: new import_graphql5.GraphQLNonNull(new import_graphql5.GraphQLList(new import_graphql5.GraphQLNonNull(import_graphql5.GraphQLString))),
1420
+ description: 'Keys to walk from the document root, e.g. `["profile", "level"]`. An all-digits key indexes an array.'
1421
+ },
1422
+ as: { type: jsonPathCast, description: "Overrides how the value is read before comparing" },
1423
+ eq: { type: import_graphql_scalars.GraphQLJSON, description: "Equal to" },
1424
+ ne: { type: import_graphql_scalars.GraphQLJSON, description: "Not equal to" },
1425
+ lt: { type: import_graphql_scalars.GraphQLJSON, description: "Less than" },
1426
+ lte: { type: import_graphql_scalars.GraphQLJSON, description: "Less than or equal to" },
1427
+ gt: { type: import_graphql_scalars.GraphQLJSON, description: "Greater than" },
1428
+ gte: { type: import_graphql_scalars.GraphQLJSON, description: "Greater than or equal to" },
1429
+ startsWith: {
1430
+ type: import_graphql5.GraphQLString,
1431
+ description: "Extracted value starts with this string. `%`, `_` and `\\` are matched literally."
1432
+ },
1433
+ endsWith: {
1434
+ type: import_graphql5.GraphQLString,
1435
+ description: "Extracted value ends with this string. `%`, `_` and `\\` are matched literally."
1436
+ },
1437
+ contains: {
1438
+ type: import_graphql5.GraphQLString,
1439
+ description: "Extracted value contains this string. `%`, `_` and `\\` are matched literally."
1440
+ },
1441
+ iStartsWith: { type: import_graphql5.GraphQLString, description: "Case-insensitive `startsWith`." },
1442
+ iEndsWith: { type: import_graphql5.GraphQLString, description: "Case-insensitive `endsWith`." },
1443
+ iContains: { type: import_graphql5.GraphQLString, description: "Case-insensitive `contains`." },
1444
+ isNull: {
1445
+ type: import_graphql5.GraphQLBoolean,
1446
+ description: "When true, matches rows where the path is missing or holds JSON null"
1447
+ },
1448
+ isNotNull: { type: import_graphql5.GraphQLBoolean, description: "When true, matches rows where the path holds a value" }
1449
+ }
1450
+ });
1397
1451
  var IN_ARRAY_DESCRIPTION = "Matches any one of these values (SQL `IN`)";
1398
1452
  var NOT_IN_ARRAY_DESCRIPTION = "Matches none of these values (SQL `NOT IN`)";
1399
1453
  var arrayFilterFields = (colType, colArr) => {
@@ -1987,6 +2041,9 @@ var orderByEntries = (orderArgs) => Object.entries(orderArgs).sort((a, b) => (b[
1987
2041
  if (typeof config === "object" && config.direction === void 0) {
1988
2042
  throw new import_graphql5.GraphQLError(`ORDER BY ${column}: ordering through a relation is not supported in this query`);
1989
2043
  }
2044
+ if (config.matchFilterOrder) {
2045
+ throw new import_graphql5.GraphQLError(`ORDER BY ${column}: 'matchFilterOrder' is not supported in this query`);
2046
+ }
1990
2047
  return [column, config.direction, config.nulls ?? void 0];
1991
2048
  });
1992
2049
  var orderExpressions = (expr, direction, nulls, dialectColumn) => {
@@ -2021,7 +2078,22 @@ var extendRelationOrderChain = (parentTable, relationName, relEntry, ctx, chain)
2021
2078
  table: aliasedTarget
2022
2079
  };
2023
2080
  };
2024
- var collectOrderEntries = (table, tableKey, orderArgs, ctx, chain, out) => {
2081
+ var filterOrderExpression = (column, columnName, whereArgs) => {
2082
+ const values = whereArgs?.[columnName]?.inArray;
2083
+ if (!Array.isArray(values)) {
2084
+ throw new import_graphql5.GraphQLError(
2085
+ `ORDER BY ${columnName}: 'matchFilterOrder' needs an 'inArray' filter on the same column in this query's 'where'`
2086
+ );
2087
+ }
2088
+ if (!values.length) {
2089
+ return import_drizzle_orm4.sql`0`;
2090
+ }
2091
+ const branches = values.map(
2092
+ (value, index) => import_drizzle_orm4.sql`when ${column} = ${remapFromGraphQLCore(value, column, columnName)} then ${import_drizzle_orm4.sql.raw(String(index))}`
2093
+ );
2094
+ return import_drizzle_orm4.sql`case ${import_drizzle_orm4.sql.join(branches, import_drizzle_orm4.sql` `)} else ${import_drizzle_orm4.sql.raw(String(values.length))} end`;
2095
+ };
2096
+ var collectOrderEntries = (table, tableKey, orderArgs, ctx, chain, out, whereArgs) => {
2025
2097
  const columns = (0, import_drizzle_orm4.getColumns)(table);
2026
2098
  const relations = ctx?.relationMap[tableKey];
2027
2099
  for (const [key, config] of Object.entries(orderArgs)) {
@@ -2030,8 +2102,11 @@ var collectOrderEntries = (table, tableKey, orderArgs, ctx, chain, out) => {
2030
2102
  }
2031
2103
  const column = columns[key];
2032
2104
  if (column) {
2105
+ if (config.matchFilterOrder && chain) {
2106
+ throw new import_graphql5.GraphQLError(`ORDER BY ${key}: 'matchFilterOrder' is not supported through a relation`);
2107
+ }
2033
2108
  out.push({
2034
- expression: chain ? import_drizzle_orm4.sql`(select ${column} from ${import_drizzle_orm4.sql.join(chain.fromParts, import_drizzle_orm4.sql`, `)} where ${(0, import_drizzle_orm4.and)(...chain.conditions)})` : column,
2109
+ expression: config.matchFilterOrder ? filterOrderExpression(column, key, whereArgs) : chain ? import_drizzle_orm4.sql`(select ${column} from ${import_drizzle_orm4.sql.join(chain.fromParts, import_drizzle_orm4.sql`, `)} where ${(0, import_drizzle_orm4.and)(...chain.conditions)})` : column,
2035
2110
  column,
2036
2111
  direction: config.direction,
2037
2112
  nulls: config.nulls ?? void 0,
@@ -2044,12 +2119,12 @@ var collectOrderEntries = (table, tableKey, orderArgs, ctx, chain, out) => {
2044
2119
  throw new import_graphql5.GraphQLError(`ORDER BY ${key}: Unknown column or relation`);
2045
2120
  }
2046
2121
  const nextChain = extendRelationOrderChain(chain?.table ?? table, key, relEntry, ctx, chain);
2047
- collectOrderEntries(nextChain.table, relEntry.targetTableName, config, ctx, nextChain, out);
2122
+ collectOrderEntries(nextChain.table, relEntry.targetTableName, config, ctx, nextChain, out, void 0);
2048
2123
  }
2049
2124
  };
2050
- var extractOrderBy = (table, orderArgs, ctx) => {
2125
+ var extractOrderBy = (table, orderArgs, ctx, whereArgs) => {
2051
2126
  const entries = [];
2052
- collectOrderEntries(table, ctx?.tableKey ?? "", orderArgs, ctx, void 0, entries);
2127
+ collectOrderEntries(table, ctx?.tableKey ?? "", orderArgs, ctx, void 0, entries, whereArgs);
2053
2128
  return entries.sort((a, b) => b.priority - a.priority).flatMap((entry) => orderExpressions(entry.expression, entry.direction, entry.nulls, entry.column));
2054
2129
  };
2055
2130
  var LIKE_ESCAPE_CHAR = "\\";
@@ -2081,6 +2156,105 @@ var jsonContains = (column, columnName, value) => {
2081
2156
  throw new import_graphql5.GraphQLError(`WHERE ${columnName}: Operator 'contains' is not supported for this dialect!`);
2082
2157
  }
2083
2158
  };
2159
+ var sqlFalse = () => import_drizzle_orm4.sql`1 = 0`;
2160
+ var sqlTrue = () => import_drizzle_orm4.sql`1 = 1`;
2161
+ var jsonPathString = (path) => `$${path.map((part) => /^\d+$/.test(part) ? `[${part}]` : `."${part.replace(/(["\\])/g, "\\$1")}"`).join("")}`;
2162
+ var NUMERIC_TEXT_PATTERN = "^\\s*-?(\\d+\\.?\\d*|\\.\\d+)([eE][-+]?\\d+)?\\s*$";
2163
+ var jsonPathExprs = (column, path) => {
2164
+ switch (columnDialect(column)) {
2165
+ case "pg": {
2166
+ const pathArray = import_drizzle_orm4.sql`array[${import_drizzle_orm4.sql.join(
2167
+ path.map((part) => import_drizzle_orm4.sql`${part}`),
2168
+ import_drizzle_orm4.sql`, `
2169
+ )}]::text[]`;
2170
+ const text = import_drizzle_orm4.sql`(${column} #>> ${pathArray})`;
2171
+ return {
2172
+ text,
2173
+ number: import_drizzle_orm4.sql`(case when ${text} ~ ${NUMERIC_TEXT_PATTERN} then ${text}::numeric end)`,
2174
+ boolean: text,
2175
+ encodeBoolean: (value) => String(value)
2176
+ };
2177
+ }
2178
+ case "mysql": {
2179
+ const extracted = import_drizzle_orm4.sql`json_extract(${column}, ${jsonPathString(path)})`;
2180
+ const text = import_drizzle_orm4.sql`(case when json_type(${extracted}) = 'NULL' then null else json_unquote(${extracted}) end)`;
2181
+ return {
2182
+ text,
2183
+ number: import_drizzle_orm4.sql`(case when ${text} regexp ${NUMERIC_TEXT_PATTERN} then cast(${text} as decimal(65, 30)) end)`,
2184
+ boolean: text,
2185
+ encodeBoolean: (value) => String(value)
2186
+ };
2187
+ }
2188
+ default: {
2189
+ const extracted = import_drizzle_orm4.sql`json_extract(${column}, ${jsonPathString(path)})`;
2190
+ return {
2191
+ text: import_drizzle_orm4.sql`cast(${extracted} as text)`,
2192
+ number: import_drizzle_orm4.sql`(case when typeof(${extracted}) in ('integer', 'real') then ${extracted} end)`,
2193
+ // SQLite has no boolean type: a JSON `true` comes back as the integer 1.
2194
+ boolean: extracted,
2195
+ encodeBoolean: (value) => value ? 1 : 0
2196
+ };
2197
+ }
2198
+ }
2199
+ };
2200
+ var JSON_PATH_COMPARISONS = {
2201
+ eq: "=",
2202
+ ne: "<>",
2203
+ lt: "<",
2204
+ lte: "<=",
2205
+ gt: ">",
2206
+ gte: ">="
2207
+ };
2208
+ var inferJsonPathCast = (value) => typeof value === "number" ? "number" : typeof value === "boolean" ? "boolean" : "text";
2209
+ var jsonPathCondition = (column, columnName, filter) => {
2210
+ const { path, as: castOverride, ...operators } = filter;
2211
+ const locator = `${columnName}.path`;
2212
+ if (!Array.isArray(path) || !path.length) {
2213
+ throw new import_graphql5.GraphQLError(`WHERE ${locator}: 'path' must name at least one key`);
2214
+ }
2215
+ const exprs = jsonPathExprs(column, path);
2216
+ const variants = [];
2217
+ for (const [operatorName, operatorValue] of Object.entries(operators)) {
2218
+ if (operatorValue === null || operatorValue === void 0) {
2219
+ continue;
2220
+ }
2221
+ if (operatorName === "isNull" || operatorName === "isNotNull") {
2222
+ if (operatorValue === false) {
2223
+ continue;
2224
+ }
2225
+ variants.push(operatorName === "isNull" ? import_drizzle_orm4.sql`${exprs.text} is null` : import_drizzle_orm4.sql`${exprs.text} is not null`);
2226
+ continue;
2227
+ }
2228
+ if (operatorName in safeLikeOps) {
2229
+ const { buildPattern, insensitive } = safeLikeOps[operatorName];
2230
+ if (typeof operatorValue !== "string") {
2231
+ throw new import_graphql5.GraphQLError(`WHERE ${locator}: operator '${operatorName}' takes a string`);
2232
+ }
2233
+ variants.push(safeLikeCondition(exprs.text, buildPattern(operatorValue), insensitive));
2234
+ continue;
2235
+ }
2236
+ const comparison = JSON_PATH_COMPARISONS[operatorName];
2237
+ if (!comparison) {
2238
+ throw new import_graphql5.GraphQLError(`WHERE ${locator}: Unknown operator: ${operatorName}`);
2239
+ }
2240
+ const cast = castOverride ?? inferJsonPathCast(operatorValue);
2241
+ if (cast === "number") {
2242
+ const numeric = Number(operatorValue);
2243
+ if (Number.isNaN(numeric)) {
2244
+ throw new import_graphql5.GraphQLError(
2245
+ `WHERE ${locator}: operator '${operatorName}' compares as a number, so its value must be one`
2246
+ );
2247
+ }
2248
+ variants.push(import_drizzle_orm4.sql`${exprs.number} ${import_drizzle_orm4.sql.raw(comparison)} ${numeric}`);
2249
+ } else if (cast === "boolean") {
2250
+ const truthy = operatorValue === true || operatorValue === "true";
2251
+ variants.push(import_drizzle_orm4.sql`${exprs.boolean} ${import_drizzle_orm4.sql.raw(comparison)} ${exprs.encodeBoolean(truthy)}`);
2252
+ } else {
2253
+ variants.push(import_drizzle_orm4.sql`${exprs.text} ${import_drizzle_orm4.sql.raw(comparison)} ${String(operatorValue)}`);
2254
+ }
2255
+ }
2256
+ return variants.length ? variants.length > 1 ? (0, import_drizzle_orm4.and)(...variants) : variants[0] : void 0;
2257
+ };
2084
2258
  var extractFiltersColumn = (column, columnName, operators) => {
2085
2259
  const { OR, AND, NOT, ...restOperators } = operators;
2086
2260
  const entries = Object.entries(restOperators);
@@ -2102,6 +2276,13 @@ var extractFiltersColumn = (column, columnName, operators) => {
2102
2276
  variants.push(singleValueOps[operatorName](column, singleValue));
2103
2277
  } else if (operatorName in stringValueOps) {
2104
2278
  variants.push(stringValueOps[operatorName](column, operatorValue));
2279
+ } else if (operatorName === "path" && isJsonColumn2(column)) {
2280
+ for (const pathFilter of operatorValue) {
2281
+ const extracted = jsonPathCondition(column, columnName, pathFilter);
2282
+ if (extracted) {
2283
+ variants.push(extracted);
2284
+ }
2285
+ }
2105
2286
  } else if (operatorName === "contains" && isJsonColumn2(column)) {
2106
2287
  variants.push(jsonContains(column, columnName, operatorValue));
2107
2288
  } else if (operatorName in safeLikeOps) {
@@ -2109,7 +2290,8 @@ var extractFiltersColumn = (column, columnName, operators) => {
2109
2290
  variants.push(safeLikeCondition(column, buildPattern(operatorValue), insensitive));
2110
2291
  } else if (operatorName in arrayValueOps) {
2111
2292
  if (!operatorValue.length) {
2112
- throw new import_graphql5.GraphQLError(`WHERE ${columnName}: Unable to use operator ${operatorName} with an empty array!`);
2293
+ variants.push(operatorName === "inArray" ? sqlFalse() : sqlTrue());
2294
+ continue;
2113
2295
  }
2114
2296
  const arrayValue = operatorValue.map((val) => remapFromGraphQLCore(val, column, columnName));
2115
2297
  variants.push(arrayValueOps[operatorName](column, arrayValue));
@@ -2117,7 +2299,8 @@ var extractFiltersColumn = (column, columnName, operators) => {
2117
2299
  variants.push((0, import_drizzle_orm4.arrayContains)(column, [operatorValue]));
2118
2300
  } else if (operatorName in arrayMembershipOps) {
2119
2301
  if (!operatorValue.length) {
2120
- throw new import_graphql5.GraphQLError(`WHERE ${columnName}: Unable to use operator ${operatorName} with an empty array!`);
2302
+ variants.push(operatorName === "hasSome" ? sqlFalse() : sqlTrue());
2303
+ continue;
2121
2304
  }
2122
2305
  variants.push(arrayMembershipOps[operatorName](column, operatorValue));
2123
2306
  } else if (operatorName === "isEmpty") {
@@ -2380,7 +2563,7 @@ var extractRelationsParamsInner = (relationMap, tables, tableName, typeName, ori
2380
2563
  } : void 0;
2381
2564
  const hasPagination = offset != null || limit != null;
2382
2565
  const pkNames = targetPkNames ?? [];
2383
- thisRecord.orderBy = relationArgs?.orderBy ? (aliasedTable2) => extractOrderBy(aliasedTable2, relationArgs.orderBy, relationFilterCtx(filterCtx, targetTableName)) : hasPagination && pkNames.length ? (aliasedTable2) => primaryKeyOrderExprs(aliasedTable2, pkNames) : void 0;
2566
+ thisRecord.orderBy = relationArgs?.orderBy ? (aliasedTable2) => extractOrderBy(aliasedTable2, relationArgs.orderBy, relationFilterCtx(filterCtx, targetTableName), relWhere) : hasPagination && pkNames.length ? (aliasedTable2) => primaryKeyOrderExprs(aliasedTable2, pkNames) : void 0;
2384
2567
  thisRecord.offset = offset;
2385
2568
  thisRecord.limit = limit;
2386
2569
  const relWith = relationField ? extractRelationsParamsInner(
@@ -2460,6 +2643,15 @@ var primaryKeyOrderExprs = (table, pkNames) => {
2460
2643
  var CURSOR_FIELD_NAME = "cursor";
2461
2644
  var ROW_CURSOR_PROP = "__drizzle_graphql_cursor";
2462
2645
  var orderByHasRelationEntry = (orderBy) => !!orderBy && Object.values(orderBy).some((config) => config && typeof config === "object" && config.direction === void 0);
2646
+ var orderByCursorObstacle = (orderBy) => {
2647
+ if (orderByHasRelationEntry(orderBy)) {
2648
+ return "'after' cannot be combined with an orderBy that orders through a relation \u2014 a related row's value cannot be encoded into a cursor.";
2649
+ }
2650
+ if (orderBy && Object.values(orderBy).some((config) => config && typeof config === "object" && config.matchFilterOrder)) {
2651
+ return "'after' cannot be combined with 'matchFilterOrder' \u2014 a row's position in the request's own filter list is not a value of the row, so it cannot be encoded into a cursor.";
2652
+ }
2653
+ return void 0;
2654
+ };
2463
2655
  var cursorOrderingEntries = (orderBy, pkNames) => {
2464
2656
  const entries = orderBy ? orderByEntries(orderBy) : [];
2465
2657
  const seen = new Set(entries.map(([column]) => column));
@@ -3056,11 +3248,10 @@ var runRelationalSelect = async (opts) => {
3056
3248
  if (after != null && distinct) {
3057
3249
  throw new import_graphql5.GraphQLError("'after' cannot be combined with 'distinct'.");
3058
3250
  }
3059
- if (orderByHasRelationEntry(orderBy)) {
3251
+ const cursorObstacle = orderByCursorObstacle(orderBy);
3252
+ if (cursorObstacle) {
3060
3253
  if (after != null) {
3061
- throw new import_graphql5.GraphQLError(
3062
- "'after' cannot be combined with an orderBy that orders through a relation \u2014 a related row's value cannot be encoded into a cursor."
3063
- );
3254
+ throw new import_graphql5.GraphQLError(cursorObstacle);
3064
3255
  }
3065
3256
  } else if (!pkNames?.length) {
3066
3257
  if (after != null) {
@@ -3100,13 +3291,13 @@ var runRelationalSelect = async (opts) => {
3100
3291
  // drizzle-orm v1 RQB calls orderBy/where with the aliased table proxy — use it
3101
3292
  // directly so column refs match the CTE alias.
3102
3293
  orderBy: distinctKeys ? (aliasedTable2) => [
3103
- ...orderBy ? extractOrderBy(aliasedTable2, orderBy, relationFilterCtx(filterCtx, tableName)) : [],
3294
+ ...orderBy ? extractOrderBy(aliasedTable2, orderBy, relationFilterCtx(filterCtx, tableName), where) : [],
3104
3295
  ...primaryKeyOrderExprs(aliasedTable2, pkNames)
3105
3296
  ] : cursorEntries ? (
3106
3297
  // Cursor pagination needs a total order: the request's orderBy plus the PK tiebreak,
3107
3298
  // exactly the ordering the cursor encodes and the keyset predicate compares against.
3108
3299
  (aliasedTable2) => cursorOrderExprs(aliasedTable2, cursorEntries)
3109
- ) : orderBy ? (aliasedTable2) => extractOrderBy(aliasedTable2, orderBy, relationFilterCtx(filterCtx, tableName)) : needsDefaultOrder && pkNames?.length ? (aliasedTable2) => primaryKeyOrderExprs(aliasedTable2, pkNames) : void 0,
3300
+ ) : orderBy ? (aliasedTable2) => extractOrderBy(aliasedTable2, orderBy, relationFilterCtx(filterCtx, tableName), where) : needsDefaultOrder && pkNames?.length ? (aliasedTable2) => primaryKeyOrderExprs(aliasedTable2, pkNames) : void 0,
3110
3301
  where: distinctKeys ? { RAW: (aliasedTable2) => primaryKeyRestriction(aliasedTable2, pkNames, distinctKeys) } : where || cursorValues ? {
3111
3302
  RAW: (aliasedTable2) => (0, import_drizzle_orm4.and)(
3112
3303
  where ? extractFilters(aliasedTable2, tableName, where, relationFilterCtx(filterCtx, tableName)) : void 0,
@@ -4763,11 +4954,10 @@ var generateSelectArray2 = (db, tableName, tables, relationMap, orderArgs, filte
4763
4954
  if (after != null && distinct?.length) {
4764
4955
  throw new import_graphql9.GraphQLError("'after' cannot be combined with 'distinct'.");
4765
4956
  }
4766
- if (orderByHasRelationEntry(orderBy)) {
4957
+ const cursorObstacle = orderByCursorObstacle(orderBy);
4958
+ if (cursorObstacle) {
4767
4959
  if (after != null) {
4768
- throw new import_graphql9.GraphQLError(
4769
- "'after' cannot be combined with an orderBy that orders through a relation \u2014 a related row's value cannot be encoded into a cursor."
4770
- );
4960
+ throw new import_graphql9.GraphQLError(cursorObstacle);
4771
4961
  }
4772
4962
  } else if (!pkNames.length) {
4773
4963
  if (after != null) {
@@ -4815,7 +5005,7 @@ var generateSelectArray2 = (db, tableName, tables, relationMap, orderArgs, filte
4815
5005
  q = q.orderBy(...cursorOrderExprs(table, cursorEntries));
4816
5006
  } else if (orderBy) {
4817
5007
  q = q.orderBy(
4818
- ...extractOrderBy(table, orderBy, relationFilterCtx(filterCtx, tableName)),
5008
+ ...extractOrderBy(table, orderBy, relationFilterCtx(filterCtx, tableName), where),
4819
5009
  ...distinctKeys ? primaryKeyOrderExprs(table, pkNames) : []
4820
5010
  );
4821
5011
  } else if ((distinctKeys || offset != null || limit != null) && pkNames.length) {
@@ -4881,7 +5071,7 @@ var generateSelectSingle2 = (db, tableName, tables, relationMap, orderArgs, filt
4881
5071
  q = q.where(extractFilters(table, tableName, where, relationFilterCtx(filterCtx, tableName)));
4882
5072
  }
4883
5073
  if (orderBy) {
4884
- q = q.orderBy(...extractOrderBy(table, orderBy, relationFilterCtx(filterCtx, tableName)));
5074
+ q = q.orderBy(...extractOrderBy(table, orderBy, relationFilterCtx(filterCtx, tableName), where));
4885
5075
  } else if (pkNames.length) {
4886
5076
  q = q.orderBy(...primaryKeyOrderExprs(table, pkNames));
4887
5077
  }