@mikro-orm/core 7.1.0-dev.1 → 7.1.0-dev.10

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 (46) hide show
  1. package/EntityManager.d.ts +63 -11
  2. package/EntityManager.js +163 -18
  3. package/drivers/DatabaseDriver.d.ts +1 -0
  4. package/drivers/DatabaseDriver.js +3 -0
  5. package/drivers/IDatabaseDriver.d.ts +30 -1
  6. package/entity/BaseEntity.d.ts +2 -2
  7. package/entity/Collection.d.ts +6 -3
  8. package/entity/Collection.js +15 -4
  9. package/entity/EntityFactory.js +20 -1
  10. package/entity/EntityLoader.d.ts +3 -1
  11. package/entity/EntityLoader.js +39 -16
  12. package/entity/EntityRepository.d.ts +27 -9
  13. package/entity/EntityRepository.js +12 -0
  14. package/entity/WrappedEntity.d.ts +2 -2
  15. package/entity/defineEntity.d.ts +40 -9
  16. package/entity/defineEntity.js +0 -6
  17. package/entity/utils.js +4 -5
  18. package/errors.d.ts +2 -0
  19. package/errors.js +4 -0
  20. package/index.d.ts +2 -2
  21. package/index.js +1 -1
  22. package/metadata/MetadataDiscovery.d.ts +11 -0
  23. package/metadata/MetadataDiscovery.js +127 -9
  24. package/metadata/MetadataValidator.js +9 -0
  25. package/metadata/types.d.ts +10 -2
  26. package/naming-strategy/AbstractNamingStrategy.d.ts +1 -1
  27. package/naming-strategy/NamingStrategy.d.ts +1 -1
  28. package/package.json +1 -1
  29. package/platforms/Platform.d.ts +5 -0
  30. package/platforms/Platform.js +7 -0
  31. package/serialization/EntitySerializer.d.ts +18 -6
  32. package/serialization/EntitySerializer.js +31 -5
  33. package/typings.d.ts +108 -6
  34. package/typings.js +19 -2
  35. package/utils/AbstractMigrator.d.ts +11 -1
  36. package/utils/AbstractMigrator.js +203 -0
  37. package/utils/Configuration.d.ts +7 -0
  38. package/utils/Configuration.js +1 -0
  39. package/utils/DataloaderUtils.d.ts +10 -1
  40. package/utils/DataloaderUtils.js +78 -0
  41. package/utils/QueryHelper.d.ts +16 -0
  42. package/utils/QueryHelper.js +15 -0
  43. package/utils/Utils.d.ts +1 -1
  44. package/utils/Utils.js +1 -1
  45. package/utils/fs-utils.d.ts +2 -0
  46. package/utils/fs-utils.js +7 -1
