uql-orm 0.24.6 → 0.25.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 (66) hide show
  1. package/README.md +9 -8
  2. package/dist/cockroachdb/crdbQuerierPool.d.ts +1 -1
  3. package/dist/cockroachdb/crdbQuerierPool.js +5 -4
  4. package/dist/dialect/abstractSqlDialect.d.ts +78 -27
  5. package/dist/dialect/abstractSqlDialect.js +194 -130
  6. package/dist/dialect/hydrateColumn.d.ts +16 -0
  7. package/dist/dialect/hydrateColumn.js +66 -0
  8. package/dist/dialect/jsonSql.d.ts +24 -0
  9. package/dist/dialect/jsonSql.js +39 -0
  10. package/dist/dialect/mysqlLikeSqlDialect.d.ts +5 -0
  11. package/dist/dialect/mysqlLikeSqlDialect.js +10 -1
  12. package/dist/dialect/pgLikeSqlDialect.d.ts +3 -7
  13. package/dist/dialect/pgLikeSqlDialect.js +2 -14
  14. package/dist/dialect/vectorCast.d.ts +15 -0
  15. package/dist/dialect/vectorCast.js +58 -0
  16. package/dist/entity/metadata/definition.d.ts +0 -1
  17. package/dist/entity/metadata/definition.js +1 -1
  18. package/dist/maria/mariaDialect.d.ts +3 -2
  19. package/dist/maria/mariaDialect.js +3 -18
  20. package/dist/maria/mariadbQuerierPool.js +6 -1
  21. package/dist/migrate/builder/migrationBuilder.d.ts +12 -16
  22. package/dist/migrate/builder/migrationBuilder.js +24 -59
  23. package/dist/migrate/builder/tableBuilder.js +0 -12
  24. package/dist/migrate/cli.d.ts +0 -1
  25. package/dist/migrate/cli.js +1 -1
  26. package/dist/migrate/codegen/entityCodeGenerator.js +0 -3
  27. package/dist/migrate/drift/driftDetector.js +17 -15
  28. package/dist/migrate/generator/mongoSchemaGenerator.d.ts +9 -1
  29. package/dist/migrate/generator/mongoSchemaGenerator.js +18 -0
  30. package/dist/migrate/introspection/abstractSqlSchemaIntrospector.d.ts +8 -2
  31. package/dist/migrate/introspection/abstractSqlSchemaIntrospector.js +10 -9
  32. package/dist/migrate/introspection/mysqlIntrospector.d.ts +0 -3
  33. package/dist/migrate/introspection/mysqlIntrospector.js +0 -9
  34. package/dist/migrate/introspection/postgresIntrospector.d.ts +0 -3
  35. package/dist/migrate/introspection/postgresIntrospector.js +0 -12
  36. package/dist/migrate/introspection/sqliteIntrospector.d.ts +1 -0
  37. package/dist/migrate/introspection/sqliteIntrospector.js +1 -9
  38. package/dist/migrate/migrator.d.ts +17 -0
  39. package/dist/migrate/migrator.js +51 -50
  40. package/dist/migrate/schemaGenerator.d.ts +19 -8
  41. package/dist/migrate/schemaGenerator.js +48 -17
  42. package/dist/neon/neonQuerierPool.d.ts +1 -1
  43. package/dist/neon/neonQuerierPool.js +6 -4
  44. package/dist/postgres/abstractPgQuerierPool.d.ts +6 -0
  45. package/dist/postgres/abstractPgQuerierPool.js +3 -0
  46. package/dist/postgres/pgNumericTypes.d.ts +41 -0
  47. package/dist/postgres/pgNumericTypes.js +35 -0
  48. package/dist/postgres/pgQuerierPool.d.ts +1 -1
  49. package/dist/postgres/pgQuerierPool.js +5 -4
  50. package/dist/querier/abstractSqlQuerier.d.ts +9 -2
  51. package/dist/querier/abstractSqlQuerier.js +34 -25
  52. package/dist/schema/canonicalType.js +2 -12
  53. package/dist/schema/schemaAST.js +0 -24
  54. package/dist/schema/schemaASTBuilder.d.ts +1 -1
  55. package/dist/schema/schemaASTBuilder.js +4 -5
  56. package/dist/sqlite/nodeSqliteQuerierPool.js +1 -0
  57. package/dist/sqlite/sqliteQuerierPool.d.ts +5 -0
  58. package/dist/sqlite/sqliteQuerierPool.js +7 -0
  59. package/dist/turso/tursoLocalQuerierPool.js +1 -0
  60. package/dist/type/entity.d.ts +16 -2
  61. package/dist/type/migration.d.ts +30 -9
  62. package/dist/type/queryAggregate.d.ts +3 -0
  63. package/dist/util/field.util.d.ts +4 -0
  64. package/dist/util/field.util.js +12 -0
  65. package/dist/util/sqlLiteral.js +18 -15
  66. package/package.json +6 -6
@@ -1,12 +1,12 @@
1
1
  import { getMeta } from '../entity/index.js';
2
2
  import { QueryRaw, RAW_ALIAS, RAW_VALUE, } from '../type/index.js';
