uql-orm 0.37.1 → 0.39.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.
Files changed (85) hide show
  1. package/dist/browser/uql-browser.min.js +2 -2
  2. package/dist/browser/uql-browser.min.js.map +5 -5
  3. package/dist/cockroachdb/cockroachDialect.d.ts +5 -24
  4. package/dist/cockroachdb/cockroachDialect.js +3 -29
  5. package/dist/dialect/abstractSqlDialect.d.ts +42 -12
  6. package/dist/dialect/abstractSqlDialect.js +117 -48
  7. package/dist/dialect/aliases.d.ts +6 -0
  8. package/dist/dialect/aliases.js +6 -0
  9. package/dist/dialect/index.d.ts +0 -5
  10. package/dist/dialect/index.js +2 -5
  11. package/dist/dialect/jsonSql.d.ts +17 -2
  12. package/dist/dialect/jsonSql.js +15 -0
  13. package/dist/dialect/mysqlLikeSqlDialect.d.ts +13 -14
  14. package/dist/dialect/mysqlLikeSqlDialect.js +16 -20
  15. package/dist/dialect/pgLikeSqlDialect.d.ts +17 -20
  16. package/dist/dialect/pgLikeSqlDialect.js +30 -62
  17. package/dist/dialect/vectorCast.d.ts +0 -9
  18. package/dist/dialect/vectorCast.js +0 -8
  19. package/dist/dialect/vectorSqlDialect.d.ts +47 -17
  20. package/dist/dialect/vectorSqlDialect.js +78 -30
  21. package/dist/entity/decorator/entity.d.ts +1 -1
  22. package/dist/entity/metadata/definition.d.ts +1 -1
  23. package/dist/entity/metadata/definition.js +4 -3
  24. package/dist/http/query.js +3 -2
  25. package/dist/libsql/libsqlDialect.d.ts +2 -2
  26. package/dist/libsql/libsqlDialect.js +3 -3
  27. package/dist/maria/mariaDialect.d.ts +12 -17
  28. package/dist/maria/mariaDialect.js +21 -36
  29. package/dist/maria/mariaVectorMetrics.d.ts +8 -0
  30. package/dist/maria/mariaVectorMetrics.js +10 -0
  31. package/dist/maria/mariadbQuerier.d.ts +5 -0
  32. package/dist/maria/mariadbQuerier.js +5 -0
  33. package/dist/migrate/builder/migrationBuilder.d.ts +8 -17
  34. package/dist/migrate/builder/migrationBuilder.js +48 -136
  35. package/dist/migrate/builder/types.d.ts +0 -2
  36. package/dist/migrate/ddl/index.d.ts +11 -0
  37. package/dist/migrate/ddl/index.js +34 -0
  38. package/dist/{dialect/indexSqlDialect.d.ts → migrate/ddl/indexDdl.d.ts} +23 -19
  39. package/dist/migrate/ddl/indexDdl.js +126 -0
  40. package/dist/migrate/ddl/mysqlIndexDdl.d.ts +52 -0
  41. package/dist/migrate/ddl/mysqlIndexDdl.js +125 -0
  42. package/dist/migrate/ddl/pgIndexDdl.d.ts +36 -0
  43. package/dist/migrate/ddl/pgIndexDdl.js +87 -0
  44. package/dist/migrate/drift/driftDetector.js +6 -1
  45. package/dist/migrate/generator/mongoSchemaGenerator.js +1 -1
  46. package/dist/migrate/index.d.ts +1 -0
  47. package/dist/migrate/index.js +2 -0
  48. package/dist/migrate/introspection/mysqlIntrospector.d.ts +9 -3
  49. package/dist/migrate/introspection/mysqlIntrospector.js +27 -5
  50. package/dist/migrate/migrator.js +3 -2
  51. package/dist/migrate/schemaGenerator.d.ts +6 -6
  52. package/dist/migrate/schemaGenerator.js +13 -22
  53. package/dist/mongo/mongoDialect.d.ts +1 -2
  54. package/dist/mongo/mongoDialect.js +29 -22
  55. package/dist/mongo/mongodbQuerier.js +1 -1
  56. package/dist/mysql/mysqlDialect.d.ts +3 -6
  57. package/dist/mysql/mysqlDialect.js +4 -11
  58. package/dist/postgres/postgresDialect.d.ts +2 -0
  59. package/dist/postgres/postgresDialect.js +2 -0
  60. package/dist/querier/abstractSqlQuerier.d.ts +11 -0
  61. package/dist/querier/abstractSqlQuerier.js +35 -0
  62. package/dist/schema/canonicalType.js +35 -36
  63. package/dist/schema/indexDifferences.d.ts +2 -1
  64. package/dist/schema/indexDifferences.js +3 -2
  65. package/dist/sqlite/sqliteDialect.d.ts +2 -2
  66. package/dist/sqlite/sqliteDialect.js +5 -5
  67. package/dist/turso/tursoDialect.d.ts +2 -2
  68. package/dist/turso/tursoDialect.js +4 -4
  69. package/dist/type/dialect.d.ts +21 -7
  70. package/dist/type/dialect.js +17 -0
  71. package/dist/type/entity.d.ts +117 -8
  72. package/dist/type/entity.js +7 -0
  73. package/dist/type/query.d.ts +18 -0
  74. package/dist/type/query.js +6 -0
  75. package/dist/type/queryWhere.d.ts +46 -2
  76. package/dist/type/vector.d.ts +45 -7
  77. package/dist/type/vector.js +16 -1
  78. package/dist/util/dialect.util.d.ts +20 -1
  79. package/dist/util/dialect.util.js +49 -4
  80. package/dist/util/object.util.d.ts +7 -1
  81. package/dist/util/object.util.js +8 -0
  82. package/dist/util/relationQuery.util.d.ts +3 -1
  83. package/dist/util/relationQuery.util.js +8 -3
  84. package/package.json +1 -1
  85. package/dist/dialect/indexSqlDialect.js +0 -103
