@mikro-orm/sql 7.0.0-dev.335 → 7.0.0-dev.337

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.
@@ -4,6 +4,7 @@ import { type SchemaHelper } from './schema/SchemaHelper.js';
4
4
  import type { IndexDef } from './typings.js';
5
5
  import { NativeQueryBuilder } from './query/NativeQueryBuilder.js';
6
6
  export declare abstract class AbstractSqlPlatform extends Platform {
7
+ #private;
7
8
  protected readonly schemaHelper?: SchemaHelper;
8
9
  usesPivotTable(): boolean;
9
10
  indexForeignKeys(): boolean;
@@ -26,6 +27,12 @@ export declare abstract class AbstractSqlPlatform extends Platform {
26
27
  quoteValue(value: any): string;
27
28
  getSearchJsonPropertySQL(path: string, type: string, aliased: boolean): string | RawQueryFragment;
28
29
  getSearchJsonPropertyKey(path: string[], type: string, aliased: boolean, value?: unknown): string | RawQueryFragment;
30
+ /**
31
+ * Quotes a key for use inside a JSON path expression (e.g. `$.key`).
32
+ * Simple alphanumeric keys are left unquoted; others are wrapped in double quotes.
33
+ * @internal
34
+ */
35
+ quoteJsonKey(key: string): string;
29
36
  getJsonIndexDefinition(index: IndexDef): string[];
30
37
  supportsUnionWhere(): boolean;
31
38
  supportsSchemas(): boolean;
@@ -42,6 +49,27 @@ export declare abstract class AbstractSqlPlatform extends Platform {
42
49
  quoteCollation(collation: string): string;
43
50
  /** @internal */
44
51
  protected validateCollationName(collation: string): void;
52
+ /** @internal */
53
+ validateJsonPropertyName(name: string): void;
54
+ /**
55
+ * Returns FROM clause for JSON array iteration.
56
+ * @internal
57
+ */
58
+ getJsonArrayFromSQL(column: string, alias: string, _properties: {
59
+ name: string;
60
+ type: string;
61
+ }[]): string;
62
+ /**
63
+ * Returns SQL expression to access an element's property within a JSON array iteration.
64
+ * @internal
65
+ */
66
+ getJsonArrayElementPropertySQL(alias: string, property: string, _type: string): string;
67
+ /**
68
+ * Wraps JSON array FROM clause and WHERE condition into a full EXISTS condition.
69
+ * MySQL overrides this because `json_table` doesn't support correlated subqueries.
70
+ * @internal
71
+ */
72
+ getJsonArrayExistsSQL(from: string, where: string): string;
45
73
  /**
46
74
  * Maps a runtime type name (e.g. 'string', 'number') to a driver-specific bind type constant.
47
75
  * Used by NativeQueryBuilder for output bindings.
@@ -3,6 +3,7 @@ import { SqlEntityRepository } from './SqlEntityRepository.js';
3
3
  import { SqlSchemaGenerator } from './schema/SqlSchemaGenerator.js';
4
4
  import { NativeQueryBuilder } from './query/NativeQueryBuilder.js';
5
5
  export class AbstractSqlPlatform extends Platform {
6
+ static #JSON_PROPERTY_NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
6
7
  schemaHelper;
7
8
  usesPivotTable() {
8
9
  return true;
@@ -64,11 +65,18 @@ export class AbstractSqlPlatform extends Platform {
64
65
  }
65
66
  getSearchJsonPropertyKey(path, type, aliased, value) {
66
67
  const [a, ...b] = path;
67
- const quoteKey = (key) => (/^[a-z]\w*$/i.exec(key) ? key : `"${key}"`);
68
68
  if (aliased) {
69
- return raw(alias => `json_extract(${this.quoteIdentifier(`${alias}.${a}`)}, '$.${b.map(quoteKey).join('.')}')`);
69
+ return raw(alias => `json_extract(${this.quoteIdentifier(`${alias}.${a}`)}, '$.${b.map(this.quoteJsonKey).join('.')}')`);
70
70
  }
71
- return raw(`json_extract(${this.quoteIdentifier(a)}, '$.${b.map(quoteKey).join('.')}')`);
71
+ return raw(`json_extract(${this.quoteIdentifier(a)}, '$.${b.map(this.quoteJsonKey).join('.')}')`);
72
+ }
73
+ /**
74
+ * Quotes a key for use inside a JSON path expression (e.g. `$.key`).
75
+ * Simple alphanumeric keys are left unquoted; others are wrapped in double quotes.
76
+ * @internal
77
+ */
78
+ quoteJsonKey(key) {
79
+ return /^[a-z]\w*$/i.exec(key) ? key : `"${key}"`;
72
80
  }
73
81
  getJsonIndexDefinition(index) {
74
82
  return index.columnNames.map(column => {
@@ -116,6 +124,34 @@ export class AbstractSqlPlatform extends Platform {
116
124
  throw new Error(`Invalid collation name: '${collation}'. Collation names must contain only word characters.`);
117
125
  }
118
126
  }
127
+ /** @internal */
128
+ validateJsonPropertyName(name) {
129
+ if (!AbstractSqlPlatform.#JSON_PROPERTY_NAME_RE.test(name)) {
130
+ throw new Error(`Invalid JSON property name: '${name}'. JSON property names must contain only alphanumeric characters and underscores.`);
131
+ }
132
+ }
133
+ /**
134
+ * Returns FROM clause for JSON array iteration.
135
+ * @internal
136
+ */
137
+ getJsonArrayFromSQL(column, alias, _properties) {
138
+ return `json_each(${column}) as ${this.quoteIdentifier(alias)}`;
139
+ }
140
+ /**
141
+ * Returns SQL expression to access an element's property within a JSON array iteration.
142
+ * @internal
143
+ */
144
+ getJsonArrayElementPropertySQL(alias, property, _type) {
145
+ return `${this.quoteIdentifier(alias)}.${this.quoteIdentifier(property)}`;
146
+ }
147
+ /**
148
+ * Wraps JSON array FROM clause and WHERE condition into a full EXISTS condition.
149
+ * MySQL overrides this because `json_table` doesn't support correlated subqueries.
150
+ * @internal
151
+ */
152
+ getJsonArrayExistsSQL(from, where) {
153
+ return `exists (select 1 from ${from} where ${where})`;
154
+ }
119
155
  /**
120
156
  * Maps a runtime type name (e.g. 'string', 'number') to a driver-specific bind type constant.
121
157
  * Used by NativeQueryBuilder for output bindings.
@@ -5,6 +5,7 @@ import { AbstractSqlPlatform } from '../../AbstractSqlPlatform.js';
5
5
  import type { IndexDef } from '../../typings.js';
6
6
  import { MySqlNativeQueryBuilder } from './MySqlNativeQueryBuilder.js';
7
7
  export declare class BaseMySqlPlatform extends AbstractSqlPlatform {
8
+ #private;
8
9
  protected readonly schemaHelper: MySqlSchemaHelper;
9
10
  protected readonly exceptionConverter: MySqlExceptionConverter;
10
11
  protected readonly ORDER_BY_NULLS_TRANSLATE: {
@@ -42,5 +43,10 @@ export declare class BaseMySqlPlatform extends AbstractSqlPlatform {
42
43
  getFullTextWhereClause(): string;
43
44
  getFullTextIndexExpression(indexName: string, schemaName: string | undefined, tableName: string, columns: SimpleColumnMeta[]): string;
44
45
  getOrderByExpression(column: string, direction: string, collation?: string): string[];
46
+ getJsonArrayFromSQL(column: string, alias: string, properties: {
47
+ name: string;
48
+ type: string;
49
+ }[]): string;
50
+ getJsonArrayExistsSQL(from: string, where: string): string;
45
51
  getDefaultClientUrl(): string;
46
52
  }
@@ -6,6 +6,12 @@ import { MySqlNativeQueryBuilder } from './MySqlNativeQueryBuilder.js';
6
6
  export class BaseMySqlPlatform extends AbstractSqlPlatform {
7
7
  schemaHelper = new MySqlSchemaHelper(this);
8
8
  exceptionConverter = new MySqlExceptionConverter();
9
+ #jsonTypeCasts = {
10
+ string: 'text',
11
+ number: 'double',
12
+ bigint: 'bigint',
13
+ boolean: 'unsigned',
14
+ };
9
15
  ORDER_BY_NULLS_TRANSLATE = {
10
16
  [QueryOrder.asc_nulls_first]: 'is not null',
11
17
  [QueryOrder.asc_nulls_last]: 'is null',
@@ -114,6 +120,17 @@ export class BaseMySqlPlatform extends AbstractSqlPlatform {
114
120
  ret.push(`${col} ${dir.replace(/(\s|nulls|first|last)*/gi, '')}`);
115
121
  return ret;
116
122
  }
123
+ getJsonArrayFromSQL(column, alias, properties) {
124
+ const columns = properties
125
+ .map(p => `${this.quoteIdentifier(p.name)} ${this.#jsonTypeCasts[p.type] ?? 'text'} path '$.${this.quoteJsonKey(p.name)}'`)
126
+ .join(', ');
127
+ return `json_table(${column}, '$[*]' columns (${columns})) as ${this.quoteIdentifier(alias)}`;
128
+ }
129
+ // MySQL does not support correlated json_table inside EXISTS subqueries,
130
+ // so we use a semi-join via the comma-join pattern instead.
131
+ getJsonArrayExistsSQL(from, where) {
132
+ return `(select 1 from ${from} where ${where} limit 1) is not null`;
133
+ }
117
134
  getDefaultClientUrl() {
118
135
  return 'mysql://root@127.0.0.1:3306';
119
136
  }
@@ -5,6 +5,7 @@ import { PostgreSqlNativeQueryBuilder } from './PostgreSqlNativeQueryBuilder.js'
5
5
  import { PostgreSqlSchemaHelper } from './PostgreSqlSchemaHelper.js';
6
6
  import { PostgreSqlExceptionConverter } from './PostgreSqlExceptionConverter.js';
7
7
  export declare class BasePostgreSqlPlatform extends AbstractSqlPlatform {
8
+ #private;
8
9
  protected readonly schemaHelper: PostgreSqlSchemaHelper;
9
10
  protected readonly exceptionConverter: PostgreSqlExceptionConverter;
10
11
  createNativeQueryBuilder(): PostgreSqlNativeQueryBuilder;
@@ -102,5 +103,10 @@ export declare class BasePostgreSqlPlatform extends AbstractSqlPlatform {
102
103
  castColumn(prop?: {
103
104
  columnTypes?: string[];
104
105
  }): string;
106
+ getJsonArrayFromSQL(column: string, alias: string, _properties: {
107
+ name: string;
108
+ type: string;
109
+ }[]): string;
110
+ getJsonArrayElementPropertySQL(alias: string, property: string, type: string): string;
105
111
  getDefaultClientUrl(): string;
106
112
  }
@@ -7,6 +7,8 @@ import { FullTextType } from './FullTextType.js';
7
7
  export class BasePostgreSqlPlatform extends AbstractSqlPlatform {
8
8
  schemaHelper = new PostgreSqlSchemaHelper(this);
9
9
  exceptionConverter = new PostgreSqlExceptionConverter();
10
+ /** Maps JS runtime type names to PostgreSQL cast types for JSON property access. @internal */
11
+ #jsonTypeCasts = { number: 'float8', bigint: 'int8', boolean: 'bool' };
10
12
  createNativeQueryBuilder() {
11
13
  return new PostgreSqlNativeQueryBuilder(this);
12
14
  }
@@ -215,12 +217,7 @@ export class BasePostgreSqlPlatform extends AbstractSqlPlatform {
215
217
  const last = path.pop();
216
218
  const root = this.quoteIdentifier(aliased ? `${ALIAS_REPLACEMENT}.${first}` : first);
217
219
  type = typeof type === 'string' ? this.getMappedType(type).runtimeType : String(type);
218
- const types = {
219
- number: 'float8',
220
- bigint: 'int8',
221
- boolean: 'bool',
222
- };
223
- const cast = (key) => raw(type in types ? `(${key})::${types[type]}` : key);
220
+ const cast = (key) => raw(type in this.#jsonTypeCasts ? `(${key})::${this.#jsonTypeCasts[type]}` : key);
224
221
  let lastOperator = '->>';
225
222
  // force `->` for operator payloads with array values
226
223
  if (Utils.isPlainObject(value) &&
@@ -352,6 +349,13 @@ export class BasePostgreSqlPlatform extends AbstractSqlPlatform {
352
349
  return '';
353
350
  }
354
351
  }
352
+ getJsonArrayFromSQL(column, alias, _properties) {
353
+ return `jsonb_array_elements(${column}) as ${this.quoteIdentifier(alias)}`;
354
+ }
355
+ getJsonArrayElementPropertySQL(alias, property, type) {
356
+ const expr = `${this.quoteIdentifier(alias)}->>${this.quoteValue(property)}`;
357
+ return type in this.#jsonTypeCasts ? `(${expr})::${this.#jsonTypeCasts[type]}` : expr;
358
+ }
355
359
  getDefaultClientUrl() {
356
360
  return 'postgresql://postgres@127.0.0.1:5432';
357
361
  }
@@ -75,5 +75,6 @@ export declare class SqlitePlatform extends AbstractSqlPlatform {
75
75
  convertVersionValue(value: Date | number, prop: EntityProperty): number | {
76
76
  $in: (string | number)[];
77
77
  };
78
+ getJsonArrayElementPropertySQL(alias: string, property: string, _type: string): string;
78
79
  quoteValue(value: any): string;
79
80
  }
@@ -133,6 +133,9 @@ export class SqlitePlatform extends AbstractSqlPlatform {
133
133
  }
134
134
  return value;
135
135
  }
136
+ getJsonArrayElementPropertySQL(alias, property, _type) {
137
+ return `json_extract(${this.quoteIdentifier(alias)}.value, '$.${this.quoteJsonKey(property)}')`;
138
+ }
136
139
  quoteValue(value) {
137
140
  if (value instanceof Date) {
138
141
  return '' + +value;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikro-orm/sql",
3
- "version": "7.0.0-dev.335",
3
+ "version": "7.0.0-dev.337",
4
4
  "description": "TypeScript ORM for Node.js based on Data Mapper, Unit of Work and Identity Map patterns. Supports MongoDB, MySQL, PostgreSQL and SQLite databases as well as usage with vanilla JavaScript.",
5
5
  "keywords": [
6
6
  "data-mapper",
@@ -53,7 +53,7 @@
53
53
  "@mikro-orm/core": "^6.6.9"
54
54
  },
55
55
  "peerDependencies": {
56
- "@mikro-orm/core": "7.0.0-dev.335"
56
+ "@mikro-orm/core": "7.0.0-dev.337"
57
57
  },
58
58
  "engines": {
59
59
  "node": ">= 22.17.0"
@@ -2,6 +2,7 @@ import { GroupOperator, isRaw, JsonType, RawQueryFragment, ReferenceKind, Utils,
2
2
  import { ObjectCriteriaNode } from './ObjectCriteriaNode.js';
3
3
  import { ArrayCriteriaNode } from './ArrayCriteriaNode.js';
4
4
  import { ScalarCriteriaNode } from './ScalarCriteriaNode.js';
5
+ import { EMBEDDABLE_ARRAY_OPS } from './enums.js';
5
6
  /**
6
7
  * @internal
7
8
  */
@@ -69,15 +70,26 @@ export class CriteriaNodeFactory {
69
70
  }, {});
70
71
  return this.createNode(metadata, entityName, map, node, key, validate);
71
72
  }
73
+ // For array embeddeds stored as real columns, route property-level queries
74
+ // as scalar nodes so QueryBuilderHelper generates EXISTS subqueries with
75
+ // JSON array iteration. Keys containing `~` indicate the property lives
76
+ // inside a parent's object-mode JSON column (MetadataDiscovery uses `~` as
77
+ // the glue for object embeds), where JSON path access is used instead.
78
+ if (prop.array && !String(key).includes('~')) {
79
+ const keys = Object.keys(val);
80
+ const hasOnlyArrayOps = keys.every(k => EMBEDDABLE_ARRAY_OPS.includes(k));
81
+ if (!hasOnlyArrayOps) {
82
+ return this.createScalarNode(metadata, entityName, val, node, key, validate);
83
+ }
84
+ }
72
85
  // array operators can be used on embedded properties
73
- const allowedOperators = ['$contains', '$contained', '$overlap'];
74
- const operator = Object.keys(val).some(f => Utils.isOperator(f) && !allowedOperators.includes(f));
86
+ const operator = Object.keys(val).some(f => Utils.isOperator(f) && !EMBEDDABLE_ARRAY_OPS.includes(f));
75
87
  if (operator) {
76
88
  throw ValidationError.cannotUseOperatorsInsideEmbeddables(entityName, prop.name, payload);
77
89
  }
78
90
  const map = Object.keys(val).reduce((oo, k) => {
79
91
  const embeddedProp = prop.embeddedProps[k] ?? Object.values(prop.embeddedProps).find(p => p.name === k);
80
- if (!embeddedProp && !allowedOperators.includes(k)) {
92
+ if (!embeddedProp && !EMBEDDABLE_ARRAY_OPS.includes(k)) {
81
93
  throw ValidationError.invalidEmbeddableQuery(entityName, k, prop.type);
82
94
  }
83
95
  if (embeddedProp) {
@@ -56,6 +56,17 @@ export declare class QueryBuilderHelper {
56
56
  private fieldName;
57
57
  getProperty(field: string, alias?: string): EntityProperty | undefined;
58
58
  isTableNameAliasRequired(type: QueryType): boolean;
59
+ private processEmbeddedArrayCondition;
60
+ private buildJsonArrayExists;
61
+ private resolveEmbeddedProp;
62
+ private buildEmbeddedArrayOperatorCondition;
63
+ private processJsonElemMatch;
64
+ /**
65
+ * Shared logic for building WHERE conditions inside JSON array EXISTS subqueries.
66
+ * Used by both embedded array queries (metadata-driven) and $elemMatch (type-inferred).
67
+ */
68
+ private buildArrayElementWhere;
69
+ private inferJsonValueType;
59
70
  processOnConflictCondition(cond: FilterQuery<any>, schema?: string): FilterQuery<any>;
60
71
  createFormulaTable(alias: string, meta: EntityMetadata, schema?: string): FormulaTable;
61
72
  }
@@ -1,5 +1,5 @@
1
- import { ALIAS_REPLACEMENT, ALIAS_REPLACEMENT_RE, ArrayType, inspect, isRaw, LockMode, OptimisticLockError, QueryOperator, QueryOrderNumeric, raw, Raw, QueryHelper, ReferenceKind, Utils, ValidationError, } from '@mikro-orm/core';
2
- import { JoinType, QueryType } from './enums.js';
1
+ import { ALIAS_REPLACEMENT, ALIAS_REPLACEMENT_RE, ArrayType, JsonType, inspect, isRaw, LockMode, OptimisticLockError, QueryOperator, QueryOrderNumeric, raw, Raw, QueryHelper, ReferenceKind, Utils, ValidationError, } from '@mikro-orm/core';
2
+ import { EMBEDDABLE_ARRAY_OPS, JoinType, QueryType } from './enums.js';
3
3
  /**
4
4
  * @internal
5
5
  */
@@ -12,6 +12,8 @@ export class QueryBuilderHelper {
12
12
  #subQueries;
13
13
  #driver;
14
14
  #tptAliasMap;
15
+ /** Monotonically increasing counter for unique JSON array iteration aliases within a single query. */
16
+ #jsonAliasCounter = 0;
15
17
  constructor(entityName, alias, aliasMap, subQueries, driver, tptAliasMap = {}) {
16
18
  this.#entityName = entityName;
17
19
  this.#alias = alias;
@@ -436,6 +438,26 @@ export class QueryBuilderHelper {
436
438
  params.push(this.getRegExpParam(cond[key]));
437
439
  return { sql: parts.join(' and '), params };
438
440
  }
441
+ if (Utils.isPlainObject(cond[key]) && !Raw.isKnownFragmentSymbol(key)) {
442
+ const [a, f] = this.splitField(key);
443
+ const prop = this.getProperty(f, a);
444
+ if (prop?.kind === ReferenceKind.EMBEDDED && prop.array) {
445
+ const keys = Object.keys(cond[key]);
446
+ const hasOnlyArrayOps = keys.every((k) => EMBEDDABLE_ARRAY_OPS.includes(k));
447
+ if (!hasOnlyArrayOps) {
448
+ return this.processEmbeddedArrayCondition(cond[key], prop, a);
449
+ }
450
+ }
451
+ // $elemMatch on JSON properties — iterate array elements via EXISTS subquery.
452
+ // When combined with other operators (e.g. $contains), processObjectSubCondition
453
+ // splits them first (size > 1), so $elemMatch arrives here alone.
454
+ if (prop && cond[key].$elemMatch != null && Utils.getObjectKeysSize(cond[key]) === 1) {
455
+ if (!(prop.customType instanceof JsonType)) {
456
+ throw new ValidationError(`$elemMatch can only be used on JSON array properties, but '${this.#entityName}.${prop.name}' has type '${prop.type}'`);
457
+ }
458
+ return this.processJsonElemMatch(cond[key].$elemMatch, prop, a);
459
+ }
460
+ }
439
461
  if (Utils.isPlainObject(cond[key]) || cond[key] instanceof RegExp) {
440
462
  return this.processObjectSubCondition(cond, key, type);
441
463
  }
@@ -827,6 +849,196 @@ export class QueryBuilderHelper {
827
849
  isTableNameAliasRequired(type) {
828
850
  return [QueryType.SELECT, QueryType.COUNT].includes(type);
829
851
  }
852
+ processEmbeddedArrayCondition(cond, prop, alias) {
853
+ const column = this.#platform.quoteIdentifier(`${alias}.${prop.fieldNames[0]}`);
854
+ const resolveProperty = (key) => {
855
+ const { embProp, jsonPropName } = this.resolveEmbeddedProp(prop, key);
856
+ return { name: jsonPropName, type: embProp.runtimeType ?? 'string' };
857
+ };
858
+ const invalidObjectError = (key) => ValidationError.invalidEmbeddableQuery(this.#entityName, key, prop.type);
859
+ const parts = [];
860
+ const allParams = [];
861
+ // Top-level $not generates NOT EXISTS (no element matches the inner condition).
862
+ const { $not, ...rest } = cond;
863
+ if (Utils.hasObjectKeys(rest)) {
864
+ const result = this.buildJsonArrayExists(rest, column, false, resolveProperty, invalidObjectError);
865
+ if (result) {
866
+ parts.push(result.sql);
867
+ allParams.push(...result.params);
868
+ }
869
+ }
870
+ if ($not != null) {
871
+ if (!Utils.isPlainObject($not)) {
872
+ throw new ValidationError(`Invalid query: $not in embedded array queries expects an object value`);
873
+ }
874
+ const result = this.buildJsonArrayExists($not, column, true, resolveProperty, invalidObjectError);
875
+ if (result) {
876
+ parts.push(result.sql);
877
+ allParams.push(...result.params);
878
+ }
879
+ }
880
+ if (parts.length === 0) {
881
+ return { sql: '1 = 1', params: [] };
882
+ }
883
+ return { sql: parts.join(' and '), params: allParams };
884
+ }
885
+ buildJsonArrayExists(cond, column, negate, resolveProperty, invalidObjectError) {
886
+ const jeAlias = `__je${this.#jsonAliasCounter++}`;
887
+ const referencedProps = new Map();
888
+ const { sql: whereSql, params } = this.buildArrayElementWhere(cond, jeAlias, referencedProps, resolveProperty, invalidObjectError);
889
+ if (!whereSql) {
890
+ return null;
891
+ }
892
+ const from = this.#platform.getJsonArrayFromSQL(column, jeAlias, [...referencedProps.values()]);
893
+ const exists = this.#platform.getJsonArrayExistsSQL(from, whereSql);
894
+ return { sql: negate ? `not ${exists}` : exists, params };
895
+ }
896
+ resolveEmbeddedProp(prop, key) {
897
+ const embProp = prop.embeddedProps[key] ?? Object.values(prop.embeddedProps).find(p => p.name === key);
898
+ if (!embProp) {
899
+ throw ValidationError.invalidEmbeddableQuery(this.#entityName, key, prop.type);
900
+ }
901
+ const prefix = `${prop.fieldNames[0]}~`;
902
+ const raw = embProp.fieldNames[0];
903
+ const jsonPropName = raw.startsWith(prefix) ? raw.slice(prefix.length) : raw;
904
+ return { embProp, jsonPropName };
905
+ }
906
+ buildEmbeddedArrayOperatorCondition(lhs, value, params) {
907
+ const supported = new Set(['$eq', '$ne', '$gt', '$gte', '$lt', '$lte', '$in', '$nin', '$not', '$like', '$exists']);
908
+ const parts = [];
909
+ // Clone to avoid getOperatorReplacement mutating the original (it sets value[op] = null for $exists).
910
+ value = { ...value };
911
+ for (const op of Object.keys(value)) {
912
+ if (!supported.has(op)) {
913
+ throw new ValidationError(`Operator ${op} is not supported in embedded array queries`);
914
+ }
915
+ const replacement = this.getOperatorReplacement(op, value);
916
+ const val = value[op];
917
+ if (['$in', '$nin'].includes(op)) {
918
+ if (!Array.isArray(val)) {
919
+ throw new ValidationError(`Invalid query: ${op} operator expects an array value`);
920
+ }
921
+ else if (val.length === 0) {
922
+ parts.push(`1 = ${op === '$in' ? 0 : 1}`);
923
+ }
924
+ else {
925
+ val.forEach((v) => params.push(v));
926
+ parts.push(`${lhs} ${replacement} (${val.map(() => '?').join(', ')})`);
927
+ }
928
+ }
929
+ else if (op === '$exists') {
930
+ parts.push(`${lhs} ${replacement} null`);
931
+ }
932
+ else if (val === null) {
933
+ parts.push(`${lhs} ${replacement} null`);
934
+ }
935
+ else {
936
+ parts.push(`${lhs} ${replacement} ?`);
937
+ params.push(val);
938
+ }
939
+ }
940
+ return parts.join(' and ');
941
+ }
942
+ processJsonElemMatch(cond, prop, alias) {
943
+ const column = this.#platform.quoteIdentifier(`${alias}.${prop.fieldNames[0]}`);
944
+ const result = this.buildJsonArrayExists(cond, column, false, (key, value) => {
945
+ this.#platform.validateJsonPropertyName(key);
946
+ return { name: key, type: this.inferJsonValueType(value) };
947
+ }, () => ValidationError.invalidQueryCondition(cond));
948
+ return result ?? { sql: '1 = 1', params: [] };
949
+ }
950
+ /**
951
+ * Shared logic for building WHERE conditions inside JSON array EXISTS subqueries.
952
+ * Used by both embedded array queries (metadata-driven) and $elemMatch (type-inferred).
953
+ */
954
+ buildArrayElementWhere(cond, jeAlias, referencedProps, resolveProperty, invalidObjectError) {
955
+ const parts = [];
956
+ const params = [];
957
+ for (const k of Object.keys(cond)) {
958
+ if (k === '$and' || k === '$or') {
959
+ const items = cond[k];
960
+ if (items.length === 0) {
961
+ continue;
962
+ }
963
+ const subParts = [];
964
+ for (const item of items) {
965
+ const sub = this.buildArrayElementWhere(item, jeAlias, referencedProps, resolveProperty, invalidObjectError);
966
+ if (sub.sql) {
967
+ subParts.push(sub.sql);
968
+ params.push(...sub.params);
969
+ }
970
+ }
971
+ if (subParts.length > 0) {
972
+ const joiner = k === '$or' ? ' or ' : ' and ';
973
+ parts.push(`(${subParts.join(joiner)})`);
974
+ }
975
+ continue;
976
+ }
977
+ // Within $or/$and scope, $not provides element-level negation:
978
+ // "this element does not match the condition".
979
+ if (k === '$not') {
980
+ const sub = this.buildArrayElementWhere(cond[k], jeAlias, referencedProps, resolveProperty, invalidObjectError);
981
+ if (sub.sql) {
982
+ parts.push(`not (${sub.sql})`);
983
+ params.push(...sub.params);
984
+ }
985
+ continue;
986
+ }
987
+ const value = cond[k];
988
+ const { name, type } = resolveProperty(k, value);
989
+ referencedProps.set(k, { name, type });
990
+ const lhs = this.#platform.getJsonArrayElementPropertySQL(jeAlias, name, type);
991
+ if (Utils.isPlainObject(value)) {
992
+ const valueKeys = Object.keys(value);
993
+ if (valueKeys.some(vk => !Utils.isOperator(vk))) {
994
+ throw invalidObjectError(k);
995
+ }
996
+ const sub = this.buildEmbeddedArrayOperatorCondition(lhs, value, params);
997
+ parts.push(sub);
998
+ }
999
+ else if (value === null) {
1000
+ parts.push(`${lhs} is null`);
1001
+ }
1002
+ else {
1003
+ parts.push(`${lhs} = ?`);
1004
+ params.push(value);
1005
+ }
1006
+ }
1007
+ return { sql: parts.join(' and '), params };
1008
+ }
1009
+ inferJsonValueType(value) {
1010
+ if (typeof value === 'number') {
1011
+ return 'number';
1012
+ }
1013
+ if (typeof value === 'boolean') {
1014
+ return 'boolean';
1015
+ }
1016
+ if (typeof value === 'bigint') {
1017
+ return 'bigint';
1018
+ }
1019
+ if (Utils.isPlainObject(value)) {
1020
+ for (const v of Object.values(value)) {
1021
+ if (typeof v === 'number') {
1022
+ return 'number';
1023
+ }
1024
+ if (typeof v === 'boolean') {
1025
+ return 'boolean';
1026
+ }
1027
+ if (typeof v === 'bigint') {
1028
+ return 'bigint';
1029
+ }
1030
+ if (Array.isArray(v) && v.length > 0) {
1031
+ if (typeof v[0] === 'number') {
1032
+ return 'number';
1033
+ }
1034
+ if (typeof v[0] === 'boolean') {
1035
+ return 'boolean';
1036
+ }
1037
+ }
1038
+ }
1039
+ }
1040
+ return 'string';
1041
+ }
830
1042
  processOnConflictCondition(cond, schema) {
831
1043
  const meta = this.#metadata.get(this.#entityName);
832
1044
  const tableName = meta.tableName;
package/query/enums.d.ts CHANGED
@@ -7,6 +7,8 @@ export declare enum QueryType {
7
7
  DELETE = "DELETE",
8
8
  UPSERT = "UPSERT"
9
9
  }
10
+ /** Operators that apply to the embedded array column itself, not to individual elements. */
11
+ export declare const EMBEDDABLE_ARRAY_OPS: string[];
10
12
  export declare enum JoinType {
11
13
  leftJoin = "left join",
12
14
  innerJoin = "inner join",
package/query/enums.js CHANGED
@@ -8,6 +8,8 @@ export var QueryType;
8
8
  QueryType["DELETE"] = "DELETE";
9
9
  QueryType["UPSERT"] = "UPSERT";
10
10
  })(QueryType || (QueryType = {}));
11
+ /** Operators that apply to the embedded array column itself, not to individual elements. */
12
+ export const EMBEDDABLE_ARRAY_OPS = ['$contains', '$contained', '$overlap'];
11
13
  export var JoinType;
12
14
  (function (JoinType) {
13
15
  JoinType["leftJoin"] = "left join";