3
- import { asSelectMap, buildQueryWhereAsMap, buildSortMap, escapeSqlId, fillOnFields, filterFieldKeys, flatObject, getInsertFieldKeys, getKeys, getRelationRequestSummary, getSoftDeleteValue, hasKeys, hasMultipleKeys, isJsonType, isJsonUpdateOp, isOperatorObject, isOperatorOnlyObject, isPopulatingRelations, isVectorSearch, normalizeScalarFieldSelection, parseGroupMap, parseRelationAtKey, parseRelationSize, raw, someValue, withoutSoftDeleteFilter, } from '../util/index.js';
3
+ import { asSelectMap, buildQueryWhereAsMap, buildSortMap, escapeSqlId, fillOnFields, filterFieldKeys, flatObject, getInsertFieldKeys, getKeys, getRelationRequestSummary, getSoftDeleteValue, hasKeys, hasMultipleKeys, isBooleanType, isJsonType, isJsonUpdateOp, isNumericType, isOperatorObject, isOperatorOnlyObject, isPopulatingRelations, isVectorSearch, normalizeScalarFieldSelection, parseGroupMap, parseRelationAtKey, parseRelationSize, raw, someValue, withoutSoftDeleteFilter, } from '../util/index.js';
4
4
  import { escapeAnsiSqlLiteral, escapeSingleQuotes } from '../util/sqlLiteral.js';
5
5
  import { IndexSqlDialect } from './indexSqlDialect.js';
6
6
  import { buildElemMatchConditions } from './jsonArrayElemMatchUtils.js';
7
- import { JSON_ELEM_ALIAS_PREFIX, jsonElemExists } from './jsonSql.js';
7
+ import { isJsonbOp, JSON_ELEM_ALIAS_PREFIX, jsonCompareMode, jsonElemExists } from './jsonSql.js';
8
8
  import { SqlQueryContext } from './queryContext.js';
