@mikro-orm/sql 7.2.7-dev.6 → 7.2.7-dev.8

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.
@@ -18,6 +18,8 @@ export declare abstract class AbstractSqlDriver<Connection extends AbstractSqlCo
18
18
  evaluateFormula(formula: (...args: any[]) => string | Raw, columns: any, table: FormulaTable): string;
19
19
  /** For TPT entities, returns ownProps (columns in this table); otherwise returns all props. */
20
20
  protected getTableProps<T extends object>(meta: EntityMetadata<T>): EntityProperty<T>[];
21
+ /** Columns reloaded by INSERT: generated/default columns any row leaves to the database, and explicit `returning` hints. */
22
+ protected getInsertReturningProperties<T extends object>(meta: EntityMetadata<T>, data: EntityDictionary<T>[]): EntityProperty<T>[];
21
23
  /** Columns reloaded by UPDATE: raw values, PKs, generated/version columns and explicit `returning` hints. */
22
24
  protected getUpdateReturningProperties<T extends object>(meta: EntityMetadata<T>, data: EntityDictionary<T>[]): EntityProperty<T>[];
23
25
  /** Column expression for the `returning` clause of batch updates, applying `convertToJSValueSQL` of custom types. */
@@ -63,6 +65,8 @@ export declare abstract class AbstractSqlDriver<Connection extends AbstractSqlCo
63
65
  private buildCloneFields;
64
66
  private getCloneableProps;
65
67
  nativeInsertMany<T extends object>(entityName: EntityName<T>, data: EntityDictionary<T>[], options?: NativeInsertUpdateManyOptions<T>, transform?: (sql: string) => string): Promise<QueryResult<T>>;
68
+ /** Resolves the primary key of every inserted row from the supplied data or the returned rows, and reports them via `insertedIds` when all are known. */
69
+ protected resolveInsertedPrimaryKeys<T extends object>(meta: EntityMetadata<T>, data: EntityDictionary<T>[], res: QueryResult<T>): any[][];
66
70
  nativeUpdate<T extends object>(entityName: EntityName<T>, where: FilterQuery<T>, data: EntityDictionary<T>, options?: NativeInsertUpdateOptions<T> & UpsertOptions<T>): Promise<QueryResult<T>>;
67
71
  nativeUpdateMany<T extends object>(entityName: EntityName<T>, where: FilterQuery<T>[], data: EntityDictionary<T>[], options?: NativeInsertUpdateManyOptions<T> & UpsertManyOptions<T>, transform?: (sql: string, params: any[]) => string): Promise<QueryResult<T>>;
68
72
  nativeDelete<T extends object>(entityName: EntityName<T>, where: FilterQuery<T> | string | any, options?: DeleteOptions<T>): Promise<QueryResult<T>>;
