uql-orm 0.24.5 → 0.24.7

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 (62) 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 -28
  5. package/dist/dialect/abstractSqlDialect.js +196 -147
  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/introspection/abstractSqlSchemaIntrospector.d.ts +8 -2
  29. package/dist/migrate/introspection/abstractSqlSchemaIntrospector.js +10 -9
  30. package/dist/migrate/introspection/mysqlIntrospector.d.ts +0 -3
  31. package/dist/migrate/introspection/mysqlIntrospector.js +0 -9
  32. package/dist/migrate/introspection/postgresIntrospector.d.ts +0 -3
  33. package/dist/migrate/introspection/postgresIntrospector.js +0 -12
  34. package/dist/migrate/introspection/sqliteIntrospector.d.ts +1 -0
  35. package/dist/migrate/introspection/sqliteIntrospector.js +1 -9
  36. package/dist/migrate/migrator.d.ts +8 -0
  37. package/dist/migrate/migrator.js +19 -29
  38. package/dist/migrate/schemaGenerator.js +0 -12
  39. package/dist/mongo/mongoDialect.d.ts +7 -0
  40. package/dist/mongo/mongoDialect.js +37 -1
  41. package/dist/mongo/mongodbQuerier.js +8 -3
  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/abstractQuerier.d.ts +0 -3
  51. package/dist/querier/abstractQuerier.js +10 -9
  52. package/dist/querier/abstractSqlQuerier.d.ts +9 -2
  53. package/dist/querier/abstractSqlQuerier.js +31 -24
  54. package/dist/schema/canonicalType.js +2 -12
  55. package/dist/schema/schemaAST.js +0 -24
  56. package/dist/schema/schemaASTBuilder.js +0 -3
  57. package/dist/type/query.d.ts +2 -0
  58. package/dist/type/queryAggregate.d.ts +3 -0
  59. package/dist/util/field.util.d.ts +4 -0
  60. package/dist/util/field.util.js +12 -0
  61. package/dist/util/sqlLiteral.js +18 -15
  62. package/package.json +5 -5