9
- import { isVectorFieldType } from './vectorCast.js';
9
+ import { isVectorFieldType, resolveVectorCast } from './vectorCast.js';
10
10
  export class AbstractSqlDialect extends IndexSqlDialect {
11
11
  isolationLevelStrategy = 'inline';
12
12
  alterColumnStrategy = 'single-statement';
@@ -433,44 +433,15 @@ export class AbstractSqlDialect extends IndexSqlDialect {
433
433
  }
434
434
  compareFieldOperator(ctx, entity, key, op, val, opts = {}) {
435
435
  const field = this.resolveOperandField(ctx, entity, key, opts);
436
- const simpleOp = AbstractSqlDialect.COMPARE_OP_MAP.get(op);
437
- if (simpleOp) {
438
- this.appendFieldSql(ctx, field, `${simpleOp}${this.addValue(ctx.values, val)}`);
439
- return;
440
- }
441
- const likeWrap = AbstractSqlDialect.LIKE_OP_MAP.get(op);
442
- if (likeWrap) {
443
- this.appendLikeOp(ctx, field, op, likeWrap(val));
436
+ if (this.appendOperatorCondition(ctx, field, op, val)) {
444
437
  return;
445
438
  }
446
439
  switch (op) {
447
- case '$eq':
448
- case '$ne':
449
- this.appendEqNe(ctx, field, op, val);
450
- break;
451
- case '$regex':
452
- this.appendFieldSql(ctx, field, ` ${this.regexpOp} ${this.addValue(ctx.values, val)}`);
453
- break;
454
440
  case '$not':
455
441
  ctx.append('NOT (');
456
442
  this.compare(ctx, entity, key, val, opts);
457
443
  ctx.append(')');
458
444
  break;
459
- case '$in':
460
- case '$nin':
461
- this.appendInNin(ctx, field, op, val);
462
- break;
463
- case '$between': {
464
- const [min, max] = val;
465
- this.appendFieldSql(ctx, field, ` BETWEEN ${this.addValue(ctx.values, min)} AND ${this.addValue(ctx.values, max)}`);
466
- break;
467
- }
468
- case '$isNull':
469
- this.appendFieldSql(ctx, field, val ? ' IS NULL' : ' IS NOT NULL');
470
- break;
471
- case '$isNotNull':
472
- this.appendFieldSql(ctx, field, val ? ' IS NOT NULL' : ' IS NULL');
473
- break;
474
445
  case '$all':
475
446
  ctx.append(this.jsonAll(ctx, field ?? '', val));
476
447
  break;
@@ -484,6 +455,57 @@ export class AbstractSqlDialect extends IndexSqlDialect {
484
455
  throw TypeError(`unknown operator: ${op}`);
485
456
  }
486
457
  }
458
+ /**
459
+ * Render `<operand> <op> <value>` for every operator that needs nothing but its left-hand SQL, and
460
+ * report whether `op` was one of them.
461
+ *
462
+ * One implementation for three callers that each had their own: a WHERE column, a HAVING aggregate
463
+ * expression, and a `$size` count (which passes no operand, since its expression is already in the
464
+ * context). They previously disagreed - HAVING carried a second comparison-operator map and threw
465
+ * `unsupported HAVING operator` on the `$like` that `QueryHavingMap` accepts, and neither of the
466
+ * other two turned `$eq: null` into `IS NULL` the way the WHERE path does.
467
+ *
468
+ * The operators kept out are the ones that need more than an operand: `$not` recurses through the
469
+ * entity, and `$all`/`$size`/`$elemMatch` address a JSON document.
470
+ */
471
+ appendOperatorCondition(ctx, operand, op, val) {
472
+ const simpleOp = AbstractSqlDialect.COMPARE_OP_MAP.get(op);
473
+ if (simpleOp) {
474
+ this.appendFieldSql(ctx, operand, `${simpleOp}${this.addValue(ctx.values, val)}`);
475
+ return true;
476
+ }
477
+ const likeWrap = AbstractSqlDialect.LIKE_OP_MAP.get(op);
478
+ if (likeWrap) {
479
+ this.appendLikeOp(ctx, operand, op, likeWrap(val));
480
+ return true;
481
+ }
482
+ switch (op) {
483
+ case '$eq':
484
+ case '$ne':
485
+ this.appendEqNe(ctx, operand, op, val);
486
+ return true;
487
+ case '$regex':
488
+ this.appendFieldSql(ctx, operand, ` ${this.regexpOp} ${this.addValue(ctx.values, val)}`);
489
+ return true;
490
+ case '$in':
491
+ case '$nin':
492
+ this.appendInNin(ctx, operand, op, val);
493
+ return true;
494
+ case '$between': {
495
+ const [min, max] = val;
496
+ this.appendFieldSql(ctx, operand, ` BETWEEN ${this.addValue(ctx.values, min)} AND ${this.addValue(ctx.values, max)}`);
497
+ return true;
498
+ }
499
+ case '$isNull':
500
+ this.appendFieldSql(ctx, operand, val ? ' IS NULL' : ' IS NOT NULL');
501
+ return true;
502
+ case '$isNotNull':
503
+ this.appendFieldSql(ctx, operand, val ? ' IS NOT NULL' : ' IS NULL');
504
+ return true;
505
+ default:
506
+ return false;
507
+ }
508
+ }
487
509
  appendLikeOp(ctx, field, op, wrappedVal) {
488
510
  const isIlike = AbstractSqlDialect.LIKE_CASE_INSENSITIVE_OPS.has(op);
489
511
  const ph = this.addValue(ctx.values, wrappedVal);
@@ -512,7 +534,11 @@ export class AbstractSqlDialect extends IndexSqlDialect {
512
534
  }
513
535
  }
514
536
  appendInNin(ctx, field, op, val) {
515
- this.appendFieldSql(ctx, field, this.formatIn(ctx, Array.isArray(val) ? val : [], op === '$nin'));
537
+ if (!Array.isArray(val)) {
538
+ // Not covered by the types: `/http` casts client JSON straight to `Query`, so this arrives untyped.
539
+ throw TypeError(`${op} expects an array, got ${val === null ? 'null' : typeof val}`);
540
+ }
541
+ this.appendFieldSql(ctx, field, this.formatIn(ctx, val, op === '$nin'));
516
542
  }
517
543
  /**
518
544
  * Build a comparison condition for a JSON field.
@@ -588,29 +614,7 @@ export class AbstractSqlDialect extends IndexSqlDialect {
588
614
  * {@link jsonCompareMode} for why each mode exists.
589
615
  */
590
616
  jsonComparand(jsonField, value) {
591
- return this.jsonCompareMode(value) === 'numeric' ? this.numericCast(jsonField) : jsonField;
592
- }
593
- /**
594
- * How a JSON scalar has to be compared against `value` (or, for `$in`/`$nin`, against every element
595
- * of it). Extracting a JSON value yields *text*, which loses the type, so each operand type is
596
- * compared in the representation every engine agrees on:
597
- * - `numeric` - cast the accessor. Keeps `1` equal to a stored `1.0`, which strict JSON equality
598
- * would not, and satisfies drivers that send typed parameters (`text = integer` otherwise).
599
- * - `json` - compare the JSON value against a JSON-encoded parameter. No cast recovers a boolean
600
- * portably: PostgreSQL raises `text = boolean` and MySQL matches `'true'` against `1`.
601
- * - `text` - compare as extracted, which is also what the string operators need.
602
- *
603
- * Mixed operand types fall back to `text`, since one comparison cannot be two shapes at once.
604
- */
605
- jsonCompareMode(value) {
606
- const operands = Array.isArray(value) ? value : [value];
607
- if (operands.length === 0) {
608
- return 'text';
609
- }
610
- if (operands.every((operand) => typeof operand === 'boolean')) {
611
- return 'json';
612
- }
613
- return operands.every((operand) => typeof operand === 'number') ? 'numeric' : 'text';
617
+ return jsonCompareMode(value) === 'numeric' ? this.numericCast(jsonField) : jsonField;
614
618
  }
615
619
  /**
616
620
  * Whether the dialect's array containment ({@link jsonAll}) matches an object element that merely
@@ -638,7 +642,7 @@ export class AbstractSqlDialect extends IndexSqlDialect {
638
642
  // only when *every* operand needs it - the same all-operands rule the comparison classifier uses.
639
643
  if (isOperatorOnlyObject(match)) {
640
644
  const entries = Object.entries(match);
641
- const asJson = !this.jsonScalarElemKeepsType && entries.every(([op, val]) => this.isJsonbOp(op, val));
645
+ const asJson = !this.jsonScalarElemKeepsType && entries.every(([op, val]) => isJsonbOp(op, val));
642
646
  const alias = ctx.nextAlias(JSON_ELEM_ALIAS_PREFIX);
643
647
  const conditions = entries.map(([op, val]) => this.buildJsonFieldCondition(ctx, () => this.jsonElemRef(alias, undefined, asJson), '', op, val, asJson));
644
648
  return jsonElemExists(this.jsonElemFrom(jsonField, [], alias, asJson), conditions);
@@ -653,28 +657,11 @@ export class AbstractSqlDialect extends IndexSqlDialect {
653
657
  }
654
658
  const alias = ctx.nextAlias(JSON_ELEM_ALIAS_PREFIX);
655
659
  const conditions = buildElemMatchConditions(match, (field, op, opVal) => {
656
- const asJson = this.isJsonbOp(op, opVal);
660
+ const asJson = isJsonbOp(op, opVal);
657
661
  return this.buildJsonFieldCondition(ctx, (f) => this.jsonElemRef(alias, f, asJson), field, op, opVal, asJson);
658
662
  });
659
663
  return jsonElemExists(this.jsonElemFrom(jsonField, Object.keys(match), alias), conditions);
660
664
  }
661
- /**
662
- * Whether the operator reads the JSON *value* instead of its text form. The array operators always
663
- * do. Equality joins them for boolean operands, because extracting JSON as text loses the type in
664
- * a way no cast recovers portably: PostgreSQL raises `operator does not exist: text = boolean`,
665
- * MySQL compares `'true'` to `1` and silently matches nothing, and SQLite's `json_extract` yields
666
- * `1`. Comparing the JSON value against a JSON-encoded parameter is exact on every dialect.
667
- *
668
- * Numbers stay on the text accessor with a numeric cast ({@link jsonComparand}), which keeps
669
- * `1` equal to `1.0` - JSON equality would not.
670
- */
671
- isJsonbOp(op, value) {
672
- if (op === '$all' || op === '$size' || op === '$elemMatch') {
673
- return true;
674
- }
675
- const comparesValue = op === '$eq' || op === '$ne' || op === '$in' || op === '$nin';
676
- return comparesValue && this.jsonCompareMode(value) === 'json';
677
- }
678
665
  /**
679
666
  * A JSON-encoded bound parameter, cast to the dialect's JSON type. Only the positional-placeholder
680
667
  * dialects use this - PostgreSQL binds JSON through {@link PgLikeSqlDialect.jsonScalarParam} instead.
@@ -856,51 +843,17 @@ export class AbstractSqlDialect extends IndexSqlDialect {
856
843
  return direction;
857
844
  }
858
845
  /** Scalar comparison operators shared by `HAVING` conditions and `$size` comparisons. */
859
- static comparisonOpMap = new Map([
860
- ['$eq', '='],
861
- ['$ne', '<>'],
862
- ['$gt', '>'],
863
- ['$gte', '>='],
864
- ['$lt', '<'],
865
- ['$lte', '<='],
866
- ]);
867
846
  havingCondition(ctx, expr, condition) {
868
847
  if (typeof condition !== 'object' || condition === null) {
869
- ctx.append(`${expr} = `);
870
- ctx.addValue(condition);
848
+ this.appendOperatorCondition(ctx, expr, '$eq', condition);
871
849
  return;
872
850
  }
873
851
  const ops = condition;
874
- const keys = getKeys(ops);
875
- keys.forEach((op, i) => {
852
+ getKeys(ops).forEach((op, i) => {
876
853
  if (i > 0)
877
854
  ctx.append(' AND ');
878
- const val = ops[op];
879
- if (op === '$between') {
880
- const [min, max] = val;
881
- ctx.append(`${expr} BETWEEN `);
882
- ctx.addValue(min);
883
- ctx.append(' AND ');
884
- ctx.addValue(max);
885
- }
886
- else if (op === '$in' || op === '$nin') {
887
- ctx.append(`${expr}${this.formatIn(ctx, Array.isArray(val) ? val : [], op === '$nin')}`);
888
- }
889
- else if (op === '$isNull') {
890
- ctx.append(`${expr}${val ? ' IS NULL' : ' IS NOT NULL'}`);
891
- }
892
- else if (op === '$isNotNull') {
893
- ctx.append(`${expr}${val ? ' IS NOT NULL' : ' IS NULL'}`);
894
- }
895
- else if (op === '$ne') {
896
- ctx.append(this.neExpr(expr, this.addValue(ctx.values, val)));
897
- }
898
- else {
899
- const sqlOp = AbstractSqlDialect.comparisonOpMap.get(op);
900
- if (!sqlOp)
901
- throw TypeError(`unsupported HAVING operator: ${op}`);
902
- ctx.append(`${expr} ${sqlOp} `);
903
- ctx.addValue(val);
855
+ if (!this.appendOperatorCondition(ctx, expr, op, ops[op])) {
856
+ throw TypeError(`unsupported HAVING operator: ${op}`);
904
857
  }
905
858
  });
906
859
  }
@@ -1008,14 +961,25 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1008
961
  */
1009
962
  upsert(ctx, entity, conflictPaths, payload, extraReturning = '') {
1010
963
  const meta = getMeta(entity);
1011
- const updateCtx = this.createContext();
964
+ const updateCtx = this.upsertUpdateBindsInPlace ? ctx : this.createContext();
1012
965
  const update = this.getUpsertUpdateAssignments(updateCtx, meta, conflictPaths, payload, this.upsertExcluded);
1013
966
  const keys = this.getUpsertConflictPathsStr(meta, conflictPaths);
1014
967
  const onConflict = update ? `DO UPDATE SET ${update}` : 'DO NOTHING';
1015
968
  this.appendInsertValues(ctx, entity, payload);
1016
969
  ctx.append(` ON CONFLICT (${keys}) ${onConflict} ${this.returningId(entity)}${extraReturning}`);
1017
- ctx.pushValue(...updateCtx.values);
970
+ if (updateCtx !== ctx) {
971
+ ctx.pushValue(...updateCtx.values);
972
+ }
1018
973
  }
974
+ /**
975
+ * Whether the upsert's update assignments can bind straight into the statement's own context.
976
+ *
977
+ * They cannot on a `?`-placeholder dialect: the assignments are built before the insert but read
978
+ * after it, so their values have to be pushed afterwards to land in the right positional order.
979
+ * A `$n` placeholder carries its own index, so there is nothing to reorder - but it also cannot use
980
+ * the scratch context, whose numbering would restart at `$1` and collide with the insert's.
981
+ */
982
+ upsertUpdateBindsInPlace = false;
1019
983
  /** How an `ON CONFLICT` assignment reads the row that was being inserted. */
1020
984
  upsertExcluded = (columnName) => `EXCLUDED.${columnName}`;
1021
985
  getUpsertUpdateAssignments(ctx, meta, conflictPaths, payload, callback) {
@@ -1089,6 +1053,95 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1089
1053
  }
1090
1054
  return isVectorFieldType(type) ? 'vector' : 'plain';
1091
1055
  }
1056
+ /**
1057
+ * Which of an entity's columns need decoding on READ, and how: the inverse of {@link persistKind},
1058
+ * cached per entity for the same reason it classifies per column. A 1000-row read of a 10-field
1059
+ * entity otherwise asks `isJsonType` (which lowercases a string on every call) 10,000 times to get
1060
+ * the same ten answers. Most entities land here for their numeric columns alone, where the per-row
1061
+ * cost is one `typeof` against a value the driver usually decoded already.
1062
+ *
1063
+ * Dialect-aware exactly like {@link supportedVectorType}, because it has to be: a `sparsevec` field
1064
+ * is written as a plain dense vector everywhere but Postgres, so reading it back by the field's own
1065
+ * declared cast would look for a sparse literal that was never stored.
1066
+ *
1067
+ * A type lands here rather than at the driver when the wire type alone cannot decide it, and only
1068
+ * the declaration can: `Boolean` is 0/1 in a SQLite INTEGER and a MySQL `TINYINT(1)`, both
1069
+ * indistinguishable from a genuine small integer; a decimal is text from pg *and* mysql2, and only
1070
+ * the field says it was meant as a number; and `type: BigInt` shares BIGINT with `type: Number`, so
1071
+ * the wire decode has to be undone for it. All are no-ops where the driver already decoded.
1072
+ *
1073
+ * Classified through the same `isNumericType`/`isBooleanType`/`isJsonType` the rest of the library
1074
+ * uses, not against the constructors: `type` accepts a string logical type for every one of these
1075
+ * (`@Field({ type: 'decimal' })`), and matching `=== Number` alone left those reading back as text.
1076
+ */
1077
+ hydratableFields(entity) {
1078
+ const cached = this.hydratable.get(entity);
1079
+ if (cached) {
1080
+ return cached;
1081
+ }
1082
+ const decoded = [];
1083
+ for (const [key, field] of Object.entries(getMeta(entity).fields)) {
1084
+ const kind = this.hydrateKind(field);
1085
+ if (kind) {
1086
+ decoded.push([key, kind]);
1087
+ }
1088
+ }
1089
+ this.hydratable.set(entity, decoded);
1090
+ return decoded;
1091
+ }
1092
+ /**
1093
+ * The same classification for an aggregate row. Not cached, because these columns are a shape of the
1094
+ * query rather than of the entity, and it is computed once per call either way.
1095
+ *
1096
+ * Mirrors `QueryAggregateFnResult`, which is the contract callers already compile against:
1097
+ * `$count`/`$sum`/`$avg` are a number whatever they aggregate, while `$min`/`$max` and every
1098
+ * `$group` column keep the aggregated field's own type, so they decode as that field would. Without
1099
+ * it a `$sum` over a BIGINT column came back as `'500'` from a result type that says `number`, since
1100
+ * Postgres widens that sum to NUMERIC and no driver can know it was meant as a JS number.
1101
+ */
1102
+ hydratableAggregates(entity, q) {
1103
+ const { fields } = getMeta(entity);
1104
+ const decoded = [];
1105
+ for (const entry of parseGroupMap(q.$group, q.$agg)) {
1106
+ if (entry.kind === 'fn' && entry.op !== '$min' && entry.op !== '$max') {
1107
+ decoded.push([entry.alias, 'number']);
1108
+ continue;
1109
+ }
1110
+ // `$min`/`$max` read the field they aggregate; a `$group` column is that field. Only `$count`
1111
+ // takes `'*'`, and it went down the numeric path above, so there is always a field to look up.
1112
+ const key = entry.kind === 'fn' ? entry.fieldRef : entry.alias;
1113
+ const kind = this.hydrateKind(fields[key]);
1114
+ if (kind) {
1115
+ decoded.push([entry.alias, kind]);
1116
+ }
1117
+ }
1118
+ return decoded;
1119
+ }
1120
+ /**
1121
+ * The mirror of {@link persistKind}: what one column decodes as, or nothing if it needs no decode.
1122
+ *
1123
+ * Ordered for correctness, not for speed - this runs once per entity, cached, never per row. The
1124
+ * one order that is load-bearing is `BigInt` before {@link isNumericType}, which answers true for
1125
+ * `BigInt` as well as `Number`: swap them and every `type: BigInt` property silently decodes to a
1126
+ * JS number again.
1127
+ */
1128
+ hydrateKind(field) {
1129
+ const type = field?.type;
1130
+ if (isJsonType(type)) {
1131
+ return 'json';
1132
+ }
1133
+ if (isVectorFieldType(type)) {
1134
+ return this.supportedVectorType(resolveVectorCast(field));
1135
+ }
1136
+ if (isBooleanType(type)) {
1137
+ return 'boolean';
1138
+ }
1139
+ if (type === BigInt) {
1140
+ return 'bigint';
1141
+ }
1142
+ return isNumericType(type) ? 'number' : undefined;
1143
+ }
1144
+ hydratable = new WeakMap();
1092
1145
  /** The one type dispatch for a persisted value, over a column kind decided by the caller. */
1093
1146
  writePersistableValue(ctx, kind, field, value) {
1094
1147
  if (value instanceof QueryRaw) {
@@ -1218,7 +1271,7 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1218
1271
  operators.forEach((op, index) => {
1219
1272
  if (index > 0)
1220
1273
  ctx.append(' AND ');
1221
- const asJson = this.isJsonbOp(op, value[op]);
1274
+ const asJson = isJsonbOp(op, value[op]);
1222
1275
  const sql = this.buildJsonFieldCondition(ctx, () => accessor(asJson), jsonPath, op, value[op], asJson);
1223
1276
  if (sql) {
1224
1277
  ctx.append(sql);
@@ -1366,24 +1419,35 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1366
1419
  ctx.append(')');
1367
1420
  }
1368
1421
  }
1422
+ /** The runtime half of {@link QuerySizeComparisonOps}: what a count can sensibly be compared with. */
1423
+ static SIZE_COMPARE_OPS = new Set([
1424
+ '$eq',
1425
+ '$ne',
1426
+ '$gt',
1427
+ '$gte',
1428
+ '$lt',
1429
+ '$lte',
1430
+ '$between',
1431
+ ]);
1369
1432
  /**
1370
- * Append a single size comparison operator and value to the context.
1433
+ * Append a single size comparison operator and value. No operand: the count expression is already
1434
+ * in the context, so this contributes only the ` <op> <value>` tail.
1435
+ *
1436
+ * Gated on {@link SIZE_COMPARE_OPS} rather than on whatever the shared renderer accepts, because
1437
+ * that renderer also knows `$like`, `$regex` and `$in`, none of which mean anything against a
1438
+ * count. `$size: { $like: 5 }` has to stay the error it always was.
1371
1439
  */
1372
1440
  appendSizeOp(ctx, op, val) {
1373
- if (op === '$between') {
1374
- const [min, max] = val;
1375
- ctx.append(' BETWEEN ');
1376
- ctx.addValue(min);
1377
- ctx.append(' AND ');
1378
- ctx.addValue(max);
1379
- return;
1380
- }
1381
- const sqlOp = AbstractSqlDialect.comparisonOpMap.get(op);
1382
- if (!sqlOp) {
1441
+ if (!AbstractSqlDialect.SIZE_COMPARE_OPS.has(op)) {
1383
1442
  throw TypeError(`unsupported $size comparison operator: ${op}`);
1384
1443
  }
1385
- ctx.append(` ${sqlOp} `);
1386
- ctx.addValue(val);
1444
+ // A COUNT is never NULL, so equality stays plain here instead of taking the shared renderer's
1445
+ // null-safe `$ne` (`IS DISTINCT FROM` on Postgres, `IS NOT` on SQLite). Same rows, shorter SQL.
1446
+ if (op === '$eq' || op === '$ne') {
1447
+ this.appendFieldSql(ctx, undefined, ` ${op === '$eq' ? '=' : '<>'} ${this.addValue(ctx.values, val)}`);
1448
+ return;
1449
+ }
1450
+ this.appendOperatorCondition(ctx, undefined, op, val);
1387
1451
  }
1388
1452
  /** ANSI-style single-quote escaping. MySQL-family dialects override this for backslash escaping. */
1389
1453
  escape(value) {
@@ -0,0 +1,16 @@
1
+ import { type VectorCast } from './vectorCast.js';
2
+ /**
3
+ * How a stored column is decoded on read: the inverse of `AbstractSqlDialect.persistKind`. `json`
4
+ * parses; a {@link VectorCast} says which literal; `boolean` undoes an engine with no boolean type,
5
+ * `number` and `bigint` a driver that hands a wide integer or a decimal back as text.
6
+ */
7
+ export type HydrateKind = 'json' | 'boolean' | 'number' | 'bigint' | VectorCast;
8
+ /**
9
+ * Decode one non-null cell. Kept beside {@link HydrateKind} rather than inlined into the querier's
10
+ * row walk, so classifying a column and decoding it stay one subject in one file.
11
+ *
12
+ * Every branch is a no-op on a value the driver already decoded, because which types arrive as text
13
+ * varies per driver and the entity is the only thing that says what they were meant to be. A value
14
+ * that does not match its column's format is returned untouched rather than replaced by a guess.
15
+ */
16
+ export declare function decodeColumn(value: unknown, kind: HydrateKind): unknown;
@@ -0,0 +1,66 @@
1
+ import { parseVectorLiteral } from './vectorCast.js';
2
+ /**
3
+ * Decode one non-null cell. Kept beside {@link HydrateKind} rather than inlined into the querier's
4
+ * row walk, so classifying a column and decoding it stay one subject in one file.
5
+ *
6
+ * Every branch is a no-op on a value the driver already decoded, because which types arrive as text
7
+ * varies per driver and the entity is the only thing that says what they were meant to be. A value
8
+ * that does not match its column's format is returned untouched rather than replaced by a guess.
9
+ */
10
+ export function decodeColumn(value, kind) {
11
+ if (kind === 'boolean') {
12
+ // 0/1 from SQLite's INTEGER or MySQL's TINYINT(1). Already a boolean on Postgres.
13
+ return typeof value === 'boolean' ? value : Boolean(value);
14
+ }
15
+ const text = asText(value);
16
+ if (kind === 'bigint') {
17
+ if (typeof value === 'bigint') {
18
+ return value;
19
+ }
20
+ try {
21
+ // A number, not just text: `type: BigInt` is BIGINT, which the pg pools decode at the wire.
22
+ return BigInt(text ?? value);
23
+ }
24
+ catch {
25
+ // Not an integer after all (a fractional column declared `bigint`); keep what the driver gave.
26
+ return value;
27
+ }
28
+ }
29
+ // Everything below decodes text; anything else the driver already returned correctly.
30
+ if (text === undefined) {
31
+ return value;
32
+ }
33
+ if (kind === 'number') {
34
+ const decoded = Number(text);
35
+ return Number.isNaN(decoded) ? value : decoded;
36
+ }
37
+ if (kind === 'json') {
38
+ try {
39
+ return JSON.parse(text);
40
+ }
41
+ catch {
42
+ // Keep the original value when the driver returns non-JSON text.
43
+ return value;
44
+ }
45
+ }
46
+ return parseVectorLiteral(text, kind) ?? value;
47
+ }
48
+ /** Lazy so a consumer that never reads an encoded column never constructs one. */
49
+ let decoder;
50
+ /**
51
+ * The text a driver returned, or `undefined` when it returned something already decoded.
52
+ *
53
+ * Bytes count as text: `bun:sql` hands a MySQL DECIMAL, and any `SUM` over one, back as a `Buffer`,
54
+ * so a string-only check left those as raw bytes. `TextDecoder` rather than `Buffer.toString`, because
55
+ * this module is reachable from the browser entry and may not name a Node builtin.
56
+ */
57
+ function asText(value) {
58
+ if (typeof value === 'string') {
59
+ return value;
60
+ }
61
+ if (value instanceof Uint8Array) {
62
+ decoder ??= new TextDecoder();
63
+ return decoder.decode(value);
64
+ }
65
+ return undefined;
66
+ }
@@ -41,3 +41,27 @@ export declare function jsonSetTarget(expr: string, field: FieldOptions | undefi
41
41
  export declare function jsonRemoveCall(fn: string, expr: string, keys: readonly string[]): string;
42
42
  /** `WHERE` is omitted for an empty `$elemMatch`, which asks only that the array has an element. */
43
43
  export declare function jsonElemExists(from: string, conditions: readonly string[]): string;
44
+ /**
45
+ * How a JSON scalar has to be compared against `value` (or, for `$in`/`$nin`, against every element
46
+ * of it). Extracting a JSON value yields *text*, which loses the type, so each operand type is
47
+ * compared in the representation every engine agrees on:
48
+ * - `numeric` - cast the accessor. Keeps `1` equal to a stored `1.0`, which strict JSON equality
49
+ * would not, and satisfies drivers that send typed parameters (`text = integer` otherwise).
50
+ * - `json` - compare the JSON value against a JSON-encoded parameter. No cast recovers a boolean
51
+ * portably: PostgreSQL raises `text = boolean` and MySQL matches `'true'` against `1`.
52
+ * - `text` - compare as extracted, which is also what the string operators need.
53
+ *
54
+ * Mixed operand types fall back to `text`, since one comparison cannot be two shapes at once.
55
+ */
56
+ export declare function jsonCompareMode(value: unknown): 'json' | 'numeric' | 'text';
57
+ /**
58
+ * Whether the operator reads the JSON *value* instead of its text form. The array operators always
59
+ * do. Equality joins them for boolean operands, because extracting JSON as text loses the type in
60
+ * a way no cast recovers portably: PostgreSQL raises `operator does not exist: text = boolean`,
61
+ * MySQL compares `'true'` to `1` and silently matches nothing, and SQLite's `json_extract` yields
62
+ * `1`. Comparing the JSON value against a JSON-encoded parameter is exact on every dialect.
63
+ *
64
+ * Numbers stay on the text accessor with a numeric cast, which keeps `1` equal to `1.0` - JSON
65
+ * equality would not.
66
+ */
67
+ export declare function isJsonbOp(op: string, value?: unknown): boolean;
@@ -54,3 +54,42 @@ export function jsonElemExists(from, conditions) {
54
54
  const where = conditions.length ? ` WHERE ${conditions.join(' AND ')}` : '';
55
55
  return `EXISTS (SELECT 1 FROM ${from}${where})`;
56
56
  }
57
+ /**
58
+ * How a JSON scalar has to be compared against `value` (or, for `$in`/`$nin`, against every element
59
+ * of it). Extracting a JSON value yields *text*, which loses the type, so each operand type is
60
+ * compared in the representation every engine agrees on:
61
+ * - `numeric` - cast the accessor. Keeps `1` equal to a stored `1.0`, which strict JSON equality
62
+ * would not, and satisfies drivers that send typed parameters (`text = integer` otherwise).
63
+ * - `json` - compare the JSON value against a JSON-encoded parameter. No cast recovers a boolean
64
+ * portably: PostgreSQL raises `text = boolean` and MySQL matches `'true'` against `1`.
65
+ * - `text` - compare as extracted, which is also what the string operators need.
66
+ *
67
+ * Mixed operand types fall back to `text`, since one comparison cannot be two shapes at once.
68
+ */
69
+ export function jsonCompareMode(value) {
70
+ const operands = Array.isArray(value) ? value : [value];
71
+ if (operands.length === 0) {
72
+ return 'text';
73
+ }
74
+ if (operands.every((operand) => typeof operand === 'boolean')) {
75
+ return 'json';
76
+ }
77
+ return operands.every((operand) => typeof operand === 'number') ? 'numeric' : 'text';
78
+ }
79
+ /**
80
+ * Whether the operator reads the JSON *value* instead of its text form. The array operators always
81
+ * do. Equality joins them for boolean operands, because extracting JSON as text loses the type in
82
+ * a way no cast recovers portably: PostgreSQL raises `operator does not exist: text = boolean`,
83
+ * MySQL compares `'true'` to `1` and silently matches nothing, and SQLite's `json_extract` yields
84
+ * `1`. Comparing the JSON value against a JSON-encoded parameter is exact on every dialect.
85
+ *
86
+ * Numbers stay on the text accessor with a numeric cast, which keeps `1` equal to `1.0` - JSON
87
+ * equality would not.
88
+ */
89
+ export function isJsonbOp(op, value) {
90
+ if (op === '$all' || op === '$size' || op === '$elemMatch') {
91
+ return true;
92
+ }
93
+ const comparesValue = op === '$eq' || op === '$ne' || op === '$in' || op === '$nin';
94
+ return comparesValue && jsonCompareMode(value) === 'json';
95
+ }
@@ -36,6 +36,11 @@ export declare abstract class MysqlLikeSqlDialect extends AbstractSqlDialect {
36
36
  * payload) has to land after the insert's for a `?`-placeholder driver.
37
37
  */
38
38
  upsert<E>(ctx: QueryContext, entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: E | E[]): void;
39
+ /**
40
+ * Appended to both branches above. Empty on MySQL, which has no `INSERT ... RETURNING`; MariaDB
41
+ * 10.5+ has it, and used to restate this whole method just to add it.
42
+ */
43
+ protected upsertReturning<E>(_entity: Type<E>): string;
39
44
  readonly maxBindValues: number;
40
45
  escape(value: unknown): string;
41
46
  /**
@@ -58,17 +58,26 @@ export class MysqlLikeSqlDialect extends AbstractSqlDialect {
58
58
  const meta = getMeta(entity);
59
59
  const updateCtx = this.createContext();
60
60
  const update = this.getUpsertUpdateAssignments(updateCtx, meta, conflictPaths, payload, (name) => `VALUES(${name})`);
61
+ const returning = this.upsertReturning(entity);
61
62
  if (update) {
62
63
  this.appendInsertValues(ctx, entity, payload);
63
- ctx.append(` ON DUPLICATE KEY UPDATE ${update}`);
64
+ ctx.append(` ON DUPLICATE KEY UPDATE ${update}${returning}`);
64
65
  ctx.pushValue(...updateCtx.values);
65
66
  return;
66
67
  }
67
68
  const insertCtx = this.createContext();
68
69
  this.appendInsertValues(insertCtx, entity, payload);
69
70
  ctx.append(insertCtx.sql.replace(/^INSERT/, 'INSERT IGNORE'));
71
+ ctx.append(returning);
70
72
  ctx.pushValue(...insertCtx.values);
71
73
  }
74
+ /**
75
+ * Appended to both branches above. Empty on MySQL, which has no `INSERT ... RETURNING`; MariaDB
76
+ * 10.5+ has it, and used to restate this whole method just to add it.
77
+ */
78
+ upsertReturning(_entity) {
79
+ return '';
80
+ }
72
81
  maxBindValues = 65535;
73
82
  escape(value) {
74
83
  return escapeMysqlSqlLiteral(value);
@@ -1,4 +1,4 @@
1
- import { type DialectFeatures, type EntityMeta, type FieldOptions, type IndexColumnSchema, type IndexFeature, type IndexSchema, type JsonColumnType, type QueryConflictPaths, 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 IndexColumnSchema, type IndexFeature, type IndexSchema, type JsonColumnType, type QueryContext, type QuerySizeComparisonOps, type QueryTextSearchOptions, type QueryVectorSearch, type Type, type VectorDistance } 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
@@ -19,6 +19,8 @@ export declare abstract class PgLikeSqlDialect extends AbstractSqlDialect {
19
19
  readonly commitTransactionCommand = "COMMIT";
20
20
  readonly rollbackTransactionCommand = "ROLLBACK";
21
21
  readonly alterColumnStrategy = "separate-clauses";
22
+ /** `$N` placeholders carry their own index, so the upsert's assignments need no scratch context. */
23
+ protected readonly upsertUpdateBindsInPlace = true;
22
24
  readonly insertIdSource = "returning";
23
25
  readonly maxBindValues: number;
24
26
  /**
@@ -46,12 +48,6 @@ export declare abstract class PgLikeSqlDialect extends AbstractSqlDialect {
46
48
  protected indexInclude(index: IndexSchema): string;
47
49
  protected indexTuning(index: IndexSchema): string;
48
50
  placeholder(index: number): string;
49
- /**
50
- * The same statement as the base, binding the assignments into the main context instead of a second
51
- * one: `$N` placeholders carry their own index, so the values need not be in statement order, and
52
- * computing them first is what keeps `appendInsertValues`' `onInsert` fields out of the update set.
53
- */
54
- upsert<E>(ctx: QueryContext, entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: E | E[], extraReturning?: string): void;
55
51
  /**
56
52
  * `to_tsvector(...) @@ websearch_to_tsquery(...)`. `websearch_to_tsquery` takes free-form user input
57
53
  * (quoted phrases, `or`, `-negation`) and never raises a syntax error, unlike `to_tsquery`, which