@mikro-orm/sql 7.0.0-dev.336 → 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;
@@ -48,6 +49,8 @@ export declare abstract class AbstractSqlPlatform extends Platform {
48
49
  quoteCollation(collation: string): string;
49
50
  /** @internal */
50
51
  protected validateCollationName(collation: string): void;
52
+ /** @internal */
53
+ validateJsonPropertyName(name: string): void;
51
54
  /**
52
55
  * Returns FROM clause for JSON array iteration.
53
56
  * @internal
@@ -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;
@@ -123,6 +124,12 @@ export class AbstractSqlPlatform extends Platform {
123
124
  throw new Error(`Invalid collation name: '${collation}'. Collation names must contain only word characters.`);
124
125
  }
125
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
+ }
126
133
  /**
127
134
  * Returns FROM clause for JSON array iteration.
128
135
  * @internal
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikro-orm/sql",
3
- "version": "7.0.0-dev.336",
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.336"
56
+ "@mikro-orm/core": "7.0.0-dev.337"
57
57
  },
58
58
  "engines": {
59
59
  "node": ">= 22.17.0"
@@ -59,8 +59,14 @@ export declare class QueryBuilderHelper {
59
59
  private processEmbeddedArrayCondition;
60
60
  private buildJsonArrayExists;
61
61
  private resolveEmbeddedProp;
62
- private buildEmbeddedArrayWhere;
63
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;
64
70
  processOnConflictCondition(cond: FilterQuery<any>, schema?: string): FilterQuery<any>;
65
71
  createFormulaTable(alias: string, meta: EntityMetadata, schema?: string): FormulaTable;
66
72
  }
@@ -1,4 +1,4 @@
1
- import { ALIAS_REPLACEMENT, ALIAS_REPLACEMENT_RE, ArrayType, inspect, isRaw, LockMode, OptimisticLockError, QueryOperator, QueryOrderNumeric, raw, Raw, QueryHelper, ReferenceKind, Utils, ValidationError, } from '@mikro-orm/core';
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
2
  import { EMBEDDABLE_ARRAY_OPS, JoinType, QueryType } from './enums.js';
3
3
  /**
4
4
  * @internal
@@ -448,6 +448,15 @@ export class QueryBuilderHelper {
448
448
  return this.processEmbeddedArrayCondition(cond[key], prop, a);
449
449
  }
450
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
+ }
451
460
  }
452
461
  if (Utils.isPlainObject(cond[key]) || cond[key] instanceof RegExp) {
453
462
  return this.processObjectSubCondition(cond, key, type);
@@ -841,14 +850,18 @@ export class QueryBuilderHelper {
841
850
  return [QueryType.SELECT, QueryType.COUNT].includes(type);
842
851
  }
843
852
  processEmbeddedArrayCondition(cond, prop, alias) {
844
- const fieldName = prop.fieldNames[0];
845
- const column = this.#platform.quoteIdentifier(`${alias}.${fieldName}`);
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);
846
859
  const parts = [];
847
860
  const allParams = [];
848
861
  // Top-level $not generates NOT EXISTS (no element matches the inner condition).
849
862
  const { $not, ...rest } = cond;
850
863
  if (Utils.hasObjectKeys(rest)) {
851
- const result = this.buildJsonArrayExists(rest, prop, column, false);
864
+ const result = this.buildJsonArrayExists(rest, column, false, resolveProperty, invalidObjectError);
852
865
  if (result) {
853
866
  parts.push(result.sql);
854
867
  allParams.push(...result.params);
@@ -858,7 +871,7 @@ export class QueryBuilderHelper {
858
871
  if (!Utils.isPlainObject($not)) {
859
872
  throw new ValidationError(`Invalid query: $not in embedded array queries expects an object value`);
860
873
  }
861
- const result = this.buildJsonArrayExists($not, prop, column, true);
874
+ const result = this.buildJsonArrayExists($not, column, true, resolveProperty, invalidObjectError);
862
875
  if (result) {
863
876
  parts.push(result.sql);
864
877
  allParams.push(...result.params);
@@ -869,10 +882,10 @@ export class QueryBuilderHelper {
869
882
  }
870
883
  return { sql: parts.join(' and '), params: allParams };
871
884
  }
872
- buildJsonArrayExists(cond, prop, column, negate) {
885
+ buildJsonArrayExists(cond, column, negate, resolveProperty, invalidObjectError) {
873
886
  const jeAlias = `__je${this.#jsonAliasCounter++}`;
874
887
  const referencedProps = new Map();
875
- const { sql: whereSql, params } = this.buildEmbeddedArrayWhere(cond, prop, jeAlias, referencedProps);
888
+ const { sql: whereSql, params } = this.buildArrayElementWhere(cond, jeAlias, referencedProps, resolveProperty, invalidObjectError);
876
889
  if (!whereSql) {
877
890
  return null;
878
891
  }
@@ -890,7 +903,55 @@ export class QueryBuilderHelper {
890
903
  const jsonPropName = raw.startsWith(prefix) ? raw.slice(prefix.length) : raw;
891
904
  return { embProp, jsonPropName };
892
905
  }
893
- buildEmbeddedArrayWhere(cond, prop, jeAlias, referencedProps) {
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) {
894
955
  const parts = [];
895
956
  const params = [];
896
957
  for (const k of Object.keys(cond)) {
@@ -901,7 +962,7 @@ export class QueryBuilderHelper {
901
962
  }
902
963
  const subParts = [];
903
964
  for (const item of items) {
904
- const sub = this.buildEmbeddedArrayWhere(item, prop, jeAlias, referencedProps);
965
+ const sub = this.buildArrayElementWhere(item, jeAlias, referencedProps, resolveProperty, invalidObjectError);
905
966
  if (sub.sql) {
906
967
  subParts.push(sub.sql);
907
968
  params.push(...sub.params);
@@ -916,22 +977,21 @@ export class QueryBuilderHelper {
916
977
  // Within $or/$and scope, $not provides element-level negation:
917
978
  // "this element does not match the condition".
918
979
  if (k === '$not') {
919
- const sub = this.buildEmbeddedArrayWhere(cond[k], prop, jeAlias, referencedProps);
980
+ const sub = this.buildArrayElementWhere(cond[k], jeAlias, referencedProps, resolveProperty, invalidObjectError);
920
981
  if (sub.sql) {
921
982
  parts.push(`not (${sub.sql})`);
922
983
  params.push(...sub.params);
923
984
  }
924
985
  continue;
925
986
  }
926
- const { embProp, jsonPropName } = this.resolveEmbeddedProp(prop, k);
927
- referencedProps.set(k, { name: jsonPropName, type: embProp.runtimeType ?? 'string' });
928
- const lhs = this.#platform.getJsonArrayElementPropertySQL(jeAlias, jsonPropName, embProp.runtimeType ?? 'string');
929
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);
930
991
  if (Utils.isPlainObject(value)) {
931
- // Validate that all keys are operators — nested embeddables within array elements are not supported.
932
992
  const valueKeys = Object.keys(value);
933
993
  if (valueKeys.some(vk => !Utils.isOperator(vk))) {
934
- throw ValidationError.invalidEmbeddableQuery(this.#entityName, k, prop.type);
994
+ throw invalidObjectError(k);
935
995
  }
936
996
  const sub = this.buildEmbeddedArrayOperatorCondition(lhs, value, params);
937
997
  parts.push(sub);
@@ -946,41 +1006,38 @@ export class QueryBuilderHelper {
946
1006
  }
947
1007
  return { sql: parts.join(' and '), params };
948
1008
  }
949
- buildEmbeddedArrayOperatorCondition(lhs, value, params) {
950
- const supported = new Set(['$eq', '$ne', '$gt', '$gte', '$lt', '$lte', '$in', '$nin', '$not', '$like', '$exists']);
951
- const parts = [];
952
- // Clone to avoid getOperatorReplacement mutating the original (it sets value[op] = null for $exists).
953
- value = { ...value };
954
- for (const op of Object.keys(value)) {
955
- if (!supported.has(op)) {
956
- throw new ValidationError(`Operator ${op} is not supported in embedded array queries`);
957
- }
958
- const replacement = this.getOperatorReplacement(op, value);
959
- const val = value[op];
960
- if (['$in', '$nin'].includes(op)) {
961
- if (!Array.isArray(val)) {
962
- throw new ValidationError(`Invalid query: ${op} operator expects an array value`);
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';
963
1023
  }
964
- else if (val.length === 0) {
965
- parts.push(`1 = ${op === '$in' ? 0 : 1}`);
1024
+ if (typeof v === 'boolean') {
1025
+ return 'boolean';
966
1026
  }
967
- else {
968
- val.forEach((v) => params.push(v));
969
- parts.push(`${lhs} ${replacement} (${val.map(() => '?').join(', ')})`);
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
+ }
970
1037
  }
971
- }
972
- else if (op === '$exists') {
973
- parts.push(`${lhs} ${replacement} null`);
974
- }
975
- else if (val === null) {
976
- parts.push(`${lhs} ${replacement} null`);
977
- }
978
- else {
979
- parts.push(`${lhs} ${replacement} ?`);
980
- params.push(val);
981
1038
  }
982
1039
  }
983
- return parts.join(' and ');
1040
+ return 'string';
984
1041
  }
985
1042
  processOnConflictCondition(cond, schema) {
986
1043
  const meta = this.#metadata.get(this.#entityName);