@@ -1,15 +1,15 @@
1
1
  import { getMeta } from '../entity/index.js';
2
- import { parseQueryLock, QueryRaw, RAW_ALIAS, RAW_VALUE, } from '../type/index.js';
2
+ import { parseQueryLock, QueryRaw, RAW_ALIAS, RAW_VALUE, VECTOR_QUERY_KEYS, } from '../type/index.js';
3
3
  import { asSelectMap, assertNonNegativeInteger, buildQueryWhereAsMap, escapeSqlId, fillOnFields, filterFieldKeys, getInsertFieldKeys, getKeys, getSoftDeleteValue, hasKeys, isBooleanType, isJsonType, isJsonUpdateOp, isNumericType, isOperatorMap, isOperatorObject, isOperatorOnlyObject, isVectorSearch, normalizeScalarFieldSelection, parseGroupMap, parseRelationSize, parseSortByCount, populatesRelations, raw, someValue, throwUnknownAggregateColumn, withoutSoftDeleteFilter, } from '../util/index.js';
4
4
  import { escapeAnsiSqlLiteral, escapeSingleQuotes } from '../util/sqlLiteral.js';
5
5
  import { COUNT_ALIAS, DISTINCT_DERIVED_ALIAS, JSON_ELEM_ALIAS_PREFIX } from './aliases.js';
6
- import { IndexSqlDialect } from './indexSqlDialect.js';
7
6
  import { buildElemMatchConditions } from './jsonArrayElemMatchUtils.js';
8
7
  import { isJsonbOp, jsonCompareMode, jsonElemExists } from './jsonSql.js';
9
8
  import { SqlQueryContext } from './queryContext.js';
10
9
  import { NO_JOINS, resolveQueryJoins, resolveSortableJoin, } from './queryJoins.js';
11
10
  import { isVectorFieldType, resolveVectorCast } from './vectorCast.js';