@@ -159,6 +159,7 @@ export class MetadataDiscovery {
159
159
  forEachProp((m, p) => this.initGeneratedColumn(m, p));
160
160
  filtered.forEach(meta => this.initAutoincrement(meta)); // once again after we init custom types
161
161
  filtered.forEach(meta => this.initCheckConstraints(meta));
162
+ filtered.forEach(meta => this.initTriggers(meta));
162
163
  forEachProp((_m, p) => {
163
164
  this.initDefaultValue(p);
164
165
  this.inferTypeFromDefault(p);
@@ -479,17 +480,28 @@ export class MetadataDiscovery {
479
480
  prop.polymorphic = prop2.polymorphic;
480
481
  prop.discriminator = prop2.discriminator;
481
482
  prop.discriminatorColumn = prop2.discriminatorColumn;
482
- prop.discriminatorValue = prop2.discriminatorValue;
483
+ // For a union-target pivot each inverse side sits on one specific target class, so its
484
+ // discriminator value is that class's tableName. For Rails-style, prop2 has a single fixed value.
485
+ prop.discriminatorValue = QueryHelper.isUnionTargetPolymorphic(prop2) ? meta.tableName : prop2.discriminatorValue;
483
486
  }
484
487
  prop.referencedColumnNames ??= Utils.flatten(meta.primaryKeys.map(primaryKey => meta.properties[primaryKey].fieldNames));
485
- // For polymorphic M:N, use discriminator base name for FK column (e.g., taggable_id instead of post_id)
486
- if (prop.polymorphic && prop.discriminator) {
488
+ // Union-target polymorphic M:N: owner side is fixed (real FK), target side uses discriminator-derived names.
489
+ const isUnionTargetMN = QueryHelper.isUnionTargetPolymorphic(prop);
490
+ if (prop.polymorphic && prop.discriminator && !isUnionTargetMN) {
491
+ // Rails-style: owner side is polymorphic, uses discriminator base name (e.g. taggable_id instead of post_id)
487
492
  prop.joinColumns ??= prop.referencedColumnNames.map(referencedColumnName => this.#namingStrategy.joinKeyColumnName(prop.discriminator, referencedColumnName, prop.referencedColumnNames.length > 1));
488
493
  }
489
494
  else {
490
495
  prop.joinColumns ??= prop.referencedColumnNames.map(referencedColumnName => this.#namingStrategy.joinKeyColumnName(meta.root.className, referencedColumnName, meta.compositePK));
491
496
  }
492
- prop.inverseJoinColumns ??= this.initManyToOneFieldName(prop, meta2.root.className);
497
+ if (isUnionTargetMN) {
498
+ // Target side uses discriminator base name (e.g. attachable_id — shared across Image/Video)
499
+ const targetPkCols = Utils.flatten(meta2.primaryKeys.map(pk => meta2.properties[pk].fieldNames));
500
+ prop.inverseJoinColumns ??= targetPkCols.map(fieldName => this.#namingStrategy.joinKeyColumnName(prop.discriminator, fieldName, targetPkCols.length > 1));
501
+ }
502
+ else {
503
+ prop.inverseJoinColumns ??= this.initManyToOneFieldName(prop, meta2.root.className);
504
+ }
493
505
  }
494
506
  isExplicitTableName(meta) {
495
507
  return meta.tableName !== this.#namingStrategy.classToTableName(meta.className);
@@ -583,6 +595,24 @@ export class MetadataDiscovery {
583
595
  if (prop.inversedBy) {
584
596
  prop.targetMeta.properties[prop.inversedBy].pivotEntity = pivotMeta.class;
585
597
  }
598
+ // Propagate pivotEntity to ALL inverse collections using mappedBy pointing at this
599
+ // owner prop. Covers three cases:
600
+ // - regular inverse (Tag.posts mappedBy Post.tags) — handled by inversedBy above
601
+ // - union-target inverse (Image.posts mappedBy Post.attachments) — on each polymorph target
602
+ // - merged inverse (Tag.owners mappedBy [Post,Video].tags) — union collection on the target
603
+ const inverseCandidates = QueryHelper.isUnionTargetPolymorphic(prop)
604
+ ? prop.polymorphTargets
605
+ : [prop.targetMeta];
606
+ for (const targetMeta of inverseCandidates) {
607
+ for (const inverseProp of Object.values(targetMeta.properties)) {
608
+ if (inverseProp.kind === ReferenceKind.MANY_TO_MANY &&
609
+ inverseProp.mappedBy === prop.name &&
610
+ !inverseProp.pivotEntity) {
611
+ inverseProp.pivotEntity = pivotMeta.class;
612
+ inverseProp.pivotTable = pivotMeta.tableName;
613
+ }
614
+ }
615
+ }
586
616
  return pivotMeta;
587
617
  });
588
618
  }
@@ -721,8 +751,12 @@ export class MetadataDiscovery {
721
751
  }
722
752
  }
723
753
  }
724
- // For polymorphic M:N, create discriminator column and polymorphic FK
725
- if (prop.polymorphic && prop.discriminatorColumn) {
754
+ // Union-target polymorphic M:N: discriminator + target FK share the pivot across multiple target types
755
+ if (prop.discriminatorColumn && QueryHelper.isUnionTargetPolymorphic(prop)) {
756
+ this.defineUnionTargetPolymorphicPivotProperties(pivotMeta2, meta, prop);
757
+ }
758
+ else if (prop.polymorphic && prop.discriminatorColumn) {
759
+ // Rails-style polymorphic M:N: multiple owners share the pivot, single target type
726
760
  this.definePolymorphicPivotProperties(pivotMeta2, meta, prop, targetMeta);
727
761
  }
728
762
  else {
@@ -809,6 +843,33 @@ export class MetadataDiscovery {
809
843
  pivotMeta.polymorphicDiscriminatorMap ??= {};
810
844
  pivotMeta.polymorphicDiscriminatorMap[prop.discriminatorValue] = meta.class;
811
845
  }
846
+ /**
847
+ * Mirror of definePolymorphicPivotProperties for union-target M:N
848
+ * (e.g. Post.attachments -> Image | Video via shared pivot with a target-side discriminator).
849
+ *
850
+ * Pivot shape:
851
+ * (owner_fk..., discriminator_column, target_fk...)
852
+ * - owner side is a normal M:1 to the single owner entity
853
+ * - target side is a discriminator column + per-target-type virtual M:1 relations
854
+ */
855
+ defineUnionTargetPolymorphicPivotProperties(pivotMeta, meta, prop) {
856
+ const discriminatorColumn = prop.discriminatorColumn;
857
+ const targets = prop.polymorphTargets;
858
+ pivotMeta.properties[meta.name + '_owner'] = this.definePivotProperty(prop, meta.name + '_owner', meta.class, prop.discriminator, true, false);
859
+ const discriminatorProp = this.createPivotScalarProperty(discriminatorColumn, [this.#platform.getVarcharTypeDeclarationSQL(prop)], [discriminatorColumn], { type: 'string', primary: true, nullable: false });
860
+ this.initFieldName(discriminatorProp);
861
+ pivotMeta.properties[discriminatorColumn] = discriminatorProp;
862
+ const firstTargetColumnTypes = this.getPrimaryKeyColumnTypes(targets[0]);
863
+ pivotMeta.properties[prop.discriminator] = this.createPivotScalarProperty(prop.discriminator, firstTargetColumnTypes, [...prop.inverseJoinColumns], { type: targets[0].className, primary: true, nullable: false });
864
+ pivotMeta.polymorphicDiscriminatorMap ??= {};
865
+ for (const targetMeta of targets) {
866
+ const relationName = `${prop.discriminator}_${targetMeta.tableName}`;
867
+ const relation = this.definePolymorphicOwnerRelation(prop, relationName, targetMeta);
868
+ relation.joinColumns = relation.fieldNames = relation.ownColumns = [...prop.inverseJoinColumns];
869
+ pivotMeta.properties[relationName] = relation;
870
+ pivotMeta.polymorphicDiscriminatorMap[targetMeta.tableName] = targetMeta.class;
871
+ }
872
+ }
812
873
  /**
813
874
  * Create a virtual M:1 relation from pivot to a polymorphic owner entity.
814
875
  * This enables single-query join loading for inverse-side polymorphic M:N.
@@ -961,6 +1022,7 @@ export class MetadataDiscovery {
961
1022
  meta.indexes = Utils.unique([...base.indexes, ...meta.indexes]);
962
1023
  meta.uniques = Utils.unique([...base.uniques, ...meta.uniques]);
963
1024
  meta.checks = Utils.unique([...base.checks, ...meta.checks]);
1025
+ meta.triggers = Utils.unique([...base.triggers, ...meta.triggers]);
964
1026
  const pks = Object.values(meta.properties)
965
1027
  .filter(p => p.primary)
966
1028
  .map(p => p.name);
@@ -1045,11 +1107,15 @@ export class MetadataDiscovery {
1045
1107
  prop.discriminatorColumn ??= this.#namingStrategy.discriminatorColumnName(prop.discriminator);
1046
1108
  prop.createForeignKeyConstraint = false;
1047
1109
  const isToOne = [ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind);
1048
- if (isToOne) {
1110
+ const isUnionTargetMN = prop.kind === ReferenceKind.MANY_TO_MANY && Array.isArray(prop.target);
1111
+ if (isToOne || isUnionTargetMN) {
1049
1112
  const types = prop.type.split(/ ?\| ?/);
1050
1113
  prop.polymorphTargets = discovered.filter(m => types.includes(m.className) && !m.embeddable);
1051
1114
  prop.targetMeta = prop.polymorphTargets[0];
1052
1115
  prop.referencedPKs = prop.targetMeta?.primaryKeys;
1116
+ if (isUnionTargetMN && prop.polymorphTargets.length < 2) {
1117
+ throw new MetadataError(`${meta.className}.${prop.name} union-target polymorphic M:N requires at least two target entity types; use a regular M:N relation for a single target.`);
1118
+ }
1053
1119
  }
1054
1120
  if (prop.discriminatorMap) {
1055
1121
  const normalizedMap = {};
@@ -1065,7 +1131,7 @@ export class MetadataDiscovery {
1065
1131
  }
1066
1132
  prop.discriminatorMap = normalizedMap;
1067
1133
  }
1068
- else if (isToOne) {
1134
+ else if (isToOne || isUnionTargetMN) {
1069
1135
  prop.discriminatorMap = {};
1070
1136
  const tableNameToTarget = new Map();
1071
1137
  for (const target of prop.polymorphTargets) {
@@ -1520,6 +1586,27 @@ export class MetadataDiscovery {
1520
1586
  }
1521
1587
  }
1522
1588
  }
1589
+ initTriggers(meta) {
1590
+ if (meta.triggers.length === 0) {
1591
+ return;
1592
+ }
1593
+ const columns = meta.createSchemaColumnMappingObject();
1594
+ const table = this.createSchemaTable(meta);
1595
+ for (const trigger of meta.triggers) {
1596
+ if (trigger.body && trigger.expression) {
1597
+ throw new MetadataError(`Trigger "${trigger.name ?? '(unnamed)'}" on entity ${meta.className} defines both 'body' and 'expression'. Use one or the other.`);
1598
+ }
1599
+ if (!trigger.body && !trigger.expression) {
1600
+ throw new MetadataError(`Trigger "${trigger.name ?? '(unnamed)'}" on entity ${meta.className} must define either 'body' or 'expression'.`);
1601
+ }
1602
+ trigger.name ??= this.#namingStrategy.indexName(meta.tableName, trigger.events, 'trigger');
1603
+ trigger.forEach ??= 'row';
1604
+ if (trigger.body instanceof Function) {
1605
+ trigger.body = trigger.body(columns, table);
1606
+ }
1607
+ }
1608
+ meta.hasTriggers = true;
1609
+ }
1523
1610
  initGeneratedColumn(meta, prop) {
1524
1611
  if (!prop.generated && prop.columnTypes) {
1525
1612
  const match = /(.*) generated always as (.*)/i.exec(prop.columnTypes[0]);
@@ -1646,7 +1733,14 @@ export class MetadataDiscovery {
1646
1733
  return (!Type.getType(t[type]).ensureComparable(meta, prop) && (prop.type === t[type] || brand === type));
1647
1734
  });
1648
1735
  if (type) {
1649
- prop.type = type === 'datetime' ? 'Date' : type;
1736
+ if (prop.array) {
1737
+ // built-in type + array: true — force-create instance for ArrayType wrapping
1738
+ prop.customType = new prop.type();
1739
+ prop.type = prop.customType.constructor.name;
1740
+ }
1741
+ else {
1742
+ prop.type = type === 'datetime' ? 'Date' : type;
1743
+ }
1650
1744
  }
1651
1745
  else {
1652
1746
  prop.customType = new prop.type();
@@ -1656,6 +1750,30 @@ export class MetadataDiscovery {
1656
1750
  if (simple) {
1657
1751
  return;
1658
1752
  }
1753
+ if (prop.array &&
1754
+ prop.customType &&
1755
+ !(prop.customType instanceof t.array) &&
1756
+ !(prop.customType instanceof t.enumArray) &&
1757
+ prop.kind === ReferenceKind.SCALAR) {
1758
+ const innerType = prop.customType;
1759
+ innerType.platform = this.#platform;
1760
+ innerType.meta = meta;
1761
+ innerType.prop = prop;
1762
+ if (!prop.columnTypes) {
1763
+ const arrayDecl = this.#platform.getArrayDeclarationSQL();
1764
+ if (arrayDecl.endsWith('[]')) {
1765
+ // native array support (e.g. postgres) — use inner type's column type with [] suffix
1766
+ prop.columnTypes = [innerType.getColumnType(prop, this.#platform) + '[]'];
1767
+ }
1768
+ else {
1769
+ // non-native (e.g. mysql, sqlite) — store as text/clob
1770
+ prop.columnTypes = [arrayDecl];
1771
+ }
1772
+ }
1773
+ const platform = this.#platform;
1774
+ prop.customType = new t.array(v => innerType.convertToJSValue(v, platform), v => innerType.convertToDatabaseValue(v, platform));
1775
+ prop.type = prop.customType.constructor.name;
1776
+ }
1659
1777
  if (!prop.customType && ['json', 'jsonb'].includes(prop.type?.toLowerCase())) {
1660
1778
  prop.customType = new t.json();
1661
1779
  }
@@ -171,6 +171,15 @@ export class MetadataValidator {
171
171
  }
172
172
  validatePolymorphicTargets(meta, prop) {
173
173
  const targets = prop.polymorphTargets;
174
+ // Union-target M:N stores one scalar target FK per pivot row, so composite-PK targets
175
+ // can't round-trip through this schema.
176
+ if (prop.kind === ReferenceKind.MANY_TO_MANY && targets.length > 1) {
177
+ for (const target of targets) {
178
+ if (target.compositePK) {
179
+ throw MetadataError.incompatiblePolymorphicTargets(meta, prop, targets[0], target, `${target.className} has a composite primary key; union-target polymorphic M:N does not support composite-PK targets.`);
180
+ }
181
+ }
182
+ }
174
183
  // Validate targetKey exists and is compatible across all targets
175
184
  if (prop.targetKey) {
176
185
  for (const target of targets) {
@@ -1,4 +1,4 @@
1
- import type { AnyEntity, Constructor, EntityName, AnyString, CheckCallback, GeneratedColumnCallback, FormulaCallback, FilterQuery, Dictionary, AutoPath, EntityClass, IndexCallback, ObjectQuery, Raw } from '../typings.js';
1
+ import type { AnyEntity, Constructor, EntityName, AnyString, CheckCallback, GeneratedColumnCallback, FormulaCallback, FilterQuery, Dictionary, AutoPath, EntityClass, IndexCallback, ObjectQuery, Raw, TriggerDef } from '../typings.js';
2
2
  import type { Cascade, LoadStrategy, DeferMode, QueryOrderMap, EmbeddedPrefixMode } from '../enums.js';
3
3
  import type { Type, types } from '../types/index.js';
4
4
  import type { EntityManager } from '../EntityManager.js';
@@ -57,6 +57,8 @@ export type EntityOptions<T, E = T extends EntityClass<infer P> ? P : T> = {
57
57
  };
58
58
  /** Used to make ORM aware of externally defined triggers. This is needed for MS SQL Server multi inserts, ignored in other dialects. */
59
59
  hasTriggers?: boolean;
60
+ /** Database triggers to create for this entity's table. (SQL drivers only) */
61
+ triggers?: TriggerDef<E>[];
60
62
  /** SQL query that maps to a {@doclink virtual-entities | virtual entity}, or for view entities, the view definition. */
61
63
  expression?: string | ((em: any, where: ObjectQuery<E>, options: FindOptions<E, any, any, any>, stream?: boolean) => object);
62
64
  /** Set {@doclink repositories#custom-repository | custom repository class}. */
@@ -202,6 +204,12 @@ export interface PropertyOptions<Owner> {
202
204
  * @see https://mikro-orm.io/docs/defining-entities#lazy-scalar-properties
203
205
  */
204
206
  lazy?: boolean;
207
+ /**
208
+ * Marks this property as an array. When used with a custom type, the type is automatically
209
+ * wrapped in an `ArrayType` that delegates element conversion to the inner type.
210
+ * The column type is inferred as `innerType[]` (e.g. `int[]`, `time[]`).
211
+ */
212
+ array?: boolean;
205
213
  /**
206
214
  * Set true to define entity's unique primary key identifier.
207
215
  * Alias for `@PrimaryKey()` decorator
@@ -334,7 +342,7 @@ export interface PropertyOptions<Owner> {
334
342
  }
335
343
  export interface ReferenceOptions<Owner, Target> extends PropertyOptions<Owner> {
336
344
  /** Set target entity type. For polymorphic relations, pass an array of entity types. */
337
- entity?: () => EntityName<Target> | EntityName<Target>[];
345
+ entity?: () => EntityName<Target> | EntityName[];
338
346
  /** Set what actions on owning entity should be cascaded to the relationship. Defaults to [Cascade.PERSIST, Cascade.MERGE] (see {@doclink cascading}). */
339
347
  cascade?: Cascade[];
340
348
  /** Always load the relationship. Discouraged for use with to-many relations for performance reasons. */
@@ -4,7 +4,7 @@ import { type ReferenceKind } from '../enums.js';
4
4
  export declare abstract class AbstractNamingStrategy implements NamingStrategy {
5
5
  getClassName(file: string, separator?: string): string;
6
6
  classToMigrationName(timestamp: string, customMigrationName?: string): string;
7
- indexName(tableName: string, columns: string[], type: 'primary' | 'foreign' | 'unique' | 'index' | 'sequence' | 'check' | 'default'): string;
7
+ indexName(tableName: string, columns: string[], type: 'primary' | 'foreign' | 'unique' | 'index' | 'sequence' | 'check' | 'default' | 'trigger'): string;
8
8
  /**
9
9
  * @inheritDoc
10
10
  */
@@ -75,7 +75,7 @@ export interface NamingStrategy {
75
75
  /**
76
76
  * Returns key/constraint name for the given type. Some drivers might not support all the types (e.g. mysql and sqlite enforce the PK name).
77
77
  */
78
- indexName(tableName: string, columns: string[], type: 'primary' | 'foreign' | 'unique' | 'index' | 'sequence' | 'check' | 'default'): string;
78
+ indexName(tableName: string, columns: string[], type: 'primary' | 'foreign' | 'unique' | 'index' | 'sequence' | 'check' | 'default' | 'trigger'): string;
79
79
  /**
80
80
  * Returns alias name for given entity. The alias needs to be unique across the query, which is by default
81
81
  * ensured via appended index parameter. It is optional to use it as long as you ensure it will be unique.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikro-orm/core",
3
- "version": "7.1.0-dev.1",
3
+ "version": "7.1.0-dev.10",
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",
@@ -211,6 +211,11 @@ export declare abstract class Platform {
211
211
  getFullTextWhereClause(prop: EntityProperty): string;
212
212
  supportsCreatingFullTextIndex(): boolean;
213
213
  getFullTextIndexExpression(indexName: string, schemaName: string | undefined, tableName: string, columns: SimpleColumnMeta[]): string;
214
+ /**
215
+ * Generates the SQL index hint clause for the given index names.
216
+ * Returns `undefined` when the platform does not support index hints (e.g. PostgreSQL, SQLite).
217
+ */
218
+ formatIndexHint(indexNames: string[]): string | undefined;
214
219
  /** Whether the driver automatically parses JSON columns into JS objects. */
215
220
  convertsJsonAutomatically(): boolean;
216
221
  /** Converts a JS value to its JSON database representation (typically JSON.stringify). */
@@ -415,6 +415,13 @@ export class Platform {
415
415
  getFullTextIndexExpression(indexName, schemaName, tableName, columns) {
416
416
  throw new Error('Full text searching is not supported by this driver.');
417
417
  }
418
+ /**
419
+ * Generates the SQL index hint clause for the given index names.
420
+ * Returns `undefined` when the platform does not support index hints (e.g. PostgreSQL, SQLite).
421
+ */
422
+ formatIndexHint(indexNames) {
423
+ return undefined;
424
+ }
418
425
  /** Whether the driver automatically parses JSON columns into JS objects. */
419
426
  convertsJsonAutomatically() {
420
427
  return true;
@@ -1,21 +1,33 @@
1
- import type { ArrayElement, AutoPath, CleanTypeConfig, ExtractFieldsHint, FromEntityType, SerializeDTO, TypeConfig, UnboxArray } from '../typings.js';
1
+ import type { ArrayElement, AutoPath, CleanTypeConfig, ExtractFieldsHint, FromEntityType, ResolveSerializeFields, SerializeDTO, SerializeFieldsKeepPK, TypeConfig, UnboxArray } from '../typings.js';
2
2
  import { type PopulatePath } from '../enums.js';
3
- /** Converts entity instances to plain DTOs via `serialize()`, with fine-grained control over populate, exclude, and serialization groups. */
3
+ /** Converts entity instances to plain DTOs via `serialize()`, with fine-grained control over populate, exclude, fields, and serialization groups. */
4
4
  export declare class EntitySerializer {
5
- /** Serializes an entity to a plain DTO, with fine-grained control over population, exclusion, groups, and custom types. */
6
- static serialize<T extends object, P extends string = never, E extends string = never>(entity: T, options?: SerializeOptions<T, P, E>): SerializeDTO<T, P, E>;
5
+ /** Serializes an entity to a plain DTO, with fine-grained control over population, exclusion, fields, groups, and custom types. */
6
+ static serialize<T extends object, P extends string = never, E extends string = never, F extends string = never>(entity: T, options?: SerializeOptions<T, P, E, F>): SerializeDTO<T, P, E, never, ResolveSerializeFields<F>, SerializeFieldsKeepPK<F>>;
7
7
  private static propertyName;
8
8
  private static processProperty;
9
9
  private static processCustomType;
10
10
  private static extractChildOptions;
11
+ /**
12
+ * Extracts the nested `fields` whitelist for a child property. A bare parent name (`fields: ['books']`) or a
13
+ * wildcard removes the whitelist on the sub-tree (everything is included), so consumers don't have to repeat
14
+ * every field of the child. Otherwise dot-paths are stripped of the parent prefix and passed down.
15
+ */
16
+ private static extractChildFields;
11
17
  private static processEntity;
12
18
  private static processCollection;
13
19
  }
14
- export interface SerializeOptions<T, P extends string = never, E extends string = never> {
20
+ export interface SerializeOptions<T, P extends string = never, E extends string = never, F extends string = never> {
15
21
  /** Specify which relation should be serialized as populated and which as a FK. */
16
22
  populate?: readonly AutoPath<T, P, `${PopulatePath.ALL}`>[];
17
23
  /** Specify which properties should be omitted. */
18
24
  exclude?: readonly AutoPath<T, E>[];
25
+ /**
26
+ * Whitelist of properties to serialize, supports dot-paths (e.g. `['name', 'books.title']`). When set, only the
27
+ * listed properties end up in the output, including primary keys. A bare property name keeps its entire sub-tree;
28
+ * a dot-path additionally narrows the nested object to the listed sub-keys. `exclude` takes precedence on conflict.
29
+ */
30
+ fields?: readonly AutoPath<T, F, `${PopulatePath.ALL}`>[];
19
31
  /** Enforce unpopulated references to be returned as objects, e.g. `{ author: { id: 1 } }` instead of `{ author: 1 }`. */
20
32
  forceObject?: boolean;
21
33
  /** Ignore custom property serializers. */
@@ -41,4 +53,4 @@ export interface SerializeOptions<T, P extends string = never, E extends string
41
53
  * const dto2 = wrap(user).serialize({ exclude: ['id', 'email'], forceObject: true });
42
54
  * ```
43
55
  */
44
- export declare function serialize<Entity extends object, Naked extends FromEntityType<Entity> = FromEntityType<Entity>, Populate extends string = never, Exclude extends string = never, Config extends TypeConfig = never>(entity: Entity, options?: Config & SerializeOptions<UnboxArray<Entity>, Populate, Exclude>): Naked extends object[] ? SerializeDTO<ArrayElement<Naked>, Populate, Exclude, CleanTypeConfig<Config>>[] : SerializeDTO<Naked, Populate, Exclude, CleanTypeConfig<Config>, ExtractFieldsHint<Entity>>;
56
+ export declare function serialize<Entity extends object, Naked extends FromEntityType<Entity> = FromEntityType<Entity>, Populate extends string = never, Exclude extends string = never, Fields extends string = never, Config extends TypeConfig = never>(entity: Entity, options?: Config & SerializeOptions<UnboxArray<Entity>, Populate, Exclude, Fields>): Naked extends object[] ? SerializeDTO<ArrayElement<Naked>, Populate, Exclude, CleanTypeConfig<Config>, ResolveSerializeFields<Fields, ExtractFieldsHint<Entity>>, SerializeFieldsKeepPK<Fields>>[] : SerializeDTO<Naked, Populate, Exclude, CleanTypeConfig<Config>, ResolveSerializeFields<Fields, ExtractFieldsHint<Entity>>, SerializeFieldsKeepPK<Fields>>;
@@ -4,13 +4,19 @@ import { ReferenceKind } from '../enums.js';
4
4
  import { Reference } from '../entity/Reference.js';
5
5
  import { SerializationContext } from './SerializationContext.js';
6
6
  import { isRaw } from '../utils/RawQueryFragment.js';
7
+ /** Returns true when any entry in `items` is `propName`, the wildcard `*`, or a dot-path under `propName`. */
8
+ function matchesPath(items, propName) {
9
+ return items.some(item => item === propName || item === '*' || item.startsWith(propName + '.'));
10
+ }
7
11
  function isVisible(meta, propName, options) {
8
12
  const prop = meta.properties[propName];
9
13
  if (options.groups && prop?.groups) {
10
14
  return prop.groups.some(g => options.groups.includes(g));
11
15
  }
12
- if (Array.isArray(options.populate) &&
13
- options.populate?.find(item => item === propName || item.startsWith(propName + '.') || item === '*')) {
16
+ if (Array.isArray(options.fields) && options.fields.length > 0 && !matchesPath(options.fields, propName)) {
17
+ return false;
18
+ }
19
+ if (Array.isArray(options.populate) && matchesPath(options.populate, propName)) {
14
20
  return true;
15
21
  }
16
22
  if (options.exclude?.find(item => item === propName)) {
@@ -22,7 +28,8 @@ function isVisible(meta, propName, options) {
22
28
  }
23
29
  function isPopulated(propName, options) {
24
30
  if (typeof options.populate !== 'boolean' &&
25
- options.populate?.find(item => item === propName || item.startsWith(propName + '.') || item === '*')) {
31
+ Array.isArray(options.populate) &&
32
+ matchesPath(options.populate, propName)) {
26
33
  return true;
27
34
  }
28
35
  if (typeof options.populate === 'boolean') {
@@ -30,9 +37,9 @@ function isPopulated(propName, options) {
30
37
  }
31
38
  return false;
32
39
  }
33
- /** Converts entity instances to plain DTOs via `serialize()`, with fine-grained control over populate, exclude, and serialization groups. */
40
+ /** Converts entity instances to plain DTOs via `serialize()`, with fine-grained control over populate, exclude, fields, and serialization groups. */
34
41
  export class EntitySerializer {
35
- /** Serializes an entity to a plain DTO, with fine-grained control over population, exclusion, groups, and custom types. */
42
+ /** Serializes an entity to a plain DTO, with fine-grained control over population, exclusion, fields, groups, and custom types. */
36
43
  static serialize(entity, options = {}) {
37
44
  const wrapped = helper(entity);
38
45
  const meta = wrapped.__meta;
@@ -184,8 +191,27 @@ export class EntitySerializer {
184
191
  ? Utils.extractChildElements(options.populate, prop, '*')
185
192
  : options.populate,
186
193
  exclude: Array.isArray(options.exclude) ? Utils.extractChildElements(options.exclude, prop) : options.exclude,
194
+ fields: Array.isArray(options.fields) ? this.extractChildFields(options.fields, prop) : options.fields,
187
195
  };
188
196
  }
197
+ /**
198
+ * Extracts the nested `fields` whitelist for a child property. A bare parent name (`fields: ['books']`) or a
199
+ * wildcard removes the whitelist on the sub-tree (everything is included), so consumers don't have to repeat
200
+ * every field of the child. Otherwise dot-paths are stripped of the parent prefix and passed down.
201
+ */
202
+ static extractChildFields(fields, prop) {
203
+ const out = [];
204
+ const dotPrefix = prop + '.';
205
+ for (const field of fields) {
206
+ if (field === prop || field === '*') {
207
+ return undefined;
208
+ }
209
+ if (field.startsWith(dotPrefix)) {
210
+ out.push(field.substring(dotPrefix.length));
211
+ }
212
+ }
213
+ return out;
214
+ }
189
215
  static processEntity(prop, entity, platform, options) {
190
216
  const child = Reference.unwrapReference(entity[prop.name]);
191
217
  const wrapped = helper(child);