uql-orm 0.24.6 → 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 (56) 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 +189 -129
  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/neon/neonQuerierPool.d.ts +1 -1
  40. package/dist/neon/neonQuerierPool.js +6 -4
  41. package/dist/postgres/abstractPgQuerierPool.d.ts +6 -0
  42. package/dist/postgres/abstractPgQuerierPool.js +3 -0
  43. package/dist/postgres/pgNumericTypes.d.ts +41 -0
  44. package/dist/postgres/pgNumericTypes.js +35 -0
  45. package/dist/postgres/pgQuerierPool.d.ts +1 -1
  46. package/dist/postgres/pgQuerierPool.js +5 -4
  47. package/dist/querier/abstractSqlQuerier.d.ts +9 -2
  48. package/dist/querier/abstractSqlQuerier.js +31 -24
  49. package/dist/schema/canonicalType.js +2 -12
  50. package/dist/schema/schemaAST.js +0 -24
  51. package/dist/schema/schemaASTBuilder.js +0 -3
  52. package/dist/type/queryAggregate.d.ts +3 -0
  53. package/dist/util/field.util.d.ts +4 -0
  54. package/dist/util/field.util.js +12 -0
  55. package/dist/util/sqlLiteral.js +18 -15
  56. package/package.json +4 -4
@@ -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);
@@ -588,29 +610,7 @@ export class AbstractSqlDialect extends IndexSqlDialect {
588
610
  * {@link jsonCompareMode} for why each mode exists.
589
611
  */
590
612
  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';
613
+ return jsonCompareMode(value) === 'numeric' ? this.numericCast(jsonField) : jsonField;
614
614
  }
615
615
  /**
616
616
  * Whether the dialect's array containment ({@link jsonAll}) matches an object element that merely
@@ -638,7 +638,7 @@ export class AbstractSqlDialect extends IndexSqlDialect {
638
638
  // only when *every* operand needs it - the same all-operands rule the comparison classifier uses.
639
639
  if (isOperatorOnlyObject(match)) {
640
640
  const entries = Object.entries(match);
641
- const asJson = !this.jsonScalarElemKeepsType && entries.every(([op, val]) => this.isJsonbOp(op, val));
641
+ const asJson = !this.jsonScalarElemKeepsType && entries.every(([op, val]) => isJsonbOp(op, val));
642
642
  const alias = ctx.nextAlias(JSON_ELEM_ALIAS_PREFIX);
643
643
  const conditions = entries.map(([op, val]) => this.buildJsonFieldCondition(ctx, () => this.jsonElemRef(alias, undefined, asJson), '', op, val, asJson));
644
644
  return jsonElemExists(this.jsonElemFrom(jsonField, [], alias, asJson), conditions);
@@ -653,28 +653,11 @@ export class AbstractSqlDialect extends IndexSqlDialect {
653
653
  }
654
654
  const alias = ctx.nextAlias(JSON_ELEM_ALIAS_PREFIX);
655
655
  const conditions = buildElemMatchConditions(match, (field, op, opVal) => {
656
- const asJson = this.isJsonbOp(op, opVal);
656
+ const asJson = isJsonbOp(op, opVal);
657
657
  return this.buildJsonFieldCondition(ctx, (f) => this.jsonElemRef(alias, f, asJson), field, op, opVal, asJson);
658
658
  });
659
659
  return jsonElemExists(this.jsonElemFrom(jsonField, Object.keys(match), alias), conditions);
660
660
  }
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
661
  /**
679
662
  * A JSON-encoded bound parameter, cast to the dialect's JSON type. Only the positional-placeholder
680
663
  * dialects use this - PostgreSQL binds JSON through {@link PgLikeSqlDialect.jsonScalarParam} instead.
@@ -856,51 +839,17 @@ export class AbstractSqlDialect extends IndexSqlDialect {
856
839
  return direction;
857
840
  }
858
841
  /** 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
842
  havingCondition(ctx, expr, condition) {
868
843
  if (typeof condition !== 'object' || condition === null) {
869
- ctx.append(`${expr} = `);
870
- ctx.addValue(condition);
844
+ this.appendOperatorCondition(ctx, expr, '$eq', condition);
871
845
  return;
872
846
  }
873
847
  const ops = condition;
874
- const keys = getKeys(ops);
875
- keys.forEach((op, i) => {
848
+ getKeys(ops).forEach((op, i) => {
876
849
  if (i > 0)
877
850
  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);
851
+ if (!this.appendOperatorCondition(ctx, expr, op, ops[op])) {
852
+ throw TypeError(`unsupported HAVING operator: ${op}`);
904
853
  }
905
854
  });
906
855
  }
@@ -1008,14 +957,25 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1008
957
  */
1009
958
  upsert(ctx, entity, conflictPaths, payload, extraReturning = '') {
1010
959
  const meta = getMeta(entity);
1011
- const updateCtx = this.createContext();
960
+ const updateCtx = this.upsertUpdateBindsInPlace ? ctx : this.createContext();
1012
961
  const update = this.getUpsertUpdateAssignments(updateCtx, meta, conflictPaths, payload, this.upsertExcluded);
1013
962
  const keys = this.getUpsertConflictPathsStr(meta, conflictPaths);
1014
963
  const onConflict = update ? `DO UPDATE SET ${update}` : 'DO NOTHING';
1015
964
  this.appendInsertValues(ctx, entity, payload);
1016
965
  ctx.append(` ON CONFLICT (${keys}) ${onConflict} ${this.returningId(entity)}${extraReturning}`);
1017
- ctx.pushValue(...updateCtx.values);
966
+ if (updateCtx !== ctx) {
967
+ ctx.pushValue(...updateCtx.values);
968
+ }
1018
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;
1019
979
  /** How an `ON CONFLICT` assignment reads the row that was being inserted. */
1020
980
  upsertExcluded = (columnName) => `EXCLUDED.${columnName}`;
1021
981
  getUpsertUpdateAssignments(ctx, meta, conflictPaths, payload, callback) {
@@ -1089,6 +1049,95 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1089
1049
  }
1090
1050
  return isVectorFieldType(type) ? 'vector' : 'plain';
1091
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();
1092
1141
  /** The one type dispatch for a persisted value, over a column kind decided by the caller. */
1093
1142
  writePersistableValue(ctx, kind, field, value) {
1094
1143
  if (value instanceof QueryRaw) {
@@ -1218,7 +1267,7 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1218
1267
  operators.forEach((op, index) => {
1219
1268
  if (index > 0)
1220
1269
  ctx.append(' AND ');
1221
- const asJson = this.isJsonbOp(op, value[op]);
1270
+ const asJson = isJsonbOp(op, value[op]);
1222
1271
  const sql = this.buildJsonFieldCondition(ctx, () => accessor(asJson), jsonPath, op, value[op], asJson);
1223
1272
  if (sql) {
1224
1273
  ctx.append(sql);
@@ -1366,24 +1415,35 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1366
1415
  ctx.append(')');
1367
1416
  }
1368
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
+ ]);
1369
1428
  /**
1370
- * 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.
1371
1435
  */
1372
1436
  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) {
1437
+ if (!AbstractSqlDialect.SIZE_COMPARE_OPS.has(op)) {
1383
1438
  throw TypeError(`unsupported $size comparison operator: ${op}`);
1384
1439
  }
1385
- ctx.append(` ${sqlOp} `);
1386
- 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);
1387
1447
  }
1388
1448
  /** ANSI-style single-quote escaping. MySQL-family dialects override this for backslash escaping. */
1389
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);
@@ -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