uql-orm 0.19.0 → 0.20.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 (109) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +1 -1
  3. package/dist/browser/uql-browser.min.js +2 -2
  4. package/dist/browser/uql-browser.min.js.map +3 -3
  5. package/dist/bunSql/bunSqlCockroachDialect.d.ts +1 -1
  6. package/dist/bunSql/bunSqlCockroachDialect.js +1 -1
  7. package/dist/dialect/abstractSqlDialect.d.ts +109 -46
  8. package/dist/dialect/abstractSqlDialect.d.ts.map +1 -1
  9. package/dist/dialect/abstractSqlDialect.js +182 -116
  10. package/dist/dialect/abstractSqlDialect.js.map +1 -1
  11. package/dist/dialect/jsonArrayElemMatchUtils.d.ts +5 -6
  12. package/dist/dialect/jsonArrayElemMatchUtils.d.ts.map +1 -1
  13. package/dist/dialect/jsonArrayElemMatchUtils.js +9 -19
  14. package/dist/dialect/jsonArrayElemMatchUtils.js.map +1 -1
  15. package/dist/dialect/jsonSql.d.ts +24 -0
  16. package/dist/dialect/jsonSql.d.ts.map +1 -0
  17. package/dist/dialect/jsonSql.js +37 -0
  18. package/dist/dialect/jsonSql.js.map +1 -0
  19. package/dist/dialect/mysqlLikeSqlDialect.d.ts +34 -2
  20. package/dist/dialect/mysqlLikeSqlDialect.d.ts.map +1 -1
  21. package/dist/dialect/mysqlLikeSqlDialect.js +63 -17
  22. package/dist/dialect/mysqlLikeSqlDialect.js.map +1 -1
  23. package/dist/dialect/pgLikeSqlDialect.d.ts +23 -4
  24. package/dist/dialect/pgLikeSqlDialect.d.ts.map +1 -1
  25. package/dist/dialect/pgLikeSqlDialect.js +49 -36
  26. package/dist/dialect/pgLikeSqlDialect.js.map +1 -1
  27. package/dist/dialect/vectorSqlDialect.d.ts +54 -0
  28. package/dist/dialect/vectorSqlDialect.d.ts.map +1 -0
  29. package/dist/dialect/vectorSqlDialect.js +73 -0
  30. package/dist/dialect/vectorSqlDialect.js.map +1 -0
  31. package/dist/entity/decorator/index-decorator.d.ts +6 -13
  32. package/dist/entity/decorator/index-decorator.d.ts.map +1 -1
  33. package/dist/entity/decorator/index-decorator.js.map +1 -1
  34. package/dist/maria/mariaDialect.d.ts +15 -3
  35. package/dist/maria/mariaDialect.d.ts.map +1 -1
  36. package/dist/maria/mariaDialect.js +25 -12
  37. package/dist/maria/mariaDialect.js.map +1 -1
  38. package/dist/migrate/generator/mongoSchemaGenerator.d.ts +1 -1
  39. package/dist/migrate/generator/mongoSchemaGenerator.d.ts.map +1 -1
  40. package/dist/mongo/mongoDialect.d.ts +26 -5
  41. package/dist/mongo/mongoDialect.d.ts.map +1 -1
  42. package/dist/mongo/mongoDialect.js +97 -11
  43. package/dist/mongo/mongoDialect.js.map +1 -1
  44. package/dist/mongo/mongodbQuerier.d.ts.map +1 -1
  45. package/dist/mongo/mongodbQuerier.js +2 -1
  46. package/dist/mongo/mongodbQuerier.js.map +1 -1
  47. package/dist/postgres/postgresWireDriverCapabilities.d.ts +1 -1
  48. package/dist/postgres/postgresWireDriverCapabilities.js +1 -1
  49. package/dist/schema/schemaAST.d.ts.map +1 -1
  50. package/dist/schema/schemaAST.js +7 -19
  51. package/dist/schema/schemaAST.js.map +1 -1
  52. package/dist/schema/types.d.ts +1 -1
  53. package/dist/sqlite/sqliteDialect.d.ts +28 -7
  54. package/dist/sqlite/sqliteDialect.d.ts.map +1 -1
  55. package/dist/sqlite/sqliteDialect.js +58 -73
  56. package/dist/sqlite/sqliteDialect.js.map +1 -1
  57. package/dist/type/dialect.d.ts +192 -0
  58. package/dist/type/dialect.d.ts.map +1 -0
  59. package/dist/type/dialect.js +2 -0
  60. package/dist/type/dialect.js.map +1 -0
  61. package/dist/type/entity.d.ts +63 -42
  62. package/dist/type/entity.d.ts.map +1 -1
  63. package/dist/type/entity.js.map +1 -1
  64. package/dist/type/index.d.ts +5 -0
  65. package/dist/type/index.d.ts.map +1 -1
  66. package/dist/type/index.js +5 -0
  67. package/dist/type/index.js.map +1 -1
  68. package/dist/type/querier.d.ts +3 -2
  69. package/dist/type/querier.d.ts.map +1 -1
  70. package/dist/type/querier.js.map +1 -1
  71. package/dist/type/query.d.ts +5 -707
  72. package/dist/type/query.d.ts.map +1 -1
  73. package/dist/type/query.js +1 -51
  74. package/dist/type/query.js.map +1 -1
  75. package/dist/type/queryAggregate.d.ts +191 -0
  76. package/dist/type/queryAggregate.d.ts.map +1 -0
  77. package/dist/type/queryAggregate.js +39 -0
  78. package/dist/type/queryAggregate.js.map +1 -0
  79. package/dist/type/queryRaw.d.ts +35 -0
  80. package/dist/type/queryRaw.d.ts.map +1 -0
  81. package/dist/type/queryRaw.js +11 -0
  82. package/dist/type/queryRaw.js.map +1 -0
  83. package/dist/type/queryWhere.d.ts +271 -0
  84. package/dist/type/queryWhere.d.ts.map +1 -0
  85. package/dist/type/queryWhere.js +2 -0
  86. package/dist/type/queryWhere.js.map +1 -0
  87. package/dist/type/universalQuerier.d.ts +2 -1
  88. package/dist/type/universalQuerier.d.ts.map +1 -1
  89. package/dist/type/utility.d.ts +8 -1
  90. package/dist/type/utility.d.ts.map +1 -1
  91. package/dist/type/vector.d.ts +55 -0
  92. package/dist/type/vector.d.ts.map +1 -0
  93. package/dist/type/vector.js +2 -0
  94. package/dist/type/vector.js.map +1 -0
  95. package/dist/util/ansiSqlLiteral.d.ts +2 -0
  96. package/dist/util/ansiSqlLiteral.d.ts.map +1 -1
  97. package/dist/util/ansiSqlLiteral.js +5 -1
  98. package/dist/util/ansiSqlLiteral.js.map +1 -1
  99. package/dist/util/dialect.util.d.ts +3 -1
  100. package/dist/util/dialect.util.d.ts.map +1 -1
  101. package/dist/util/dialect.util.js +11 -0
  102. package/dist/util/dialect.util.js.map +1 -1
  103. package/dist/util/object.util.d.ts +12 -1
  104. package/dist/util/object.util.d.ts.map +1 -1
  105. package/dist/util/object.util.js +18 -0
  106. package/dist/util/object.util.js.map +1 -1
  107. package/dist/util/relationQuery.util.js +1 -1
  108. package/dist/util/relationQuery.util.js.map +1 -1
  109. package/package.json +2 -2