@@ -49,6 +49,12 @@ export class AbstractSqlDriver extends DatabaseDriver {
49
49
  getTableProps(meta) {
50
50
  return meta.inheritanceType === 'tpt' && meta.ownProps ? meta.ownProps : meta.props;
51
51
  }
52
+ /** Columns reloaded by INSERT: generated/default columns any row leaves to the database, and explicit `returning` hints. */
53
+ getInsertReturningProperties(meta, data) {
54
+ return this.getTableProps(meta).filter(prop => prop.returning ||
55
+ (((prop.persist !== false && prop.defaultRaw) || prop.autoincrement || prop.generated) &&
56
+ data.some(d => !(prop.name in d) || isRaw(d[prop.name]))));
57
+ }
52
58
  /** Columns reloaded by UPDATE: raw values, PKs, generated/version columns and explicit `returning` hints. */
53
59
  getUpdateReturningProperties(meta, data) {
54
60
  const returning = new Set();
@@ -801,10 +807,7 @@ export class AbstractSqlDriver extends DatabaseDriver {
801
807
  ? '(' + fields.map(k => this.platform.quoteIdentifier(k)).join(', ') + ')'
802
808
  : `(${this.platform.quoteIdentifier(pks[0])})`;
803
809
  if (this.platform.usesOutputStatement()) {
804
- const returningProps = this.getTableProps(meta).filter(prop => prop.returning ||
805
- (((prop.persist !== false && prop.defaultRaw) || prop.autoincrement || prop.generated) &&
806
- (!(prop.name in data[0]) || isRaw(data[0][prop.name]))));
807
- const returningFields = Utils.flatten(returningProps.map(prop => prop.fieldNames));
810
+ const returningFields = Utils.flatten(this.getInsertReturningProperties(meta, data).map(prop => prop.fieldNames));
808
811
  sql +=
809
812
  returningFields.length > 0
810
813
  ? ` output ${returningFields.map(field => 'inserted.' + this.platform.quoteIdentifier(field)).join(', ')}`
@@ -931,10 +934,7 @@ export class AbstractSqlDriver extends DatabaseDriver {
931
934
  .join(', ');
932
935
  }
933
936
  if (meta && this.platform.usesReturningStatement()) {
934
- const returningProps = this.getTableProps(meta).filter(prop => prop.returning ||
935
- (((prop.persist !== false && prop.defaultRaw) || prop.autoincrement || prop.generated) &&
936
- (!(prop.name in data[0]) || isRaw(data[0][prop.name]))));
937
- const returningFields = Utils.flatten(returningProps.map(prop => prop.fieldNames));
937
+ const returningFields = Utils.flatten(this.getInsertReturningProperties(meta, data).map(prop => prop.fieldNames));
938
938
  /* v8 ignore next */
939
939
  sql +=
940
940
  returningFields.length > 0
@@ -945,22 +945,35 @@ export class AbstractSqlDriver extends DatabaseDriver {
945
945
  sql = transform(sql);
946
946
  }
947
947
  const res = await this.execute(sql, params, 'run', options.ctx, withAbortContext(options.loggerContext, options));
948
- let pk;
949
- if (pks.length > 1) {
950
- // owner has composite pk
951
- pk = data.map(d => Utils.getOrderedPrimaryKeys(d, meta));
952
- }
953
- else {
954
- res.row ??= {};
955
- res.rows ??= [];
956
- pk = data.map((d, i) => d[pks[0]] ?? res.rows[i]?.[pks[0]]).map(d => [d]);
957
- res.insertId = res.insertId || res.row[pks[0]];
958
- }
948
+ const pk = this.resolveInsertedPrimaryKeys(meta, data, res);
959
949
  for (let i = 0; i < collections.length; i++) {
960
950
  await this.processManyToMany(meta, pk[i], collections[i], false, options);
961
951
  }
962
952
  return res;
963
953
  }
954
+ /** Resolves the primary key of every inserted row from the supplied data or the returned rows, and reports them via `insertedIds` when all are known. */
955
+ resolveInsertedPrimaryKeys(meta, data, res) {
956
+ const pks = this.getPrimaryKeyFields(meta);
957
+ if (pks.length > 1) {
958
+ // owner has composite pk
959
+ const pk = data.map(d => Utils.getOrderedPrimaryKeys(d, meta));
960
+ res.insertedIds = pk;
961
+ return pk;
962
+ }
963
+ res.row ??= {};
964
+ res.rows ??= [];
965
+ const ids = data.map((d, i) => {
966
+ // a raw pk expression is resolved by the database, its value only exists in the returned row
967
+ const supplied = d[meta.primaryKeys[0]] ?? d[pks[0]];
968
+ return supplied == null || isRaw(supplied) ? res.rows[i]?.[pks[0]] : supplied;
969
+ });
970
+ res.insertId = res.insertId || res.row[pks[0]];
971
+ // drivers without `returning` leave the ids unknown here, keep the `insertId` fallback for them
972
+ if (ids.every(id => id != null)) {
973
+ res.insertedIds = ids;
974
+ }
975
+ return ids.map(id => [id]);
976
+ }
964
977
  async nativeUpdate(entityName, where, data, options = {}) {
965
978
  options.convertCustomTypes ??= true;
966
979
  const meta = this.metadata.get(entityName);
@@ -2544,7 +2557,9 @@ export class AbstractSqlDriver extends DatabaseDriver {
2544
2557
  if (ret.length > 0 && !hasExplicitFields && addFormulas) {
2545
2558
  // `toString()` must yield the quoted alias like `QueryBuilder.finalize()`, which dedupes formulas by rendered SQL
2546
2559
  const quotedAlias = this.platform.quoteIdentifier(alias);
2547
- const columns = meta.createColumnMappingObject(alias, quotedAlias.toString());
2560
+ // TPT parent aliases are only known once the parent joins exist
2561
+ qb.ensureTPTJoins();
2562
+ const columns = meta.createColumnMappingObject(p => qb.helper.getTPTAliasForProperty(p.name, alias), quotedAlias.toString());
2548
2563
  const effectiveSchema = schema ?? (meta.schema !== '*' ? meta.schema : undefined);
2549
2564
  for (const prop of meta.props) {
2550
2565
  if (lazyProps.includes(prop)) {
@@ -59,6 +59,7 @@ export declare class BasePostgreSqlPlatform extends AbstractSqlPlatform {
59
59
  };
60
60
  isBigIntProperty(prop: EntityProperty): boolean;
61
61
  getArrayDeclarationSQL(): string;
62
+ supportsArrayOverlap(): boolean;
62
63
  getFloatDeclarationSQL(): string;
63
64
  getDoubleDeclarationSQL(): string;
64
65
  getEnumTypeDeclarationSQL(column: {
@@ -199,6 +199,9 @@ export class BasePostgreSqlPlatform extends AbstractSqlPlatform {
199
199
  getArrayDeclarationSQL() {
200
200
  return 'text[]';
201
201
  }
202
+ supportsArrayOverlap() {
203
+ return true;
204
+ }
202
205
  getFloatDeclarationSQL() {
203
206
  return 'real';
204
207
  }
@@ -25,6 +25,7 @@ export class PostgreSqlExceptionConverter extends ExceptionConverter {
25
25
  break;
26
26
  case '23502':
27
27
  return new NotNullConstraintViolationException(exception);
28
+ case '23001':
28
29
  case '23503':
29
30
  return new ForeignKeyConstraintViolationException(exception);
30
31
  case '23505':
@@ -409,14 +409,19 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
409
409
  const key = this.getTableKey(col);
410
410
  ret[key] ??= [];
411
411
  let type = col.data_type.toLowerCase() === 'array' ? col.udt_name.replace(/^_(.*)$/, '$1[]') : col.udt_name;
412
+ // `udt_name` drops array element typmods (`_numeric` for `numeric(12,2)[]`), recover them from `format_type`
413
+ const arrayModifier = type.endsWith('[]')
414
+ ? /^[^(]*(\([^)]*\))[^(]*\[\]$/.exec(col.format_type ?? '')?.[1]
415
+ : undefined;
416
+ if (arrayModifier) {
417
+ type = `${type.slice(0, -2)}${arrayModifier}[]`;
418
+ }
412
419
  if (col.data_type === 'USER-DEFINED' &&
413
420
  col.udt_schema &&
414
421
  col.udt_schema !== this.platform.getDefaultSchemaName()) {
415
422
  type = `${col.udt_schema}.${type}`;
416
423
  }
417
- if (type === 'bpchar') {
418
- type = 'char';
419
- }
424
+ type = type.replace(/^bpchar\b/, 'char');
420
425
  // postgis stores the geometry/geography typmod (`geometry(point,4326)`) in atttypmod, which
421
426
  // information_schema does not expose — recover the full type via format_type so it round-trips
422
427
  if ((col.udt_name === 'geometry' || col.udt_name === 'geography') && col.format_type) {
@@ -1,4 +1,4 @@
1
- import { type EntityProperty, type IsolationLevel } from '@mikro-orm/core';
1
+ import { type EntityMetadata, type EntityProperty, type IsolationLevel } from '@mikro-orm/core';
2
2
  import { AbstractSqlPlatform } from '../../AbstractSqlPlatform.js';
3
3
  import { SqliteNativeQueryBuilder } from './SqliteNativeQueryBuilder.js';
4
4
  import { SqliteSchemaHelper } from './SqliteSchemaHelper.js';
@@ -74,6 +74,7 @@ export declare class SqlitePlatform extends AbstractSqlPlatform {
74
74
  */
75
75
  supportsSchemas(): boolean;
76
76
  getDefaultSchemaName(): string | undefined;
77
+ validateMetadata(meta: EntityMetadata): void;
77
78
  getFullTextWhereClause(): string;
78
79
  escape(value: any): string;
79
80
  convertVersionValue(value: Date | number, prop: EntityProperty): number | {
@@ -1,3 +1,4 @@
1
+ import { MetadataError } from '@mikro-orm/core';
1
2
  import { AbstractSqlPlatform } from '../../AbstractSqlPlatform.js';
2
3
  import { SqliteNativeQueryBuilder } from './SqliteNativeQueryBuilder.js';
3
4
  import { SqliteSchemaHelper } from './SqliteSchemaHelper.js';
@@ -110,6 +111,14 @@ export class SqlitePlatform extends AbstractSqlPlatform {
110
111
  // Return 'main' only when schema support is active (i.e., databases are attached)
111
112
  return this.supportsSchemas() ? 'main' : undefined;
112
113
  }
114
+ validateMetadata(meta) {
115
+ super.validateMetadata(meta);
116
+ // `main` always exists and `*` resolves at runtime; anything else fails later as `unknown database` unless attached
117
+ const attached = this.config.get('attachDatabases')?.some(db => db.name === meta.schema);
118
+ if (meta.schema && !['main', '*'].includes(meta.schema) && !attached) {
119
+ throw new MetadataError(`Entity ${meta.className} declares schema '${meta.schema}', but SQLite cannot address it: attach it via \`attachDatabases\` or remove the schema in \`discovery.onMetadata\` when sharing entities between drivers`);
120
+ }
121
+ }
113
122
  getFullTextWhereClause() {
114
123
  return `:column: match :query`;
115
124
  }
@@ -242,7 +242,8 @@ export class SqliteSchemaHelper extends SchemaHelper {
242
242
  }
243
243
  for (let i = cols.length - 1; i >= 0; i--) {
244
244
  const col = cols[i];
245
- const re = ` *, *[\`"']?${col.name}[\`"']? (.*)`;
245
+ // the first column is not preceded by a comma
246
+ const re = ` *(?:^|,) *[\`"']?${col.name}[\`"']? (.*)`;
246
247
  const columnDef = new RegExp(re, 'i').exec(columnsDef);
247
248
  if (columnDef) {
248
249
  columns[col.name] = { name: col.name, definition: columnDef[1] };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikro-orm/sql",
3
- "version": "7.2.7-dev.6",
3
+ "version": "7.2.7-dev.8",
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": "^7.2.6"
54
54
  },
55
55
  "peerDependencies": {
56
- "@mikro-orm/core": "7.2.7-dev.6"
56
+ "@mikro-orm/core": "7.2.7-dev.8"
57
57
  },
58
58
  "engines": {
59
59
  "node": ">= 22.17.0"
@@ -27,6 +27,7 @@ export declare class CriteriaNode<T extends object> implements ICriteriaNode<T>
27
27
  parentPath?: string;
28
28
  }): string;
29
29
  private isPivotJoin;
30
+ protected isOperatorPayload(payload: any): boolean;
30
31
  getPivotPath(path: string): string;
31
32
  aliased(field: string, alias?: string): string;
32
33
  isStrict(): boolean;
@@ -125,8 +125,17 @@ export class CriteriaNode {
125
125
  this.payload instanceof RegExp ||
126
126
  this.payload instanceof Date ||
127
127
  rawField;
128
- const operator = Utils.isObject(this.payload) && Utils.getObjectQueryKeys(this.payload).every(k => Utils.isOperator(k, false));
129
- return this.prop.kind === ReferenceKind.MANY_TO_MANY && (scalar || operator);
128
+ return this.prop.kind === ReferenceKind.MANY_TO_MANY && (scalar || this.isOperatorPayload(this.payload));
129
+ }
130
+ isOperatorPayload(payload) {
131
+ return (Utils.isObject(payload) &&
132
+ Utils.getObjectQueryKeys(payload).every(k => {
133
+ if (k === '$not' && payload[k] instanceof CriteriaNode && Utils.isPlainObject(payload[k].payload)) {
134
+ // `$not` wrapping entity conditions is a condition on the target entity, not on the relation itself
135
+ return Utils.getObjectQueryKeys(payload[k].payload).every(ik => Utils.isOperator(ik, false));
136
+ }
137
+ return Utils.isOperator(k, false);
138
+ }));
130
139
  }
131
140
  getPivotPath(path) {
132
141
  return `${path}[pivot]`;
@@ -283,18 +283,7 @@ export class ObjectCriteriaNode extends CriteriaNode {
283
283
  const embeddable = this.prop.kind === ReferenceKind.EMBEDDED;
284
284
  const knownKey = [ReferenceKind.SCALAR, ReferenceKind.MANY_TO_ONE, ReferenceKind.EMBEDDED].includes(this.prop.kind) ||
285
285
  (this.prop.kind === ReferenceKind.ONE_TO_ONE && this.prop.owner);
286
- const operatorKeys = knownKey &&
287
- keys.every(key => {
288
- if (key === '$not') {
289
- // $not wraps conditions like $and/$or, check if it wraps entity property conditions (needs auto-join)
290
- // vs simple operator conditions on the FK (doesn't need auto-join)
291
- const childPayload = this.payload[key].payload;
292
- if (Utils.isPlainObject(childPayload)) {
293
- return Utils.getObjectQueryKeys(childPayload).every(k => Utils.isOperator(k, false));
294
- }
295
- }
296
- return Utils.isOperator(key, false);
297
- });
286
+ const operatorKeys = knownKey && this.isOperatorPayload(this.payload);
298
287
  const primaryKeys = knownKey &&
299
288
  keys.every(key => {
300
289
  if (key === '$not') {
@@ -322,8 +311,7 @@ export class ObjectCriteriaNode extends CriteriaNode {
322
311
  this.payload instanceof RegExp ||
323
312
  this.payload instanceof Date ||
324
313
  rawField;
325
- const operator = Utils.isPlainObject(this.payload) &&
326
- Utils.getObjectQueryKeys(this.payload).every(k => Utils.isOperator(k, false));
314
+ const operator = Utils.isPlainObject(this.payload) && this.isOperatorPayload(this.payload);
327
315
  const field = `${alias}.${this.prop.name}`;
328
316
  const method = qb.hasFlag(QueryFlag.INFER_POPULATE) ? 'joinAndSelect' : 'join';
329
317
  const path = this.getPath();
@@ -821,6 +821,7 @@ export declare class QueryBuilder<Entity extends object = AnyEntity, RootAlias e
821
821
  * ```
822
822
  */
823
823
  stream(options?: QBStreamOptions): AsyncIterableIterator<Loaded<Entity, Hint, Fields>>;
824
+ private convertStreamExceptions;
824
825
  /**
825
826
  * Alias for `qb.getResultList()`
826
827
  */
@@ -1003,6 +1004,10 @@ export declare class QueryBuilder<Entity extends object = AnyEntity, RootAlias e
1003
1004
  processPopulateHint(): void;
1004
1005
  private processPopulateWhere;
1005
1006
  private mergeOnConditions;
1007
+ /**
1008
+ * `$not` can be moved to a join's `on` clause as a whole only when all of its conditions target that join.
1009
+ */
1010
+ private getNotConditionAlias;
1006
1011
  /**
1007
1012
  * `$or` branches can be moved to a join's `on` clause only when they all target that same join
1008
1013
  * and stay flat — a partial `$or` in the `on` clause would drop rows matching a sibling branch,
@@ -1012,9 +1017,9 @@ export declare class QueryBuilder<Entity extends object = AnyEntity, RootAlias e
1012
1017
  private canDistributeOrBranches;
1013
1018
  private getOrBranchAliases;
1014
1019
  /**
1015
- * An entity filter's `$or` that cannot be distributed is applied intact to the `on` clause of the
1016
- * outermost join common to all targeted joins, nesting the targeted joins under it so the clause
1017
- * can reference their aliases.
1020
+ * A condition that cannot be distributed (an entity filter's `$or`, or a `$not` spanning several joins)
1021
+ * is applied intact to the `on` clause of the outermost join common to all targeted joins, nesting
1022
+ * the targeted joins under it so the clause can reference their aliases.
1018
1023
  */
1019
1024
  private mergeFilterOrCondition;
1020
1025
  /**
@@ -1080,12 +1080,19 @@ export class QueryBuilder {
1080
1080
  const conn = this.getConnection();
1081
1081
  // outside a transaction, wrap in a short implicit one so RLS `set local` session context applies (no-op when unset)
1082
1082
  const sessionContext = this.context ? undefined : this.em?.getTransactionSessionContext();
1083
- const res = await (sessionContext
1084
- ? conn.transactional(trx => conn.execute(query.sql, query.params, method, trx, loggerContext), {
1085
- sessionContext,
1086
- loggerContext,
1087
- })
1088
- : conn.execute(query.sql, query.params, method, this.context, loggerContext));
1083
+ let res;
1084
+ // a QueryBuilder used directly must honour the same `convertException` contract as the driver methods
1085
+ try {
1086
+ res = await (sessionContext
1087
+ ? conn.transactional(trx => conn.execute(query.sql, query.params, method, trx, loggerContext), {
1088
+ sessionContext,
1089
+ loggerContext,
1090
+ })
1091
+ : conn.execute(query.sql, query.params, method, this.context, loggerContext));
1092
+ }
1093
+ catch (e) {
1094
+ throw this.driver.convertException(e);
1095
+ }
1089
1096
  const meta = this.mainAlias.meta;
1090
1097
  if (!options.mapResults || !meta) {
1091
1098
  await this.em?.storeCache(this.#state.cache, cached, res);
@@ -1146,7 +1153,7 @@ export class QueryBuilder {
1146
1153
  const chunkSize = options.chunkSize ?? 100;
1147
1154
  const query = this.toQuery();
1148
1155
  const loggerContext = { id: this.em?.id, ...this.loggerContext, ...this.#abortOptions };
1149
- const res = this.getConnection().stream(query.sql, query.params, this.context, loggerContext, chunkSize);
1156
+ const res = this.convertStreamExceptions(this.getConnection().stream(query.sql, query.params, this.context, loggerContext, chunkSize));
1150
1157
  const meta = this.mainAlias.meta;
1151
1158
  if (options.rawResults || !meta) {
1152
1159
  yield* res;
@@ -1180,6 +1187,14 @@ export class QueryBuilder {
1180
1187
  }
1181
1188
  }
1182
1189
  }
1190
+ async *convertStreamExceptions(res) {
1191
+ try {
1192
+ yield* res;
1193
+ }
1194
+ catch (e) {
1195
+ throw this.driver.convertException(e);
1196
+ }
1197
+ }
1183
1198
  /**
1184
1199
  * Alias for `qb.getResultList()`
1185
1200
  */
@@ -1558,7 +1573,10 @@ export class QueryBuilder {
1558
1573
  field = asMatch[1].trim();
1559
1574
  customAlias = asMatch[2];
1560
1575
  }
1561
- const join = Object.keys(this.#state.joins).find(k => field === k.substring(0, k.indexOf('#')));
1576
+ // an aliased field is always a column, never a relation to expand, even when an FK column is named like its relation
1577
+ const join = customAlias
1578
+ ? undefined
1579
+ : Object.keys(this.#state.joins).find(k => field === k.substring(0, k.indexOf('#')));
1562
1580
  if (join && type === 'where') {
1563
1581
  ret.push(...this.helper.mapJoinColumns(this.type, this.#state.joins[join]));
1564
1582
  return;
@@ -2102,7 +2120,13 @@ export class QueryBuilder {
2102
2120
  }
2103
2121
  mergeOnConditions(joins, cond, filter, op) {
2104
2122
  for (const k of Object.keys(cond)) {
2105
- if (Utils.isOperator(k)) {
2123
+ const notAlias = k === '$not' ? this.getNotConditionAlias(cond[k], joins) : undefined;
2124
+ if (k === '$not' && !notAlias) {
2125
+ // splitting a `$not` across joins would drop the negation, so it is kept intact on their common ancestor join
2126
+ this.mergeFilterOrCondition([{ $not: cond[k] }], joins);
2127
+ continue;
2128
+ }
2129
+ if (Utils.isOperator(k) && !notAlias) {
2106
2130
  if (Array.isArray(cond[k])) {
2107
2131
  if (k === '$or' && !this.canDistributeOrBranches(cond[k], joins)) {
2108
2132
  // entity filters have no other sink, so their `$or` is kept intact on the outermost targeted join instead
@@ -2116,7 +2140,7 @@ export class QueryBuilder {
2116
2140
  /* v8 ignore next */
2117
2141
  this.mergeOnConditions(joins, cond[k], filter, k);
2118
2142
  }
2119
- const [alias] = this.helper.splitField(k);
2143
+ const alias = notAlias ?? this.helper.splitField(k)[0];
2120
2144
  const join = joins.find(j => j.alias === alias);
2121
2145
  if (join) {
2122
2146
  const parentJoin = joins.find(j => j.alias === join.ownerAlias);
@@ -2145,6 +2169,14 @@ export class QueryBuilder {
2145
2169
  }
2146
2170
  }
2147
2171
  }
2172
+ /**
2173
+ * `$not` can be moved to a join's `on` clause as a whole only when all of its conditions target that join.
2174
+ */
2175
+ getNotConditionAlias(cond, joins) {
2176
+ const aliases = this.getOrBranchAliases([cond]);
2177
+ const [alias] = aliases;
2178
+ return aliases.size === 1 && joins.some(j => j.alias === alias) ? alias : undefined;
2179
+ }
2148
2180
  /**
2149
2181
  * `$or` branches can be moved to a join's `on` clause only when they all target that same join
2150
2182
  * and stay flat — a partial `$or` in the `on` clause would drop rows matching a sibling branch,
@@ -2173,9 +2205,9 @@ export class QueryBuilder {
2173
2205
  return aliases;
2174
2206
  }
2175
2207
  /**
2176
- * An entity filter's `$or` that cannot be distributed is applied intact to the `on` clause of the
2177
- * outermost join common to all targeted joins, nesting the targeted joins under it so the clause
2178
- * can reference their aliases.
2208
+ * A condition that cannot be distributed (an entity filter's `$or`, or a `$not` spanning several joins)
2209
+ * is applied intact to the `on` clause of the outermost join common to all targeted joins, nesting
2210
+ * the targeted joins under it so the clause can reference their aliases.
2179
2211
  */
2180
2212
  mergeFilterOrCondition(branches, joins) {
2181
2213
  const aliases = this.getOrBranchAliases(branches);
@@ -623,6 +623,19 @@ export class QueryBuilderHelper {
623
623
  parts.push(`${this.#platform.quoteIdentifier(mappedKey)} ${replacement} ${sql}`);
624
624
  params.push(...query.params);
625
625
  }
626
+ else if (['$in', '$nin'].includes(op) &&
627
+ prop?.customType instanceof ArrayType &&
628
+ Array.isArray(opValue) &&
629
+ !opValue.every((v) => Array.isArray(v))) {
630
+ // a flat list on an array column matches any of its elements, like in mongo
631
+ if (!this.#platform.supportsArrayOverlap()) {
632
+ throw new ValidationError(`${op} on array property ${prop.name} expects a list of arrays, use $eq for an exact match`);
633
+ }
634
+ const mappedKey = this.mapper(key, type, opValue, null);
635
+ const sql = `${this.#platform.quoteIdentifier(mappedKey)} && ?`;
636
+ parts.push(op === '$in' ? sql : `not (${sql})`);
637
+ params.push(prop.customType.convertToDatabaseValue(opValue, this.#platform, { fromQuery: true, key: op, mode: 'query' }));
638
+ }
626
639
  else {
627
640
  const mappedKey = this.mapper(key, type, opValue, null);
628
641
  const val = this.getValueReplacement(fields, opValue, params, op, prop, rowValues);
@@ -641,16 +654,15 @@ export class QueryBuilderHelper {
641
654
  }
642
655
  return `(${tmp.join(', ')})`;
643
656
  }
644
- // array custom types (e.g. `$contains`/`$overlap` on `string[]` columns) are converted to a single
645
- // array literal, so we emit a single bound parameter — not one per element wrapped in a tuple
657
+ // array custom types convert a whole array into one literal, so each candidate array binds a single parameter
646
658
  if (prop?.customType instanceof ArrayType) {
647
- const item = prop.customType.convertToDatabaseValue(value, this.#platform, {
648
- fromQuery: true,
649
- key,
650
- mode: 'query',
651
- });
652
- params.push(item);
653
- return '?';
659
+ const list = key === '$in' || key === '$nin';
660
+ const items = list ? value : [value];
661
+ for (const item of items) {
662
+ params.push(prop.customType.convertToDatabaseValue(item, this.#platform, { fromQuery: true, key, mode: 'query' }));
663
+ }
664
+ const sql = items.map(() => '?').join(', ');
665
+ return list ? `(${sql})` : sql;
654
666
  }
655
667
  value.forEach(p => params.push(p));
656
668
  return `(${value.map(() => '?').join(', ')})`;
@@ -760,6 +760,10 @@ export class DatabaseTable {
760
760
  !idx.primary &&
761
761
  !idx.where &&
762
762
  !this.hasAdvancedIndexOptions(idx));
763
+ // `getMappedType('timestamptz(3)[]')` resolves to the element type so diffing keeps the modifier, but the property is still an array
764
+ if (column.type?.endsWith('[]') && !column.enumItems?.length) {
765
+ column = { ...column, mappedType: Type.getType(t.array) };
766
+ }
763
767
  const kind = this.getReferenceKind(fk, unique);
764
768
  const runtimeType = this.getPropertyTypeForColumn(namingStrategy, column, fk);
765
769
  const type = fk