12
- export class AbstractSqlDialect extends IndexSqlDialect {
11
+ import { VectorSqlDialect } from './vectorSqlDialect.js';
12
+ export class AbstractSqlDialect extends VectorSqlDialect {
13
13
  /**
14
14
  * How this engine declares a namespace, so a generated migration creates the schemas its tables
15
15
  * need before creating them. Only reached where {@link DialectFeatures.schemas} is on. MySQL and
@@ -394,6 +394,17 @@ export class AbstractSqlDialect extends IndexSqlDialect {
394
394
  ['$lt', ' < '],
395
395
  ['$lte', ' <= '],
396
396
  ]);
397
+ /** What a `$near` says about the search itself; everything else in it is a bound. */
398
+ static VECTOR_QUERY_KEYS = new Set(VECTOR_QUERY_KEYS);
399
+ /**
400
+ * The runtime half of {@link QueryVectorNear}'s bounds, derived from the map above rather than
401
+ * spelled again: both are `QueryOrderedOp`, so `$near` can never accept a comparison the renderer
402
+ * below has no operator for.
403
+ */
404
+ static NEAR_BOUND_OPS = new Set([
405
+ ...AbstractSqlDialect.COMPARE_OP_MAP.keys(),
406
+ '$between',
407
+ ]);
397
408
  /**
398
409
  * Every `$like`-family operator: the pattern it wraps its value in, and whether it ignores case.
399
410
  * Each case-sensitive operator is paired here with the `$i` twin that shares its pattern, so the
@@ -479,6 +490,9 @@ export class AbstractSqlDialect extends IndexSqlDialect {
479
490
  case '$elemMatch':
480
491
  ctx.append(this.jsonElemMatch(ctx, field, val));
481
492
  break;
493
+ case '$near':
494
+ this.compareVectorNear(ctx, getMeta(entity), key, val);
495
+ break;
482
496
  default:
483
497
  throw TypeError(`unknown operator: ${op}`);
484
498
  }
@@ -547,34 +561,35 @@ export class AbstractSqlDialect extends IndexSqlDialect {
547
561
  * All dialect-specific behavior comes from overridable methods on `this`.
548
562
  */
549
563
  buildJsonFieldCondition(ctx, fieldAccessor, jsonPath, op, value, asJson = false) {
550
- const jsonField = fieldAccessor(jsonPath);
564
+ const jsonField = fieldAccessor(jsonPath, asJson ? 'json' : 'text');
565
+ // The left side of a comparison reads the path the way its operand is compared: a numeric one
566
+ // cast to a number, a boolean as the JSON value.
567
+ const comparand = (val) => fieldAccessor(jsonPath, jsonCompareMode(val));
551
568
  // The `$like` family reads a JSON path exactly as it reads a column, case folding included.
552
569
  const like = this.likeCondition(ctx, jsonField, op, value);
553
570
  if (like) {
554
571
  return like;
555
572
  }
573
+ // The ordered comparisons read the path as a number whatever the operand is, and spell their
574
+ // operator out of the same table a comparison against a column does.
575
+ const compareOp = AbstractSqlDialect.COMPARE_OP_MAP.get(op);
576
+ if (compareOp) {
577
+ return `${fieldAccessor(jsonPath, 'numeric')}${compareOp}${this.addValue(ctx.values, value)}`;
578
+ }
556
579
  switch (op) {
557
580
  case '$eq':
558
581
  if (value === null)
559
582
  return `${jsonField} IS NULL`;
560
- return `${this.jsonComparand(jsonField, value)} = ${this.jsonOperand(ctx, value, asJson)}`;
583
+ return `${comparand(value)} = ${this.jsonOperand(ctx, value, asJson)}`;
561
584
  case '$ne':
562
585
  if (value === null)
563
586
  return `${jsonField} IS NOT NULL`;
564
- return this.neExpr(this.jsonComparand(jsonField, value), this.jsonOperand(ctx, value, asJson));
565
- case '$gt':
566
- return `${this.numericCast(jsonField)} > ${this.addValue(ctx.values, value)}`;
567
- case '$gte':
568
- return `${this.numericCast(jsonField)} >= ${this.addValue(ctx.values, value)}`;
569
- case '$lt':
570
- return `${this.numericCast(jsonField)} < ${this.addValue(ctx.values, value)}`;
571
- case '$lte':
572
- return `${this.numericCast(jsonField)} <= ${this.addValue(ctx.values, value)}`;
587
+ return this.neExpr(comparand(value), this.jsonOperand(ctx, value, asJson));
573
588
  case '$regex':
574
589
  return `${jsonField} ${this.regexpOp} ${this.addValue(ctx.values, value)}`;
575
590
  case '$in':
576
591
  case '$nin':
577
- return this.jsonInNin(ctx, jsonField, op, value, asJson);
592
+ return this.jsonInNin(ctx, jsonField, comparand, op, value, asJson);
578
593
  case '$all':
579
594
  return this.jsonAll(ctx, jsonField, value);
580
595
  case '$size':
@@ -585,11 +600,11 @@ export class AbstractSqlDialect extends IndexSqlDialect {
585
600
  throw TypeError(`unknown operator: ${op}`);
586
601
  }
587
602
  }
588
- jsonInNin(ctx, jsonField, op, value, asJson) {
603
+ jsonInNin(ctx, jsonField, comparand, op, value, asJson) {
589
604
  const values = Array.isArray(value) ? value : [];
590
605
  const negate = op === '$nin';
591
606
  if (!asJson) {
592
- return `${this.jsonComparand(jsonField, values)}${this.formatIn(ctx, values, negate)}`;
607
+ return `${comparand(values)}${this.formatIn(ctx, values, negate)}`;
593
608
  }
594
609
  // JSON values have no portable array literal, so the set expands into explicit comparisons.
595
610
  const comparisons = values.map((val) => `${jsonField} ${negate ? '<>' : '='} ${this.jsonScalarParam(ctx, val)}`);
@@ -599,13 +614,6 @@ export class AbstractSqlDialect extends IndexSqlDialect {
599
614
  jsonOperand(ctx, value, asJson) {
600
615
  return asJson ? this.jsonScalarParam(ctx, value) : this.addValue(ctx.values, value);
601
616
  }
602
- /**
603
- * The left side of a comparison against a JSON scalar, cast when the operand is numeric - see
604
- * {@link jsonCompareMode} for why each mode exists.
605
- */
606
- jsonComparand(jsonField, value) {
607
- return jsonCompareMode(value) === 'numeric' ? this.numericCast(jsonField) : jsonField;
608
- }
609
617
  /**
610
618
  * Whether the dialect's array containment ({@link jsonAll}) matches an object element that merely
611
619
  * *includes* the given keys, as PostgreSQL's `@>` and MySQL's `JSON_CONTAINS` do. SQLite compares
@@ -634,7 +642,7 @@ export class AbstractSqlDialect extends IndexSqlDialect {
634
642
  const entries = Object.entries(match);
635
643
  const asJson = !this.jsonScalarElemKeepsType && entries.every(([op, val]) => isJsonbOp(op, val));
636
644
  const alias = ctx.nextAlias(JSON_ELEM_ALIAS_PREFIX);
637
- const conditions = entries.map(([op, val]) => this.buildJsonFieldCondition(ctx, () => this.jsonElemRef(alias, undefined, asJson), '', op, val, asJson));
645
+ const conditions = entries.map(([op, val]) => this.buildJsonFieldCondition(ctx, this.elemAccessor(alias, asJson), '', op, val, asJson));
638
646
  return jsonElemExists(this.jsonElemFrom(jsonField, [], alias, asJson), conditions);
639
647
  }
640
648
  if (isOperatorObject(match)) {
@@ -648,10 +656,21 @@ export class AbstractSqlDialect extends IndexSqlDialect {
648
656
  const alias = ctx.nextAlias(JSON_ELEM_ALIAS_PREFIX);
649
657
  const conditions = buildElemMatchConditions(match, (field, op, opVal) => {
650
658
  const asJson = isJsonbOp(op, opVal);
651
- return this.buildJsonFieldCondition(ctx, (f) => this.jsonElemRef(alias, f, asJson), field, op, opVal, asJson);
659
+ return this.buildJsonFieldCondition(ctx, this.elemAccessor(alias, asJson), field, op, opVal, asJson);
652
660
  });
653
661
  return jsonElemExists(this.jsonElemFrom(jsonField, Object.keys(match), alias), conditions);
654
662
  }
663
+ /**
664
+ * How a `$elemMatch` reads one exploded element: {@link jsonPathExpr}'s counterpart, over a column
665
+ * of the derived table rather than a path of the document. An empty field names the element itself,
666
+ * which is what the operator-only form matches on.
667
+ */
668
+ elemAccessor(alias, asJson) {
669
+ return (field, mode) => {
670
+ const ref = this.jsonElemRef(alias, field || undefined, asJson);
671
+ return mode === 'numeric' ? this.numericCast(ref) : ref;
672
+ };
673
+ }
655
674
  /**
656
675
  * A JSON-encoded bound parameter, cast to the dialect's JSON type. Only the positional-placeholder
657
676
  * dialects use this - PostgreSQL binds JSON through {@link PgLikeSqlDialect.jsonScalarParam} instead.
@@ -735,7 +754,8 @@ export class AbstractSqlDialect extends IndexSqlDialect {
735
754
  if (field) {
736
755
  return field.virtual ? this.escapeId(key) : this.columnWithPrefix(key, field, prefix);
737
756
  }
738
- return this.resolveJsonDotPath(meta, key, prefix)?.accessor() ?? this.escapeId(key);
757
+ const json = this.resolveJsonDotPath(meta, key, prefix);
758
+ return json ? this.jsonPathExpr(json.column, json.jsonPath, 'text') : this.escapeId(key);
739
759
  }
740
760
  pager(ctx, opts) {
741
761
  // `!== undefined`, not truthiness: `$limit: 0` asks for no rows, where "unset" means every row.
@@ -1339,20 +1359,29 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1339
1359
  if (!field || !isJsonType(field.type)) {
1340
1360
  return undefined;
1341
1361
  }
1342
- const jsonPath = key.slice(dotIndex + 1);
1343
1362
  const colName = this.resolveColumnName(root, field);
1344
- const escapedCol = (prefix ? this.escapeId(prefix, true, true) : '') + this.escapeId(colName);
1345
- return {
1346
- jsonPath,
1347
- accessor: (asJsonb) => asJsonb ? this.getJsonPathJsonbExpr(escapedCol, jsonPath) : this.getJsonPathScalarExpr(escapedCol, jsonPath),
1348
- };
1363
+ const prefixed = (prefix ? this.escapeId(prefix, true, true) : '') + this.escapeId(colName);
1364
+ return { jsonPath: key.slice(dotIndex + 1), column: prefixed };
1365
+ }
1366
+ /**
1367
+ * One JSON path, read the way `mode` asks for: the one place the three readings are chosen between,
1368
+ * so `$where`, `$sort` and every operator reach a path the same way. Public because a JSON index
1369
+ * is matched back by its own text, so the migrator's `CREATE INDEX` has to spell it from here too.
1370
+ */
1371
+ jsonPathExpr(escapedColumn, jsonPath, mode) {
1372
+ if (mode === 'json') {
1373
+ return this.getJsonPathJsonbExpr(escapedColumn, jsonPath);
1374
+ }
1375
+ const scalar = this.getJsonPathScalarExpr(escapedColumn, jsonPath);
1376
+ return mode === 'numeric' ? this.numericCast(scalar) : scalar;
1349
1377
  }
1350
1378
  /**
1351
1379
  * Compare a JSONB dot-notation path, e.g. `'settings.isArchived': { $ne: true }`.
1352
1380
  * Receives a pre-resolved `resolveJsonDotPath` result to avoid redundant computation.
1353
1381
  */
1354
1382
  compareJsonPath(ctx, resolved, val) {
1355
- const { jsonPath, accessor } = resolved;
1383
+ const { jsonPath, column } = resolved;
1384
+ const accessor = (path, mode) => this.jsonPathExpr(column, path, mode);
1356
1385
  const value = this.normalizeWhereValue(val);
1357
1386
  const operators = getKeys(value);
1358
1387
  if (operators.length > 1) {
@@ -1362,7 +1391,7 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1362
1391
  if (index > 0)
1363
1392
  ctx.append(' AND ');
1364
1393
  const asJson = isJsonbOp(op, value[op]);
1365
- const sql = this.buildJsonFieldCondition(ctx, () => accessor(asJson), jsonPath, op, value[op], asJson);
1394
+ const sql = this.buildJsonFieldCondition(ctx, accessor, jsonPath, op, value[op], asJson);
1366
1395
  if (sql) {
1367
1396
  ctx.append(sql);
1368
1397
  }
@@ -1485,18 +1514,13 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1485
1514
  this.buildSizeComparison(ctx, () => this.appendRelationSubquery(ctx, getMeta(entity), rel, opts, 'COUNT(*)', {}), sizeVal);
1486
1515
  }
1487
1516
  /**
1488
- * Build a complete `$size` comparison expression.
1489
- * Handles both single and multiple comparison operators by repeating the size expression.
1490
- * @param sizeExprFn - function that appends the size expression to ctx (e.g. `jsonb_array_length("col")`)
1517
+ * `<expr> <op> <value>` for each operator, AND-joined and parenthesized when there is more than
1518
+ * one. `exprFn` is re-run per operator because what it appends is an expression, not a column:
1519
+ * a `WHERE` has no output alias to refer back to, so the only way to compare it twice is to spell
1520
+ * it twice. Shared by `$size`, which counts, and `$near`, which measures a distance.
1491
1521
  */
1492
- buildSizeComparison(ctx, sizeExprFn, sizeVal) {
1493
- if (typeof sizeVal === 'number') {
1494
- sizeExprFn();
1495
- ctx.append(' = ');
1496
- ctx.addValue(sizeVal);
1497
- return;
1498
- }
1499
- const entries = Object.entries(sizeVal).filter(([, v]) => v !== undefined);
1522
+ buildExprComparison(ctx, exprFn, ops, appendOp) {
1523
+ const entries = Object.entries(ops).filter(([, v]) => v !== undefined);
1500
1524
  if (entries.length > 1) {
1501
1525
  ctx.append('(');
1502
1526
  }
@@ -1504,13 +1528,58 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1504
1528
  if (index > 0) {
1505
1529
  ctx.append(' AND ');
1506
1530
  }
1507
- sizeExprFn();
1508
- this.appendSizeOp(ctx, op, val);
1531
+ exprFn();
1532
+ appendOp(op, val);
1509
1533
  });
1510
1534
  if (entries.length > 1) {
1511
1535
  ctx.append(')');
1512
1536
  }
1513
1537
  }
1538
+ /**
1539
+ * Build a complete `$size` comparison expression.
1540
+ * @param sizeExprFn - function that appends the size expression to ctx (e.g. `jsonb_array_length("col")`)
1541
+ */
1542
+ buildSizeComparison(ctx, sizeExprFn, sizeVal) {
1543
+ if (typeof sizeVal === 'number') {
1544
+ sizeExprFn();
1545
+ ctx.append(' = ');
1546
+ ctx.addValue(sizeVal);
1547
+ return;
1548
+ }
1549
+ this.buildExprComparison(ctx, sizeExprFn, sizeVal, (op, val) => this.appendSizeOp(ctx, op, val));
1550
+ }
1551
+ /**
1552
+ * `<distance expr> <op> ?` - the `$where` half of vector search, where `$sort` is the ranking half.
1553
+ *
1554
+ * The bounds are validated here rather than left to the shared renderer, which also knows `$like`
1555
+ * and `$in`; and `$eq`/`$ne` are absent on purpose, since a distance is a float. `/http` casts
1556
+ * client JSON straight to `Query`, so an unknown key has to be refused rather than ignored.
1557
+ */
1558
+ compareVectorNear(ctx, meta, key, near) {
1559
+ const bounds = {};
1560
+ for (const [op, val] of Object.entries(near)) {
1561
+ if (AbstractSqlDialect.VECTOR_QUERY_KEYS.has(op) || val === undefined) {
1562
+ continue;
1563
+ }
1564
+ if (!AbstractSqlDialect.NEAR_BOUND_OPS.has(op)) {
1565
+ throw TypeError(`unsupported $near bound: ${op}`);
1566
+ }
1567
+ bounds[op] = val;
1568
+ }
1569
+ if (!hasKeys(bounds)) {
1570
+ const boundOps = [...AbstractSqlDialect.NEAR_BOUND_OPS].join(', ');
1571
+ throw TypeError(`$near on '${key}' needs a bound (${boundOps}); without one it filters nothing`);
1572
+ }
1573
+ // Required by the type, so this only fires for a query that never met it: `/http` casts client
1574
+ // JSON straight to `Query`. A `$near` never borrows the `$sort`'s vector, which is what keeps the
1575
+ // predicate meaning the same thing in a `count`, or in an entity filter merged into a `$where`.
1576
+ const { $vector, $distance } = near;
1577
+ if (!$vector) {
1578
+ throw TypeError(`$near on '${key}' needs its own $vector`);
1579
+ }
1580
+ const search = { $vector, $distance };
1581
+ this.buildExprComparison(ctx, () => this.appendVectorSort(ctx, meta, key, search), bounds, (op, val) => this.appendOperatorCondition(ctx, '', op, val));
1582
+ }
1514
1583
  /** The runtime half of {@link QuerySizeComparisonOps}: what a count can sensibly be compared with. */
1515
1584
  static SIZE_COMPARE_OPS = new Set([
1516
1585
  '$eq',
@@ -22,6 +22,12 @@ export declare const JSON_PULL_ALIAS = "_uql_pull";
22
22
  export declare const REL_TEMP_PREFIX = "_uql_rel_";
23
23
  /** The field a ManyToMany lookup nests its target match under, inside the junction's own pipeline. */
24
24
  export declare const REL_NESTED_KEY = "_uql_target";
25
+ /**
26
+ * The alias MySQL's upsert gives the row being inserted, so its `ON DUPLICATE KEY UPDATE`
27
+ * assignments read `_uql_new.col` instead of the deprecated `VALUES(col)`. MariaDB has no such
28
+ * syntax and keeps `VALUES(col)`.
29
+ */
30
+ export declare const UPSERT_NEW_ROW_ALIAS = "_uql_new";
25
31
  /**
26
32
  * Where a `$sort` by a relation's size parks its tally until the ordering has run. A function, so the
27
33
  * `$sort` that names the field and the stage that produces it cannot spell it differently - MongoDB
@@ -22,6 +22,12 @@ export const JSON_PULL_ALIAS = '_uql_pull';
22
22
  export const REL_TEMP_PREFIX = '_uql_rel_';
23
23
  /** The field a ManyToMany lookup nests its target match under, inside the junction's own pipeline. */
24
24
  export const REL_NESTED_KEY = '_uql_target';
25
+ /**
26
+ * The alias MySQL's upsert gives the row being inserted, so its `ON DUPLICATE KEY UPDATE`
27
+ * assignments read `_uql_new.col` instead of the deprecated `VALUES(col)`. MariaDB has no such
28
+ * syntax and keeps `VALUES(col)`.
29
+ */
30
+ export const UPSERT_NEW_ROW_ALIAS = '_uql_new';
25
31
  /**
26
32
  * Where a `$sort` by a relation's size parks its tally until the ordering has run. A function, so the
27
33
  * `$sort` that names the field and the stage that produces it cannot spell it differently - MongoDB
@@ -1,9 +1,4 @@
1
- export { MariaDialect } from '../maria/mariaDialect.js';
2
- export { MySqlDialect } from '../mysql/mysqlDialect.js';
3
- export { PostgresDialect } from '../postgres/postgresDialect.js';
4
- export { SqliteDialect } from '../sqlite/sqliteDialect.js';
5
1
  export * from './abstractDialect.js';
6
2
  export * from './abstractSqlDialect.js';
7
- export * from './indexSqlDialect.js';
8
3
  export * from './mysqlLikeSqlDialect.js';
9
4
  export * from './queryContext.js';
@@ -1,9 +1,6 @@
1
- export { MariaDialect } from '../maria/mariaDialect.js';
2
- export { MySqlDialect } from '../mysql/mysqlDialect.js';
3
- export { PostgresDialect } from '../postgres/postgresDialect.js';
4
- export { SqliteDialect } from '../sqlite/sqliteDialect.js';
1
+ // The concrete dialects are behind their own entries (`uql-orm/postgres`, `/mysql`, `/maria`,
2
+ // `/sqlite`, `/cockroachdb`): importing the root should not carry four engines' worth of SQL.
5
3
  export * from './abstractDialect.js';
6
4
  export * from './abstractSqlDialect.js';
7
- export * from './indexSqlDialect.js';
8
5
  export * from './mysqlLikeSqlDialect.js';
9
6
  export * from './queryContext.js';
@@ -1,4 +1,4 @@
1
- import type { FieldOptions } from '../type/index.js';
1
+ import type { FieldOptions, FieldType } from '../type/index.js';
2
2
  /**
3
3
  * A `'$.a.b'` JSON path literal, each dot-separated segment escaped. `suffix` appends an accessor
4
4
  * such as `[#]` or `[*]`. Shared across dialects unchanged: no dialect escapes a JSON path key
@@ -21,6 +21,12 @@ export declare function jsonSetTarget(expr: string, field: FieldOptions | undefi
21
21
  export declare function jsonRemoveCall(fn: string, expr: string, keys: readonly string[]): string;
22
22
  /** `WHERE` is omitted for an empty `$elemMatch`, which asks only that the array has an element. */
23
23
  export declare function jsonElemExists(from: string, conditions: readonly string[]): string;
24
+ /**
25
+ * How a JSON value is read out of its document: as the JSON value itself, as a number to compare
26
+ * against, or as the text a path yields. One vocabulary for every operand of a comparison, so the
27
+ * left side is read the way the right side is compared - see {@link jsonCompareMode}.
28
+ */
29
+ export type JsonAccessMode = 'json' | 'numeric' | 'text';
24
30
  /**
25
31
  * How a JSON scalar has to be compared against `value` (or, for `$in`/`$nin`, against every element
26
32
  * of it). Extracting a JSON value yields *text*, which loses the type, so each operand type is
@@ -33,7 +39,16 @@ export declare function jsonElemExists(from: string, conditions: readonly string
33
39
  *
34
40
  * Mixed operand types fall back to `text`, since one comparison cannot be two shapes at once.
35
41
  */
36
- export declare function jsonCompareMode(value: unknown): 'json' | 'numeric' | 'text';
42
+ export declare function jsonCompareMode(value: unknown): JsonAccessMode;
43
+ /**
44
+ * The mode a *declared* type asks for: {@link jsonCompareMode}'s twin, reading the type instead of an
45
+ * operand. An index over a JSON path is only reachable by a comparison that extracts it the same way,
46
+ * so the two have to answer alike - which is why they are one pair over one vocabulary.
47
+ *
48
+ * Reads the type through `util/field.util`'s predicates, which the dialects already carry: resolving
49
+ * it through `schema/canonicalType` instead pulls that whole module into every consumer bundle.
50
+ */
51
+ export declare function jsonTypeMode(type: FieldType): JsonAccessMode;
37
52
  /**
38
53
  * Whether the operator reads the JSON *value* instead of its text form. The array operators always
39
54
  * do. Equality joins them for boolean operands, because extracting JSON as text loses the type in
@@ -1,3 +1,4 @@
1
+ import { isBooleanType, isNumericType } from '../util/field.util.js';
1
2
  import { escapeSingleQuotes } from '../util/sqlLiteral.js';
2
3
  /**
3
4
  * A `'$.a.b'` JSON path literal, each dot-separated segment escaped. `suffix` appends an accessor
@@ -56,6 +57,20 @@ export function jsonCompareMode(value) {
56
57
  }
57
58
  return operands.every((operand) => typeof operand === 'number') ? 'numeric' : 'text';
58
59
  }
60
+ /**
61
+ * The mode a *declared* type asks for: {@link jsonCompareMode}'s twin, reading the type instead of an
62
+ * operand. An index over a JSON path is only reachable by a comparison that extracts it the same way,
63
+ * so the two have to answer alike - which is why they are one pair over one vocabulary.
64
+ *
65
+ * Reads the type through `util/field.util`'s predicates, which the dialects already carry: resolving
66
+ * it through `schema/canonicalType` instead pulls that whole module into every consumer bundle.
67
+ */
68
+ export function jsonTypeMode(type) {
69
+ if (isNumericType(type)) {
70
+ return 'numeric';
71
+ }
72
+ return isBooleanType(type) ? 'json' : 'text';
73
+ }
59
74
  /**
60
75
  * Whether the operator reads the JSON *value* instead of its text form. The array operators always
61
76
  * do. Equality joins them for boolean operands, because extracting JSON as text loses the type in
@@ -1,4 +1,4 @@
1
- import type { DialectFeatures, EntityMeta, FieldOptions, IndexFeature, IndexSchema, InsertIdSource, QueryConflictPaths, QueryContext, QueryPager, QuerySizeComparisonOps, QueryTextSearchOptions, Type } from '../type/index.js';
1
+ import type { DialectFeatures, EntityMeta, FieldOptions, InsertIdSource, QueryConflictPaths, QueryContext, QueryPager, QuerySizeComparisonOps, QueryTextSearchOptions, Type } from '../type/index.js';
2
2
  import { AbstractSqlDialect } from './abstractSqlDialect.js';
3
3
  /**
4
4
  * Shared JSON-array / JSON-object operator implementation between MySQL and MariaDB.
@@ -9,6 +9,9 @@ import { AbstractSqlDialect } from './abstractSqlDialect.js';
9
9
  * - `$elemMatch` (JSON_TABLE, or fast JSON_CONTAINS for the simple case)
10
10
  * - the update operators `$set` (JSON_SET), `$unset` (JSON_REMOVE), `$push` (JSON_MERGE_PRESERVE)
11
11
  * and `$pull` (JSON_REPLACE over JSON_TABLE)
12
+ *
13
+ * Neither has `FOR NO KEY UPDATE`/`FOR KEY SHARE`, PostgreSQL's weaker pair, so asking for one is
14
+ * rejected rather than served a stronger lock.
12
15
  */
13
16
  export declare abstract class MysqlLikeSqlDialect extends AbstractSqlDialect {
14
17
  /** Default {@link DialectFeatures} for MySQL-compatible SQL dialects. */
@@ -40,8 +43,8 @@ export declare abstract class MysqlLikeSqlDialect extends AbstractSqlDialect {
40
43
  * unique index for you.
41
44
  *
42
45
  * The update assignments are built into their own context and pushed afterwards, since they read
43
- * `VALUES(col)` rather than binding, and any value they *do* bind (an `onUpdate` field absent from the
44
- * payload) has to land after the insert's for a `?`-placeholder driver.
46
+ * the inserted row rather than binding, and any value they *do* bind (an `onUpdate` field absent from
47
+ * the payload) has to land after the insert's for a `?`-placeholder driver.
45
48
  */
46
49
  upsert<E>(ctx: QueryContext, entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: E | E[]): void;
47
50
  /**
@@ -49,6 +52,13 @@ export declare abstract class MysqlLikeSqlDialect extends AbstractSqlDialect {
49
52
  * 10.5+ has it, and used to restate this whole method just to add it.
50
53
  */
51
54
  protected upsertReturning<E>(_entity: Type<E>): string;
55
+ /**
56
+ * The alias the inserted row is given after the values list, and read back by the assignments.
57
+ * Undefined where the dialect has no such syntax - MariaDB, which reads that row through
58
+ * `VALUE(col)` instead: it renamed `VALUES()` in 10.3.3, the old name clashing with the standard
59
+ * table value constructors, where MySQL deprecated the function outright in favour of the alias.
60
+ */
61
+ protected readonly upsertNewRowAlias: string | undefined;
52
62
  readonly maxBindValues: number;
53
63
  escape(value: unknown): string;
54
64
  /**
@@ -57,17 +67,6 @@ export declare abstract class MysqlLikeSqlDialect extends AbstractSqlDialect {
57
67
  * `@Index([...], { type: 'fulltext' })`.
58
68
  */
59
69
  protected appendTextSearch<E>(ctx: QueryContext, _entity: Type<E>, meta: EntityMeta<E>, search: QueryTextSearchOptions<E>): void;
60
- /**
61
- * A full-text index is its own keyword here (`CREATE FULLTEXT INDEX ... (cols)`); `USING fulltext`
62
- * is a syntax error, so it is the keyword that changes rather than the access method.
63
- */
64
- protected indexKeyword(index: IndexSchema): string;
65
- protected readonly indexFeatures: Set<IndexFeature>;
66
- /**
67
- * No `FOR NO KEY UPDATE`/`FOR KEY SHARE`: those are PostgreSQL's weaker pair and the family has
68
- * no equivalent, so asking for one is rejected rather than served a stronger lock.
69
- */
70
- protected indexAccessMethod(index: IndexSchema): string;
71
70
  protected numericCast(expr: string): string;
72
71
  protected neExpr(field: string, ph: string): string;
73
72
  /** How a surviving element is fed back into the array a `$pull` rebuilds. */
@@ -15,6 +15,9 @@ const MAX_LIMIT = BigInt.asUintN(64, -1n);
15
15
  * - `$elemMatch` (JSON_TABLE, or fast JSON_CONTAINS for the simple case)
16
16
  * - the update operators `$set` (JSON_SET), `$unset` (JSON_REMOVE), `$push` (JSON_MERGE_PRESERVE)
17
17
  * and `$pull` (JSON_REPLACE over JSON_TABLE)
18
+ *
19
+ * Neither has `FOR NO KEY UPDATE`/`FOR KEY SHARE`, PostgreSQL's weaker pair, so asking for one is
20
+ * rejected rather than served a stronger lock.
18
21
  */
19
22
  export class MysqlLikeSqlDialect extends AbstractSqlDialect {
20
23
  /** Default {@link DialectFeatures} for MySQL-compatible SQL dialects. */
@@ -29,7 +32,7 @@ export class MysqlLikeSqlDialect extends AbstractSqlDialect {
29
32
  renameColumn: true,
30
33
  foreignKeyAlter: true,
31
34
  columnComment: true,
32
- inlineVectorIndex: false,
35
+ vectorIndexRequiresNotNull: false,
33
36
  vectorSupportsLength: false,
34
37
  supportsTimestamptz: false,
35
38
  defaultStringAsText: false,
@@ -80,17 +83,18 @@ export class MysqlLikeSqlDialect extends AbstractSqlDialect {
80
83
  * unique index for you.
81
84
  *
82
85
  * The update assignments are built into their own context and pushed afterwards, since they read
83
- * `VALUES(col)` rather than binding, and any value they *do* bind (an `onUpdate` field absent from the
84
- * payload) has to land after the insert's for a `?`-placeholder driver.
86
+ * the inserted row rather than binding, and any value they *do* bind (an `onUpdate` field absent from
87
+ * the payload) has to land after the insert's for a `?`-placeholder driver.
85
88
  */
86
89
  upsert(ctx, entity, conflictPaths, payload) {
87
90
  const meta = getMeta(entity);
91
+ const alias = this.upsertNewRowAlias && this.escapeId(this.upsertNewRowAlias, true);
88
92
  const updateCtx = this.createContext();
89
- const update = this.getUpsertUpdateAssignments(updateCtx, meta, conflictPaths, payload, (name) => `VALUES(${name})`);
93
+ const update = this.getUpsertUpdateAssignments(updateCtx, meta, conflictPaths, payload, (name) => alias ? `${alias}.${name}` : `VALUE(${name})`);
90
94
  const returning = this.upsertReturning(entity);
91
95
  if (update) {
92
96
  this.appendInsertValues(ctx, entity, payload);
93
- ctx.append(` ON DUPLICATE KEY UPDATE ${update}${returning}`);
97
+ ctx.append(`${alias ? ` AS ${alias}` : ''} ON DUPLICATE KEY UPDATE ${update}${returning}`);
94
98
  ctx.pushValue(...updateCtx.values);
95
99
  return;
96
100
  }
@@ -107,6 +111,13 @@ export class MysqlLikeSqlDialect extends AbstractSqlDialect {
107
111
  upsertReturning(_entity) {
108
112
  return '';
109
113
  }
114
+ /**
115
+ * The alias the inserted row is given after the values list, and read back by the assignments.
116
+ * Undefined where the dialect has no such syntax - MariaDB, which reads that row through
117
+ * `VALUE(col)` instead: it renamed `VALUES()` in 10.3.3, the old name clashing with the standard
118
+ * table value constructors, where MySQL deprecated the function outright in favour of the alias.
119
+ */
120
+ upsertNewRowAlias = undefined;
110
121
  maxBindValues = 65535;
111
122
  escape(value) {
112
123
  return escapeMysqlSqlLiteral(value);
@@ -123,21 +134,6 @@ export class MysqlLikeSqlDialect extends AbstractSqlDialect {
123
134
  ctx.addValue(search.$value);
124
135
  ctx.append(')');
125
136
  }
126
- /**
127
- * A full-text index is its own keyword here (`CREATE FULLTEXT INDEX ... (cols)`); `USING fulltext`
128
- * is a syntax error, so it is the keyword that changes rather than the access method.
129
- */
130
- indexKeyword(index) {
131
- return index.type === 'fulltext' ? 'FULLTEXT INDEX' : super.indexKeyword(index);
132
- }
133
- indexFeatures = new Set(['expression', 'prefixLength']);
134
- /**
135
- * No `FOR NO KEY UPDATE`/`FOR KEY SHARE`: those are PostgreSQL's weaker pair and the family has
136
- * no equivalent, so asking for one is rejected rather than served a stronger lock.
137
- */
138
- indexAccessMethod(index) {
139
- return index.type && index.type !== 'fulltext' ? ` USING ${index.type}` : '';
140
- }
141
137
  numericCast(expr) {
142
138
  return `CAST(${expr} AS DECIMAL)`;
143
139
  }
@@ -1,4 +1,4 @@
1
- import { type DialectFeatures, type EntityMeta, type FieldOptions, type IndexColumnSchema, type IndexFeature, type IndexSchema, type JsonColumnType, type QueryContext, type QuerySizeComparisonOps, type QueryTextSearchOptions, type QueryVectorSearch, type Type, type VectorDistance } from '../type/index.js';
1
+ import { type DialectFeatures, type EntityMeta, type FieldOptions, type JsonColumnType, type Query, type QueryContext, type QuerySizeComparisonOps, type QueryTextSearchOptions, type Type, type VectorDistance, type VectorOperatorMetric } from '../type/index.js';
2
2
  import { AbstractSqlDialect } from './abstractSqlDialect.js';
3
3
  /**
4
4
  * Shared AST/quoting/JSONB/full-text-search/vector-search implementation between Postgres and
@@ -30,25 +30,24 @@ export declare abstract class PgLikeSqlDialect extends AbstractSqlDialect {
30
30
  * place so a dialect cannot end up with the operator but not the opclass. The key set is the single
31
31
  * source of truth for which metrics the dialect supports at all: CockroachDB narrows it to three.
32
32
  */
33
- readonly vectorMetrics: ReadonlyMap<VectorDistance, {
34
- op: string;
35
- opsSuffix: string;
36
- }>;
37
- normalizeValue(value: unknown): unknown;
38
- /** pgvector's own index types; CockroachDB's native one widens this. */
39
- protected isVectorIndex(index: IndexSchema): boolean;
40
- protected indexAccessMethod(index: IndexSchema): string;
41
- protected readonly indexFeatures: Set<IndexFeature>;
33
+ readonly vectorMetrics: ReadonlyMap<VectorDistance, VectorOperatorMetric>;
34
+ /** `SET LOCAL` applies to the enclosing transaction and to nothing at all without one. */
35
+ readonly vectorTuningNeedsTransaction = true;
36
+ /**
37
+ * The GUC each pgvector index type reads for "how much of the index to explore". They are not the
38
+ * same quantity - `ef_search` is a candidate-list size, `probes` a count of lists - which is why
39
+ * `$candidates` is documented in the index's own units rather than as a portable number.
40
+ */
41
+ private static readonly ANN_SETTINGS;
42
42
  /**
43
- * A vector index's operator class is named `{type}_{metric}_ops`: an index on a `halfvec` column
44
- * needs `halfvec_cosine_ops`, and `vector_cosine_ops` there is rejected outright. An unsupported
45
- * distance throws rather than being omitted, since a bare `USING hnsw ("embedding")` would build
46
- * with the dialect's default metric instead of the one requested, with nothing signalling it.
47
- * Everything else takes the operator class the entry declares, e.g. `jsonb_path_ops` for GIN.
43
+ * `SET LOCAL hnsw.ef_search = N`, plus `hnsw.iterative_scan` when the query also filters by
44
+ * distance. Without iterative scan, HNSW returns its candidate list and the predicate then removes
45
+ * from it, so a `$near` can hand back fewer rows than qualify - the recall bug `$candidates`
46
+ * exists to answer. `strict_order`, never `relaxed_order`: the latter returns rows out of distance
47
+ * order, which would quietly contradict the `ORDER BY` the caller asked for.
48
48
  */
49
- protected indexColumnOpsClass(entry: IndexColumnSchema, index: IndexSchema): string;
50
- protected indexInclude(index: IndexSchema): string;
51
- protected indexTuning(index: IndexSchema): string;
49
+ vectorTuningStatements<E>(meta: EntityMeta<E>, q: Query<E>): readonly string[];
50
+ normalizeValue(value: unknown): unknown;
52
51
  placeholder(index: number): string;
53
52
  /**
54
53
  * `to_tsvector(...) @@ websearch_to_tsquery(...)`. `websearch_to_tsquery` takes free-form user input
@@ -90,6 +89,4 @@ export declare abstract class PgLikeSqlDialect extends AbstractSqlDialect {
90
89
  * Helper to add a JSON value to context with appropriate stringification and cast.
91
90
  */
92
91
  private jsonVal;
93
- /** Emit a pgvector-style distance expression: `"col" <op> $N::<vectorType>`. */
94
- protected appendVectorSort<E>(ctx: QueryContext, meta: EntityMeta<E>, key: string, search: QueryVectorSearch): void;
95
92
  }