@@ -1,10 +1,12 @@
1
1
  import { getMeta } from '../entity/index.js';
2
- import { resolveVectorCast } from '../schema/canonicalType.js';
3
2
  import { QueryRaw, RAW_ALIAS, RAW_VALUE, } from '../type/index.js';
4
- import { applyFilters, asSelectMap, buildQueryWhereAsMap, buildSortMap, escapeSqlId, fillOnFields, filterFieldKeys, flatObject, getFieldKeys, getInsertFieldKeys, getKeys, getRelationRequestSummary, getSoftDeleteValue, hasKeys, hasMultipleKeys, isJsonType, isPopulatingRelations, isVectorSearch, normalizeScalarFieldSelection, parseGroupMap, parseRelationAtKey, raw, withoutSoftDeleteFilter, } from '../util/index.js';
5
- import { AbstractDialect } from './abstractDialect.js';
3
+ import { escapeSingleQuotes } from '../util/ansiSqlLiteral.js';
4
+ import { applyFilters, asSelectMap, buildQueryWhereAsMap, buildSortMap, escapeSqlId, fillOnFields, filterFieldKeys, flatObject, getFieldKeys, getInsertFieldKeys, getKeys, getRelationRequestSummary, getSoftDeleteValue, hasKeys, hasMultipleKeys, isJsonType, isJsonUpdateOp, isOperatorObject, isOperatorOnlyObject, isPopulatingRelations, isVectorSearch, normalizeScalarFieldSelection, parseGroupMap, parseRelationAtKey, raw, someValue, withoutSoftDeleteFilter, } from '../util/index.js';
5
+ import { buildElemMatchConditions } from './jsonArrayElemMatchUtils.js';
6
+ import { jsonElemExists } from './jsonSql.js';
6
7
  import { SqlQueryContext } from './queryContext.js';