@@ -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';
@@ -100,23 +100,13 @@ export class AbstractSqlDialect extends IndexSqlDialect {
100
100
  const meta = getMeta(entity);
101
101
  const prefix = opts.prefix ? opts.prefix + '.' : '';
102
102
  const escapedPrefix = this.escapeId(opts.prefix, true, true);
103
- let selectArr;
104
- if (select) {
105
- if (Array.isArray(select)) {
106
- // Raw SQL projections passed as QueryRaw[]
107
- selectArr = select;
108
- }
109
- else {
110
- selectArr = normalizeScalarFieldSelection(meta, asSelectMap(select), exclude);
111
- }
112
- const id = meta.id;
113
- if (id && opts.prefix && !selectArr.includes(id)) {
114
- selectArr = [id, ...selectArr];
115
- }
116
- }
117
- else {
118
- selectArr = normalizeScalarFieldSelection(meta, undefined, exclude);
119
- }
103
+ const scalars = Array.isArray(select)
104
+ ? select // raw SQL projections passed as QueryRaw[]
105
+ : normalizeScalarFieldSelection(meta, asSelectMap(select), exclude);
106
+ // A prefix means relations are in play: rows arrive keyed by the id and `fillToManyRelations`
107
+ // groups children by it, so it outlives any subtraction - `$exclude` or falsy `$select` alike.
108
+ const id = meta.id;
109
+ const selectArr = id && opts.prefix && !scalars.includes(id) ? [id, ...scalars] : scalars;
120
110
  if (!selectArr.length) {
121
111
  ctx.append(escapedPrefix + '*');
122
112
  return;
@@ -387,7 +377,6 @@ export class AbstractSqlDialect extends IndexSqlDialect {
387
377
  ctx.append(')');
388
378
  }
389
379
  }
390
- /** Simple comparison operators: `getComparisonKey → op → addValue`. */
391
380
  /** Memoizes {@link escapedColumnName}; see there for why it is per dialect instance. */
392
381
  escapedColumns = new WeakMap();
393
382
  static NEGATE_OP_MAP = new Map([
@@ -444,44 +433,15 @@ export class AbstractSqlDialect extends IndexSqlDialect {
444
433
  }
445
434
  compareFieldOperator(ctx, entity, key, op, val, opts = {}) {
446
435
  const field = this.resolveOperandField(ctx, entity, key, opts);
447
- const simpleOp = AbstractSqlDialect.COMPARE_OP_MAP.get(op);
448
- if (simpleOp) {
449
- this.appendFieldSql(ctx, field, `${simpleOp}${this.addValue(ctx.values, val)}`);
450
- return;
451
- }
452
- const likeWrap = AbstractSqlDialect.LIKE_OP_MAP.get(op);
453
- if (likeWrap) {
454
- this.appendLikeOp(ctx, field, op, likeWrap(val));
436
+ if (this.appendOperatorCondition(ctx, field, op, val)) {
455
437
  return;
456
438
  }
457
439
  switch (op) {
458
- case '$eq':
459
- case '$ne':
460
- this.appendEqNe(ctx, field, op, val);
461
- break;
462
- case '$regex':
463
- this.appendFieldSql(ctx, field, ` ${this.regexpOp} ${this.addValue(ctx.values, val)}`);
464
- break;
465
440
  case '$not':
466
441
  ctx.append('NOT (');
467
442
  this.compare(ctx, entity, key, val, opts);
468
443
  ctx.append(')');
469
444
  break;
470
- case '$in':
471
- case '$nin':
472
- this.appendInNin(ctx, field, op, val);
473
- break;
474
- case '$between': {
475
- const [min, max] = val;
476
- this.appendFieldSql(ctx, field, ` BETWEEN ${this.addValue(ctx.values, min)} AND ${this.addValue(ctx.values, max)}`);
477
- break;
478
- }
479
- case '$isNull':
480
- this.appendFieldSql(ctx, field, val ? ' IS NULL' : ' IS NOT NULL');
481
- break;
482
- case '$isNotNull':
483
- this.appendFieldSql(ctx, field, val ? ' IS NOT NULL' : ' IS NULL');
484
- break;
485
445
  case '$all':
486
446
  ctx.append(this.jsonAll(ctx, field ?? '', val));
487
447
  break;
@@ -495,6 +455,57 @@ export class AbstractSqlDialect extends IndexSqlDialect {
495
455
  throw TypeError(`unknown operator: ${op}`);
496
456
  }
497
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
+ }
498
509
  appendLikeOp(ctx, field, op, wrappedVal) {
499
510
  const isIlike = AbstractSqlDialect.LIKE_CASE_INSENSITIVE_OPS.has(op);
500
511
  const ph = this.addValue(ctx.values, wrappedVal);
@@ -599,29 +610,7 @@ export class AbstractSqlDialect extends IndexSqlDialect {
599
610
  * {@link jsonCompareMode} for why each mode exists.
600
611
  */
601
612
  jsonComparand(jsonField, value) {
602
- return this.jsonCompareMode(value) === 'numeric' ? this.numericCast(jsonField) : jsonField;
603
- }
604
- /**
605
- * How a JSON scalar has to be compared against `value` (or, for `$in`/`$nin`, against every element
606
- * of it). Extracting a JSON value yields *text*, which loses the type, so each operand type is
607
- * compared in the representation every engine agrees on:
608
- * - `numeric` - cast the accessor. Keeps `1` equal to a stored `1.0`, which strict JSON equality
609
- * would not, and satisfies drivers that send typed parameters (`text = integer` otherwise).
610
- * - `json` - compare the JSON value against a JSON-encoded parameter. No cast recovers a boolean
611
- * portably: PostgreSQL raises `text = boolean` and MySQL matches `'true'` against `1`.
612
- * - `text` - compare as extracted, which is also what the string operators need.
613
- *
614
- * Mixed operand types fall back to `text`, since one comparison cannot be two shapes at once.
615
- */
616
- jsonCompareMode(value) {
617
- const operands = Array.isArray(value) ? value : [value];
618
- if (operands.length === 0) {
619
- return 'text';
620
- }
621
- if (operands.every((operand) => typeof operand === 'boolean')) {
622
- return 'json';
623
- }
624
- return operands.every((operand) => typeof operand === 'number') ? 'numeric' : 'text';
613
+ return jsonCompareMode(value) === 'numeric' ? this.numericCast(jsonField) : jsonField;
625
614
  }
626
615
  /**
627
616
  * Whether the dialect's array containment ({@link jsonAll}) matches an object element that merely
@@ -649,7 +638,7 @@ export class AbstractSqlDialect extends IndexSqlDialect {
649
638
  // only when *every* operand needs it - the same all-operands rule the comparison classifier uses.
650
639
  if (isOperatorOnlyObject(match)) {
651
640
  const entries = Object.entries(match);
652
- const asJson = !this.jsonScalarElemKeepsType && entries.every(([op, val]) => this.isJsonbOp(op, val));
641
+ const asJson = !this.jsonScalarElemKeepsType && entries.every(([op, val]) => isJsonbOp(op, val));
653
642
  const alias = ctx.nextAlias(JSON_ELEM_ALIAS_PREFIX);
654
643
  const conditions = entries.map(([op, val]) => this.buildJsonFieldCondition(ctx, () => this.jsonElemRef(alias, undefined, asJson), '', op, val, asJson));
655
644
  return jsonElemExists(this.jsonElemFrom(jsonField, [], alias, asJson), conditions);
@@ -664,28 +653,11 @@ export class AbstractSqlDialect extends IndexSqlDialect {
664
653
  }
665
654
  const alias = ctx.nextAlias(JSON_ELEM_ALIAS_PREFIX);
666
655
  const conditions = buildElemMatchConditions(match, (field, op, opVal) => {
667
- const asJson = this.isJsonbOp(op, opVal);
656
+ const asJson = isJsonbOp(op, opVal);
668
657
  return this.buildJsonFieldCondition(ctx, (f) => this.jsonElemRef(alias, f, asJson), field, op, opVal, asJson);
669
658
  });
670
659
  return jsonElemExists(this.jsonElemFrom(jsonField, Object.keys(match), alias), conditions);
671
660
  }
672
- /**
673
- * Whether the operator reads the JSON *value* instead of its text form. The array operators always
674
- * do. Equality joins them for boolean operands, because extracting JSON as text loses the type in
675
- * a way no cast recovers portably: PostgreSQL raises `operator does not exist: text = boolean`,
676
- * MySQL compares `'true'` to `1` and silently matches nothing, and SQLite's `json_extract` yields
677
- * `1`. Comparing the JSON value against a JSON-encoded parameter is exact on every dialect.
678
- *
679
- * Numbers stay on the text accessor with a numeric cast ({@link jsonComparand}), which keeps
680
- * `1` equal to `1.0` - JSON equality would not.
681
- */
682
- isJsonbOp(op, value) {
683
- if (op === '$all' || op === '$size' || op === '$elemMatch') {
684
- return true;
685
- }
686
- const comparesValue = op === '$eq' || op === '$ne' || op === '$in' || op === '$nin';
687
- return comparesValue && this.jsonCompareMode(value) === 'json';
688
- }
689
661
  /**
690
662
  * A JSON-encoded bound parameter, cast to the dialect's JSON type. Only the positional-placeholder
691
663
  * dialects use this - PostgreSQL binds JSON through {@link PgLikeSqlDialect.jsonScalarParam} instead.
@@ -867,51 +839,17 @@ export class AbstractSqlDialect extends IndexSqlDialect {
867
839
  return direction;
868
840
  }
869
841
  /** Scalar comparison operators shared by `HAVING` conditions and `$size` comparisons. */
870
- static comparisonOpMap = new Map([
871
- ['$eq', '='],
872
- ['$ne', '<>'],
873
- ['$gt', '>'],
874
- ['$gte', '>='],
875
- ['$lt', '<'],
876
- ['$lte', '<='],
877
- ]);
878
842
  havingCondition(ctx, expr, condition) {
879
843
  if (typeof condition !== 'object' || condition === null) {
880
- ctx.append(`${expr} = `);
881
- ctx.addValue(condition);
844
+ this.appendOperatorCondition(ctx, expr, '$eq', condition);
882
845
  return;
883
846
  }
884
847
  const ops = condition;
885
- const keys = getKeys(ops);
886
- keys.forEach((op, i) => {
848
+ getKeys(ops).forEach((op, i) => {
887
849
  if (i > 0)
888
850
  ctx.append(' AND ');
889
- const val = ops[op];
890
- if (op === '$between') {
891
- const [min, max] = val;
892
- ctx.append(`${expr} BETWEEN `);
893
- ctx.addValue(min);
894
- ctx.append(' AND ');
895
- ctx.addValue(max);
896
- }
897
- else if (op === '$in' || op === '$nin') {
898
- ctx.append(`${expr}${this.formatIn(ctx, Array.isArray(val) ? val : [], op === '$nin')}`);
899
- }
900
- else if (op === '$isNull') {
901
- ctx.append(`${expr}${val ? ' IS NULL' : ' IS NOT NULL'}`);
902
- }
903
- else if (op === '$isNotNull') {
904
- ctx.append(`${expr}${val ? ' IS NOT NULL' : ' IS NULL'}`);
905
- }
906
- else if (op === '$ne') {
907
- ctx.append(this.neExpr(expr, this.addValue(ctx.values, val)));
908
- }
909
- else {
910
- const sqlOp = AbstractSqlDialect.comparisonOpMap.get(op);
911
- if (!sqlOp)
912
- throw TypeError(`unsupported HAVING operator: ${op}`);
913
- ctx.append(`${expr} ${sqlOp} `);
914
- ctx.addValue(val);
851
+ if (!this.appendOperatorCondition(ctx, expr, op, ops[op])) {
852
+ throw TypeError(`unsupported HAVING operator: ${op}`);
915
853
  }
916
854
  });
917
855
  }
@@ -1019,14 +957,25 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1019
957
  */
1020
958
  upsert(ctx, entity, conflictPaths, payload, extraReturning = '') {
1021
959
  const meta = getMeta(entity);
1022
- const updateCtx = this.createContext();
960
+ const updateCtx = this.upsertUpdateBindsInPlace ? ctx : this.createContext();
1023
961
  const update = this.getUpsertUpdateAssignments(updateCtx, meta, conflictPaths, payload, this.upsertExcluded);
1024
962
  const keys = this.getUpsertConflictPathsStr(meta, conflictPaths);
1025
963
  const onConflict = update ? `DO UPDATE SET ${update}` : 'DO NOTHING';
1026
964
  this.appendInsertValues(ctx, entity, payload);
1027
965
  ctx.append(` ON CONFLICT (${keys}) ${onConflict} ${this.returningId(entity)}${extraReturning}`);
1028
- ctx.pushValue(...updateCtx.values);
966
+ if (updateCtx !== ctx) {
967
+ ctx.pushValue(...updateCtx.values);
968
+ }
1029
969
  }
970
+ /**
971
+ * Whether the upsert's update assignments can bind straight into the statement's own context.
972
+ *
973
+ * They cannot on a `?`-placeholder dialect: the assignments are built before the insert but read
974
+ * after it, so their values have to be pushed afterwards to land in the right positional order.
975
+ * A `$n` placeholder carries its own index, so there is nothing to reorder - but it also cannot use
976
+ * the scratch context, whose numbering would restart at `$1` and collide with the insert's.
977
+ */
978
+ upsertUpdateBindsInPlace = false;
1030
979
  /** How an `ON CONFLICT` assignment reads the row that was being inserted. */
1031
980
  upsertExcluded = (columnName) => `EXCLUDED.${columnName}`;
1032
981
  getUpsertUpdateAssignments(ctx, meta, conflictPaths, payload, callback) {
@@ -1100,6 +1049,95 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1100
1049
  }
1101
1050
  return isVectorFieldType(type) ? 'vector' : 'plain';
1102
1051
  }
1052
+ /**
1053
+ * Which of an entity's columns need decoding on READ, and how: the inverse of {@link persistKind},
1054
+ * cached per entity for the same reason it classifies per column. A 1000-row read of a 10-field
1055
+ * entity otherwise asks `isJsonType` (which lowercases a string on every call) 10,000 times to get
1056
+ * the same ten answers. Most entities land here for their numeric columns alone, where the per-row
1057
+ * cost is one `typeof` against a value the driver usually decoded already.
1058
+ *
1059
+ * Dialect-aware exactly like {@link supportedVectorType}, because it has to be: a `sparsevec` field
1060
+ * is written as a plain dense vector everywhere but Postgres, so reading it back by the field's own
1061
+ * declared cast would look for a sparse literal that was never stored.
1062
+ *
1063
+ * A type lands here rather than at the driver when the wire type alone cannot decide it, and only
1064
+ * the declaration can: `Boolean` is 0/1 in a SQLite INTEGER and a MySQL `TINYINT(1)`, both
1065
+ * indistinguishable from a genuine small integer; a decimal is text from pg *and* mysql2, and only
1066
+ * the field says it was meant as a number; and `type: BigInt` shares BIGINT with `type: Number`, so
1067
+ * the wire decode has to be undone for it. All are no-ops where the driver already decoded.
1068
+ *
1069
+ * Classified through the same `isNumericType`/`isBooleanType`/`isJsonType` the rest of the library
1070
+ * uses, not against the constructors: `type` accepts a string logical type for every one of these
1071
+ * (`@Field({ type: 'decimal' })`), and matching `=== Number` alone left those reading back as text.
1072
+ */
1073
+ hydratableFields(entity) {
1074
+ const cached = this.hydratable.get(entity);
1075
+ if (cached) {
1076
+ return cached;
1077
+ }
1078
+ const decoded = [];
1079
+ for (const [key, field] of Object.entries(getMeta(entity).fields)) {
1080
+ const kind = this.hydrateKind(field);
1081
+ if (kind) {
1082
+ decoded.push([key, kind]);
1083
+ }
1084
+ }
1085
+ this.hydratable.set(entity, decoded);
1086
+ return decoded;
1087
+ }
1088
+ /**
1089
+ * The same classification for an aggregate row. Not cached, because these columns are a shape of the
1090
+ * query rather than of the entity, and it is computed once per call either way.
1091
+ *
1092
+ * Mirrors `QueryAggregateFnResult`, which is the contract callers already compile against:
1093
+ * `$count`/`$sum`/`$avg` are a number whatever they aggregate, while `$min`/`$max` and every
1094
+ * `$group` column keep the aggregated field's own type, so they decode as that field would. Without
1095
+ * it a `$sum` over a BIGINT column came back as `'500'` from a result type that says `number`, since
1096
+ * Postgres widens that sum to NUMERIC and no driver can know it was meant as a JS number.
1097
+ */
1098
+ hydratableAggregates(entity, q) {
1099
+ const { fields } = getMeta(entity);
1100
+ const decoded = [];
1101
+ for (const entry of parseGroupMap(q.$group, q.$agg)) {
1102
+ if (entry.kind === 'fn' && entry.op !== '$min' && entry.op !== '$max') {
1103
+ decoded.push([entry.alias, 'number']);
1104
+ continue;
1105
+ }
1106
+ // `$min`/`$max` read the field they aggregate; a `$group` column is that field. Only `$count`
1107
+ // takes `'*'`, and it went down the numeric path above, so there is always a field to look up.
1108
+ const key = entry.kind === 'fn' ? entry.fieldRef : entry.alias;
1109
+ const kind = this.hydrateKind(fields[key]);
1110
+ if (kind) {
1111
+ decoded.push([entry.alias, kind]);
1112
+ }
1113
+ }
1114
+ return decoded;
1115
+ }
1116
+ /**
1117
+ * The mirror of {@link persistKind}: what one column decodes as, or nothing if it needs no decode.
1118
+ *
1119
+ * Ordered for correctness, not for speed - this runs once per entity, cached, never per row. The
1120
+ * one order that is load-bearing is `BigInt` before {@link isNumericType}, which answers true for
1121
+ * `BigInt` as well as `Number`: swap them and every `type: BigInt` property silently decodes to a
1122
+ * JS number again.
1123
+ */
1124
+ hydrateKind(field) {
1125
+ const type = field?.type;
1126
+ if (isJsonType(type)) {
1127
+ return 'json';
1128
+ }
1129
+ if (isVectorFieldType(type)) {
1130
+ return this.supportedVectorType(resolveVectorCast(field));
1131
+ }
1132
+ if (isBooleanType(type)) {
1133
+ return 'boolean';
1134
+ }
1135
+ if (type === BigInt) {
1136
+ return 'bigint';
1137
+ }
1138
+ return isNumericType(type) ? 'number' : undefined;
1139
+ }
1140
+ hydratable = new WeakMap();
1103
1141
  /** The one type dispatch for a persisted value, over a column kind decided by the caller. */
1104
1142
  writePersistableValue(ctx, kind, field, value) {
1105
1143
  if (value instanceof QueryRaw) {
@@ -1229,7 +1267,7 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1229
1267
  operators.forEach((op, index) => {
1230
1268
  if (index > 0)
1231
1269
  ctx.append(' AND ');
1232
- const asJson = this.isJsonbOp(op, value[op]);
1270
+ const asJson = isJsonbOp(op, value[op]);
1233
1271
  const sql = this.buildJsonFieldCondition(ctx, () => accessor(asJson), jsonPath, op, value[op], asJson);
1234
1272
  if (sql) {
1235
1273
  ctx.append(sql);
@@ -1377,24 +1415,35 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1377
1415
  ctx.append(')');
1378
1416
  }
1379
1417
  }
1418
+ /** The runtime half of {@link QuerySizeComparisonOps}: what a count can sensibly be compared with. */
1419
+ static SIZE_COMPARE_OPS = new Set([
1420
+ '$eq',
1421
+ '$ne',
1422
+ '$gt',
1423
+ '$gte',
1424
+ '$lt',
1425
+ '$lte',
1426
+ '$between',
1427
+ ]);
1380
1428
  /**
1381
- * Append a single size comparison operator and value to the context.
1429
+ * Append a single size comparison operator and value. No operand: the count expression is already
1430
+ * in the context, so this contributes only the ` <op> <value>` tail.
1431
+ *
1432
+ * Gated on {@link SIZE_COMPARE_OPS} rather than on whatever the shared renderer accepts, because
1433
+ * that renderer also knows `$like`, `$regex` and `$in`, none of which mean anything against a
1434
+ * count. `$size: { $like: 5 }` has to stay the error it always was.
1382
1435
  */
1383
1436
  appendSizeOp(ctx, op, val) {
1384
- if (op === '$between') {
1385
- const [min, max] = val;
1386
- ctx.append(' BETWEEN ');
1387
- ctx.addValue(min);
1388
- ctx.append(' AND ');
1389
- ctx.addValue(max);
1390
- return;
1391
- }
1392
- const sqlOp = AbstractSqlDialect.comparisonOpMap.get(op);
1393
- if (!sqlOp) {
1437
+ if (!AbstractSqlDialect.SIZE_COMPARE_OPS.has(op)) {
1394
1438
  throw TypeError(`unsupported $size comparison operator: ${op}`);
1395
1439
  }
1396
- ctx.append(` ${sqlOp} `);
1397
- ctx.addValue(val);
1440
+ // A COUNT is never NULL, so equality stays plain here instead of taking the shared renderer's
1441
+ // null-safe `$ne` (`IS DISTINCT FROM` on Postgres, `IS NOT` on SQLite). Same rows, shorter SQL.
1442
+ if (op === '$eq' || op === '$ne') {
1443
+ this.appendFieldSql(ctx, undefined, ` ${op === '$eq' ? '=' : '<>'} ${this.addValue(ctx.values, val)}`);
1444
+ return;
1445
+ }
1446
+ this.appendOperatorCondition(ctx, undefined, op, val);
1398
1447
  }
1399
1448
  /** ANSI-style single-quote escaping. MySQL-family dialects override this for backslash escaping. */
1400
1449
  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);