7
- export class AbstractSqlDialect extends AbstractDialect {
8
+ import { VectorSqlDialect } from './vectorSqlDialect.js';
9
+ export class AbstractSqlDialect extends VectorSqlDialect {
8
10
  isolationLevelStrategy = 'inline';
9
11
  alterColumnStrategy = 'single-statement';
10
12
  alterColumnSyntax = 'ALTER COLUMN';
@@ -17,9 +19,6 @@ export class AbstractSqlDialect extends AbstractDialect {
17
19
  * `insertMany` splits larger batches into multiple statements based on this limit.
18
20
  */
19
21
  maxBindValues = 32766;
20
- /** Vector index operator classes, keyed by distance metric. Partial: not every dialect supports every metric. */
21
- vectorOpsClass = undefined;
22
- vectorExtension = undefined;
23
22
  getBeginTransactionStatements(isolationLevel) {
24
23
  const level = isolationLevel?.toUpperCase();
25
24
  const strategy = this.isolationLevelStrategy;
@@ -389,6 +388,17 @@ export class AbstractSqlDialect extends AbstractDialect {
389
388
  ['$like', (v) => v],
390
389
  ['$ilike', (v) => v.toLowerCase()],
391
390
  ]);
391
+ /**
392
+ * The case-insensitive `LIKE_OP_MAP` keys - the value is lowercased, so the comparison must use
393
+ * `ilikeExpr` (Postgres's `ILIKE`) rather than `LIKE`. `$includes` is deliberately excluded even
394
+ * though it starts with the substring `$i`: it is case-sensitive, unlike `$iincludes`.
395
+ */
396
+ static LIKE_CASE_INSENSITIVE_OPS = new Set([
397
+ '$istartsWith',
398
+ '$iendsWith',
399
+ '$iincludes',
400
+ '$ilike',
401
+ ]);
392
402
  /** Builds `prefix.column` from an already-resolved field. */
393
403
  columnWithPrefix(key, field, prefix) {
394
404
  const columnName = this.resolveColumnName(key, field);
@@ -467,7 +477,7 @@ export class AbstractSqlDialect extends AbstractDialect {
467
477
  }
468
478
  }
469
479
  appendLikeOp(ctx, field, op, wrappedVal) {
470
- const isIlike = op.startsWith('$i') || op === '$ilike';
480
+ const isIlike = AbstractSqlDialect.LIKE_CASE_INSENSITIVE_OPS.has(op);
471
481
  const ph = this.addValue(ctx.values, wrappedVal);
472
482
  if (isIlike && field) {
473
483
  ctx.append(this.ilikeExpr(field, ph));
@@ -501,15 +511,17 @@ export class AbstractSqlDialect extends AbstractDialect {
501
511
  * Used by both `$elemMatch` and dot-notation paths.
502
512
  * All dialect-specific behavior comes from overridable methods on `this`.
503
513
  */
504
- buildJsonFieldCondition(ctx, fieldAccessor, jsonPath, op, value) {
514
+ buildJsonFieldCondition(ctx, fieldAccessor, jsonPath, op, value, asJson = false) {
505
515
  const jsonField = fieldAccessor(jsonPath);
506
516
  switch (op) {
507
517
  case '$eq':
508
- return value === null ? `${jsonField} IS NULL` : `${jsonField} = ${this.addValue(ctx.values, value)}`;
518
+ if (value === null)
519
+ return `${jsonField} IS NULL`;
520
+ return `${this.jsonComparand(jsonField, value)} = ${this.jsonOperand(ctx, value, asJson)}`;
509
521
  case '$ne':
510
522
  if (value === null)
511
523
  return `${jsonField} IS NOT NULL`;
512
- return this.neExpr(jsonField, this.addValue(ctx.values, value));
524
+ return this.neExpr(this.jsonComparand(jsonField, value), this.jsonOperand(ctx, value, asJson));
513
525
  case '$gt':
514
526
  return `${this.numericCast(jsonField)} > ${this.addValue(ctx.values, value)}`;
515
527
  case '$gte':
@@ -538,7 +550,7 @@ export class AbstractSqlDialect extends AbstractDialect {
538
550
  return `${jsonField} ${this.regexpOp} ${this.addValue(ctx.values, value)}`;
539
551
  case '$in':
540
552
  case '$nin':
541
- return this.jsonInNin(ctx, jsonField, op, value);
553
+ return this.jsonInNin(ctx, jsonField, op, value, asJson);
542
554
  case '$all':
543
555
  return this.jsonAll(ctx, jsonField, value);
544
556
  case '$size':
@@ -549,20 +561,117 @@ export class AbstractSqlDialect extends AbstractDialect {
549
561
  throw TypeError(`unknown operator: ${op}`);
550
562
  }
551
563
  }
552
- jsonInNin(ctx, jsonField, op, value) {
553
- return `${jsonField}${this.formatIn(ctx, Array.isArray(value) ? value : [], op === '$nin')}`;
564
+ jsonInNin(ctx, jsonField, op, value, asJson) {
565
+ const values = Array.isArray(value) ? value : [];
566
+ const negate = op === '$nin';
567
+ if (!asJson) {
568
+ return `${this.jsonComparand(jsonField, values)}${this.formatIn(ctx, values, negate)}`;
569
+ }
570
+ // JSON values have no portable array literal, so the set expands into explicit comparisons.
571
+ const comparisons = values.map((val) => `${jsonField} ${negate ? '<>' : '='} ${this.jsonScalarParam(ctx, val)}`);
572
+ return `(${comparisons.join(negate ? ' AND ' : ' OR ')})`;
554
573
  }
555
- jsonAll(ctx, jsonField, value) {
556
- throw TypeError(`$all is not supported in the base SQL dialect - override in dialect subclass`);
574
+ /** The bound operand of a JSON comparison: JSON-encoded when comparing against the JSON value. */
575
+ jsonOperand(ctx, value, asJson) {
576
+ return asJson ? this.jsonScalarParam(ctx, value) : this.addValue(ctx.values, value);
557
577
  }
558
- jsonSize(ctx, jsonField, value) {
559
- throw TypeError(`$size is not supported in the base SQL dialect - override in dialect subclass`);
578
+ /**
579
+ * The left side of a comparison against a JSON scalar, cast when the operand is numeric - see
580
+ * {@link jsonCompareMode} for why each mode exists.
581
+ */
582
+ jsonComparand(jsonField, value) {
583
+ return this.jsonCompareMode(value) === 'numeric' ? this.numericCast(jsonField) : jsonField;
560
584
  }
561
- jsonElemMatch(ctx, jsonField, value) {
562
- throw TypeError(`$elemMatch is not supported in the base SQL dialect - override in dialect subclass`);
585
+ /**
586
+ * How a JSON scalar has to be compared against `value` (or, for `$in`/`$nin`, against every element
587
+ * of it). Extracting a JSON value yields *text*, which loses the type, so each operand type is
588
+ * compared in the representation every engine agrees on:
589
+ * - `numeric` - cast the accessor. Keeps `1` equal to a stored `1.0`, which strict JSON equality
590
+ * would not, and satisfies drivers that send typed parameters (`text = integer` otherwise).
591
+ * - `json` - compare the JSON value against a JSON-encoded parameter. No cast recovers a boolean
592
+ * portably: PostgreSQL raises `text = boolean` and MySQL matches `'true'` against `1`.
593
+ * - `text` - compare as extracted, which is also what the string operators need.
594
+ *
595
+ * Mixed operand types fall back to `text`, since one comparison cannot be two shapes at once.
596
+ */
597
+ jsonCompareMode(value) {
598
+ const operands = Array.isArray(value) ? value : [value];
599
+ if (operands.length === 0) {
600
+ return 'text';
601
+ }
602
+ if (operands.every((operand) => typeof operand === 'boolean')) {
603
+ return 'json';
604
+ }
605
+ return operands.every((operand) => typeof operand === 'number') ? 'numeric' : 'text';
563
606
  }
564
- isJsonbOp(op) {
565
- return op === '$all' || op === '$size' || op === '$elemMatch';
607
+ /**
608
+ * Whether the dialect's array containment ({@link jsonAll}) matches an object element that merely
609
+ * *includes* the given keys, as PostgreSQL's `@>` and MySQL's `JSON_CONTAINS` do. SQLite compares
610
+ * elements as whole JSON text, so it cannot express a partial match and always expands the
611
+ * per-field form below.
612
+ */
613
+ jsonContainmentIsPartial = true;
614
+ /**
615
+ * Whether an exploded *scalar* element keeps its SQL type. SQLite's `json_each` yields JSON
616
+ * booleans as `0`/`1` integers and numbers as numbers, so such an element compares directly to a
617
+ * bound value; PostgreSQL and MySQL explode scalars to text, losing the type, so a non-string
618
+ * operand there has to compare as JSON (see {@link isJsonbOp}).
619
+ */
620
+ jsonScalarElemKeepsType = false;
621
+ /**
622
+ * `$elemMatch`: at least one element of the JSON array satisfies `match`. Three shapes, decided
623
+ * here so every dialect only supplies {@link jsonElemFrom} / {@link jsonElemRef}:
624
+ * - keys are operators (`{ $startsWith: 'ad' }`) - scalar elements, conditions on the element;
625
+ * - a plain object with no nested operators - containment, which is the only form an index serves;
626
+ * - otherwise - per-field conditions over the exploded objects.
627
+ */
628
+ jsonElemMatch(ctx, jsonField, match) {
629
+ // Conditions on the element itself. One `FROM` serves them all, so the element is read as JSON
630
+ // only when *every* operand needs it - the same all-operands rule the comparison classifier uses.
631
+ if (isOperatorOnlyObject(match)) {
632
+ const entries = Object.entries(match);
633
+ const asJson = !this.jsonScalarElemKeepsType && entries.every(([op, val]) => this.isJsonbOp(op, val));
634
+ const conditions = entries.map(([op, val]) => this.buildJsonFieldCondition(ctx, () => this.jsonElemRef(undefined, asJson), '', op, val, asJson));
635
+ return jsonElemExists(this.jsonElemFrom(jsonField, [], asJson), conditions);
636
+ }
637
+ if (isOperatorObject(match)) {
638
+ throw TypeError(`$elemMatch cannot mix operators with field names: ${Object.keys(match).join(', ')}`);
639
+ }
640
+ // A plain object with no nested operators is containment, which is also the only form an index
641
+ // can serve. SQLite compares elements exactly, so it always expands the per-field form below.
642
+ if (this.jsonContainmentIsPartial && !someValue(match, isOperatorObject)) {
643
+ return this.jsonAll(ctx, jsonField, [match]);
644
+ }
645
+ const conditions = buildElemMatchConditions(match, (field, op, opVal) => {
646
+ const asJson = this.isJsonbOp(op, opVal);
647
+ return this.buildJsonFieldCondition(ctx, (f) => this.jsonElemRef(f, asJson), field, op, opVal, asJson);
648
+ });
649
+ return jsonElemExists(this.jsonElemFrom(jsonField, Object.keys(match)), conditions);
650
+ }
651
+ /**
652
+ * Whether the operator reads the JSON *value* instead of its text form. The array operators always
653
+ * do. Equality joins them for boolean operands, because extracting JSON as text loses the type in
654
+ * a way no cast recovers portably: PostgreSQL raises `operator does not exist: text = boolean`,
655
+ * MySQL compares `'true'` to `1` and silently matches nothing, and SQLite's `json_extract` yields
656
+ * `1`. Comparing the JSON value against a JSON-encoded parameter is exact on every dialect.
657
+ *
658
+ * Numbers stay on the text accessor with a numeric cast ({@link jsonComparand}), which keeps
659
+ * `1` equal to `1.0` - JSON equality would not.
660
+ */
661
+ isJsonbOp(op, value) {
662
+ if (op === '$all' || op === '$size' || op === '$elemMatch') {
663
+ return true;
664
+ }
665
+ const comparesValue = op === '$eq' || op === '$ne' || op === '$in' || op === '$nin';
666
+ return comparesValue && this.jsonCompareMode(value) === 'json';
667
+ }
668
+ /** A JSON-encoded bound parameter, cast to the dialect's JSON type. */
669
+ jsonScalarParam(ctx, value) {
670
+ if (value instanceof QueryRaw) {
671
+ return this.addValue(ctx.values, value);
672
+ }
673
+ ctx.pushValue(JSON.stringify(value));
674
+ return this.getJsonCastExpr();
566
675
  }
567
676
  getComparisonKey(ctx, entity, key, { prefix } = {}) {
568
677
  const meta = getMeta(entity);
@@ -629,59 +738,6 @@ export class AbstractSqlDialect extends AbstractDialect {
629
738
  ctx.append(this.escapeId(name) + direction);
630
739
  });
631
740
  }
632
- /**
633
- * Resolve common parameters for a vector similarity ORDER BY expression.
634
- * Shared by all dialect overrides of `appendVectorSort`.
635
- */
636
- resolveVectorSortParams(meta, key, search) {
637
- const field = meta.fields[key];
638
- const colName = this.resolveColumnName(key, field);
639
- const distance = search.$distance ?? field?.distance ?? 'cosine';
640
- const vectorCast = resolveVectorCast(field);
641
- return { colName, distance, field, vectorCast };
642
- }
643
- /**
644
- * Mapping of UQL vector distance metrics to native SQL functions.
645
- * Override in dialects that use function-call syntax (e.g. SQLite, MariaDB).
646
- * Dialects with operator-based syntax (e.g. Postgres) leave this empty and override `appendVectorSort` directly.
647
- */
648
- vectorDistanceFns = new Map();
649
- /**
650
- * Append a vector similarity function call: `fn(col, ?)`.
651
- * Used by dialects that express vector distance via SQL functions (SQLite, MariaDB).
652
- */
653
- appendFunctionVectorSort(ctx, meta, key, search, dialectName) {
654
- const { colName, distance, vectorCast } = this.resolveVectorSortParams(meta, key, search);
655
- const fn = this.vectorDistanceFns.get(distance);
656
- if (!fn) {
657
- throw Error(`${dialectName} does not support vector distance metric: ${distance}`);
658
- }
659
- ctx.append(`${fn}(${this.escapeId(colName)}, `);
660
- ctx.addValue(`[${search.$vector.join(',')}]`);
661
- if (vectorCast && dialectName === 'PostgreSQL') {
662
- ctx.append(`::${vectorCast}`);
663
- }
664
- ctx.append(')');
665
- }
666
- /**
667
- * Append a vector distance projection.
668
- */
669
- appendVectorProjection(ctx, meta, key, search) {
670
- this.appendVectorSort(ctx, meta, key, search);
671
- ctx.append(` AS ${this.escapeId(search.$project)}`);
672
- }
673
- /**
674
- * Append a vector similarity ORDER BY expression.
675
- * Default: auto-delegates to `appendFunctionVectorSort` when `vectorDistanceFns` has entries.
676
- * Override for operator-based syntax (e.g. PostgreSQL `<=>`, `<->` operators).
677
- */
678
- appendVectorSort(ctx, meta, key, search) {
679
- if (this.vectorDistanceFns.size > 0) {
680
- this.appendFunctionVectorSort(ctx, meta, key, search, this.dialectName);
681
- return;
682
- }
683
- throw new TypeError('Vector similarity sort is not supported by this dialect. Use raw() for vector queries.');
684
- }
685
741
  pager(ctx, opts) {
686
742
  if (opts.$limit) {
687
743
  ctx.append(` LIMIT ${Number(opts.$limit)}`);
@@ -891,8 +947,8 @@ export class AbstractSqlDialect extends AbstractDialect {
891
947
  const columnName = this.resolveColumnName(key, field);
892
948
  const escapedCol = this.escapeId(columnName);
893
949
  const value = filledPayload[key];
894
- if (this.isJsonUpdateOp(value)) {
895
- this.formatJsonUpdate(ctx, escapedCol, value);
950
+ if (isJsonUpdateOp(value)) {
951
+ this.formatJsonUpdate(ctx, escapedCol, value, field);
896
952
  }
897
953
  else {
898
954
  ctx.append(`${escapedCol} = `);
@@ -1016,50 +1072,59 @@ export class AbstractSqlDialect extends AbstractDialect {
1016
1072
  ctx.addValue(value);
1017
1073
  }
1018
1074
  /**
1019
- * Generate SQL for a JSONB merge and/or unset operation.
1020
- * Called from `update()` when a field value has `$merge`, `$unset`, and/or `$push` operators.
1021
- * Generates the full `"col" = <expression>` assignment.
1022
- *
1023
- * Base implementation uses MySQL-compatible syntax with *shallow* merge semantics
1024
- * (RHS top-level keys replace LHS top-level keys, matching PostgreSQL's `jsonb || jsonb`).
1025
- * Override in dialect subclasses when a dialect needs different JSON function semantics.
1075
+ * Reads `operand` as a JSON value. Passing the `?` placeholder yields the cast for a bound
1076
+ * parameter, and passing an expression re-reads text as JSON - the same SQL either way, which is
1077
+ * why this is one hook rather than a placeholder variant plus an expression variant.
1078
+ */
1079
+ jsonCast(operand) {
1080
+ return `CAST(${operand} AS JSON)`;
1081
+ }
1082
+ /**
1083
+ * The JSON cast applied to a bound placeholder. Only the positional-placeholder dialects use it -
1084
+ * PostgreSQL binds JSON through {@link PgLikeSqlDialect.jsonScalarParam} instead.
1026
1085
  */
1027
1086
  getJsonCastExpr() {
1028
- return 'CAST(? AS JSON)';
1087
+ return this.jsonCast('?');
1029
1088
  }
1030
- formatJsonUpdate(ctx, escapedCol, value) {
1089
+ /**
1090
+ * Generate the full `"col" = <expression>` assignment for a JSON update operator payload.
1091
+ * Called from `update()` when a field value is a {@link JsonUpdateOp}.
1092
+ *
1093
+ * Each operator wraps the expression built so far, innermost-first in the order stated on
1094
+ * {@link JsonUpdateOp} (`$pull` -> `$set` -> `$push` -> `$unset`), so dialects only supply the
1095
+ * four SQL fragments below. Two invariants keep every dialect consistent and keep bound values in
1096
+ * step with their placeholders:
1097
+ * - `$pull` is innermost and its subquery reads `escapedCol`, so its value binds exactly once.
1098
+ * - Later fragments reference `expr` at most once, so a `$pull` subquery is never duplicated
1099
+ * (which would bind its value twice on positional-placeholder dialects). PostgreSQL's `$push`
1100
+ * is the one exception, and is safe there because its placeholders are numbered.
1101
+ */
1102
+ formatJsonUpdate(ctx, escapedCol, value, field) {
1103
+ // Centralizes the one narrowing cast: the payload's keys are typed against the entity's JSON
1104
+ // payload, which the dialects do not need - they only build SQL from keys and values.
1105
+ const { $pull, $set, $push, $unset } = value;
1031
1106
  let expr = escapedCol;
1032
- if (hasKeys(value.$merge)) {
1033
- const merge = value.$merge;
1034
- expr = `JSON_SET(COALESCE(${escapedCol}, '{}')`;
1035
- for (const [key, v] of Object.entries(merge)) {
1036
- expr += `, '$.${this.escapeJsonKey(key)}', ${this.getJsonCastExpr()}`;
1037
- ctx.pushValue(JSON.stringify(v));
1038
- }
1039
- expr += ')';
1040
- }
1041
- if (hasKeys(value.$push)) {
1042
- const push = value.$push;
1043
- expr = `JSON_ARRAY_APPEND(${expr}`;
1044
- for (const [key, v] of Object.entries(push)) {
1045
- expr += `, '$.${this.escapeJsonKey(key)}', ${this.getJsonCastExpr()}`;
1046
- ctx.pushValue(JSON.stringify(v));
1047
- }
1048
- expr += ')';
1107
+ if (hasKeys($pull)) {
1108
+ expr = this.jsonPull(ctx, expr, escapedCol, $pull);
1049
1109
  }
1050
- if (value.$unset?.length) {
1051
- for (const key of value.$unset) {
1052
- expr = `JSON_REMOVE(${expr}, '$.${this.escapeJsonKey(key)}')`;
1053
- }
1110
+ if (hasKeys($set)) {
1111
+ expr = this.jsonSet(ctx, expr, $set, field);
1112
+ }
1113
+ if (hasKeys($push)) {
1114
+ expr = this.jsonPush(ctx, expr, $push);
1115
+ }
1116
+ if ($unset?.length) {
1117
+ expr = this.jsonUnset(ctx, expr, $unset);
1054
1118
  }
1055
1119
  ctx.append(`${escapedCol} = ${expr}`);
1056
1120
  }
1057
- isJsonUpdateOp(value) {
1058
- return typeof value === 'object' && value !== null && ('$merge' in value || '$unset' in value || '$push' in value);
1059
- }
1060
- /** Escapes a JSON key for safe interpolation into SQL string literals. */
1061
- escapeJsonKey(key) {
1062
- return key.replace(/'/g, "''");
1121
+ /**
1122
+ * Remove every element equal to the given value, per array key. Each key wraps the expression
1123
+ * built so far, so dialects only supply {@link jsonPullKey} - and because every key reads
1124
+ * `escapedCol` rather than the accumulated expression, values bind once, in key order.
1125
+ */
1126
+ jsonPull(ctx, expr, escapedCol, pull) {
1127
+ return Object.entries(pull).reduce((acc, [key, value]) => this.jsonPullKey(ctx, acc, escapedCol, key, value), expr);
1063
1128
  }
1064
1129
  getRawValue(ctx, opts) {
1065
1130
  const { value, prefix = '', escapedPrefix, autoPrefixAlias } = opts;
@@ -1124,7 +1189,8 @@ export class AbstractSqlDialect extends AbstractDialect {
1124
1189
  operators.forEach((op, index) => {
1125
1190
  if (index > 0)
1126
1191
  ctx.append(' AND ');
1127
- const sql = this.buildJsonFieldCondition(ctx, (f) => accessor(this.isJsonbOp(op)), jsonPath, op, value[op]);
1192
+ const asJson = this.isJsonbOp(op, value[op]);
1193
+ const sql = this.buildJsonFieldCondition(ctx, () => accessor(asJson), jsonPath, op, value[op], asJson);
1128
1194
  if (sql) {
1129
1195
  ctx.append(sql);
1130
1196
  }
@@ -1143,7 +1209,7 @@ export class AbstractSqlDialect extends AbstractDialect {
1143
1209
  let expr = escapedColumn;
1144
1210
  for (let i = 0; i < segments.length; i++) {
1145
1211
  const op = i === segments.length - 1 ? '->>' : '->';
1146
- expr = `(${expr}${op}'${this.escapeJsonKey(segments[i])}')`;
1212
+ expr = `(${expr}${op}'${escapeSingleQuotes(segments[i])}')`;
1147
1213
  }
1148
1214
  return expr;
1149
1215
  }
@@ -1151,7 +1217,7 @@ export class AbstractSqlDialect extends AbstractDialect {
1151
1217
  const segments = jsonPath.split('.');
1152
1218
  let expr = escapedColumn;
1153
1219
  for (const segment of segments) {
1154
- expr = `(${expr}->'${this.escapeJsonKey(segment)}')`;
1220
+ expr = `(${expr}->'${escapeSingleQuotes(segment)}')`;
1155
1221
  }
1156
1222
  return expr;
1157
1223
  }