diff --git a/packages/core/src/EntityManager.ts b/packages/core/src/EntityManager.ts index 28235e8b8ec9..e89ad451988d 100644 --- a/packages/core/src/EntityManager.ts +++ b/packages/core/src/EntityManager.ts @@ -1422,6 +1422,38 @@ export class EntityManager { * parameter matches a property name, its value will be extracted from `data`. If no matching property exists, * the whole `data` parameter will be passed. This means we can also define `constructor(data: Partial)` and * `em.create()` will pass the data into it (unless we have a property named `data` too). + * + * The parameters are strictly checked, you need to provide all required properties. You can use `OptionalProps` + * symbol to omit some properties from this check without making them optional. Alternatively, use `partial: true` + * in the options to disable the strict checks for required properties. This option has no effect on runtime. + */ + create(entityName: EntityName, data: RequiredEntityData, options?: CreateOptions): Entity; + + /** + * Creates new instance of given entity and populates it with given data. + * The entity constructor will be used unless you provide `{ managed: true }` in the options parameter. + * The constructor will be given parameters based on the defined constructor of the entity. If the constructor + * parameter matches a property name, its value will be extracted from `data`. If no matching property exists, + * the whole `data` parameter will be passed. This means we can also define `constructor(data: Partial)` and + * `em.create()` will pass the data into it (unless we have a property named `data` too). + * + * The parameters are strictly checked, you need to provide all required properties. You can use `OptionalProps` + * symbol to omit some properties from this check without making them optional. Alternatively, use `partial: true` + * in the options to disable the strict checks for required properties. This option has no effect on runtime. + */ + create(entityName: EntityName, data: EntityData, options: CreateOptions & { partial: true }): Entity; + + /** + * Creates new instance of given entity and populates it with given data. + * The entity constructor will be used unless you provide `{ managed: true }` in the options parameter. + * The constructor will be given parameters based on the defined constructor of the entity. If the constructor + * parameter matches a property name, its value will be extracted from `data`. If no matching property exists, + * the whole `data` parameter will be passed. This means we can also define `constructor(data: Partial)` and + * `em.create()` will pass the data into it (unless we have a property named `data` too). + * + The parameters are strictly checked, you need to provide all required properties. You can use `OptionalProps` + symbol to omit some properties from this check without making them optional. Alternatively, use `partial: true` + in the options to disable the strict checks for required properties. This option has no effect on runtime. */ create(entityName: EntityName, data: RequiredEntityData, options: CreateOptions = {}): Entity { const em = this.getContext(); @@ -2105,9 +2137,14 @@ export class EntityManager { } export interface CreateOptions { + /** creates a managed entity instance instead, bypassing the constructor call */ managed?: boolean; + /** create entity in a specific schema - alternatively, use `wrap(entity).setSchema()` */ schema?: string; + /** persist the entity automatically - this is the default behavior and is also configurable globally via `persistOnCreate` option */ persist?: boolean; + /** this option disables the strict typing which requires all mandatory properties to have value, it has no effect on runtime */ + partial?: boolean; } export interface MergeOptions { diff --git a/packages/core/src/entity/EntityAssigner.ts b/packages/core/src/entity/EntityAssigner.ts index 57b2d800e2f0..9140afb9a80d 100644 --- a/packages/core/src/entity/EntityAssigner.ts +++ b/packages/core/src/entity/EntityAssigner.ts @@ -21,6 +21,7 @@ import { Reference } from './Reference'; import { ReferenceKind, SCALAR_TYPES } from '../enums'; import { EntityValidator } from './EntityValidator'; import { helper, wrap } from './wrap'; +import { EntityHelper } from './EntityHelper'; const validator = new EntityValidator(false); @@ -37,6 +38,7 @@ export class EntityAssigner { return entity as any; } + EntityHelper.ensurePropagation(entity); opts.visited ??= new Set(); opts.visited.add(entity); const wrapped = helper(entity); diff --git a/packages/core/src/entity/EntityHelper.ts b/packages/core/src/entity/EntityHelper.ts index 9905ba96ff30..bbe162092012 100644 --- a/packages/core/src/entity/EntityHelper.ts +++ b/packages/core/src/entity/EntityHelper.ts @@ -126,13 +126,13 @@ export class EntityHelper { }, set(val) { this.__helper.__data[prop.name] = val; - this.__helper.__touched = true; + this.__helper.__touched = !this.__helper.hydrator.isRunning(); }, enumerable: true, configurable: true, }); this.__helper.__data[prop.name] = val; - this.__helper.__touched = true; + this.__helper.__touched = !this.__helper.hydrator.isRunning(); }, configurable: true, }); @@ -185,7 +185,7 @@ export class EntityHelper { if (val && hydrator.isRunning() && wrapped.__originalEntityData && prop.owner) { wrapped.__originalEntityData[prop.name] = helper(wrapped.__data[prop.name]).getPrimaryKey(true); } else { - wrapped.__touched = true; + wrapped.__touched = !hydrator.isRunning(); } EntityHelper.propagate(meta, entity, this, prop, Reference.unwrapReference(val), old); diff --git a/packages/core/src/typings.ts b/packages/core/src/typings.ts index 0d5efbe9c8dc..561bc1e5c09e 100644 --- a/packages/core/src/typings.ts +++ b/packages/core/src/typings.ts @@ -548,7 +548,7 @@ export class EntityMetadata { if (val && hydrator.isRunning() && wrapped.__originalEntityData && prop.owner) { wrapped.__originalEntityData[prop.name as string] = val.__helper.getPrimaryKey(true); } else { - wrapped.__touched = true; + wrapped.__touched = !hydrator.isRunning(); } EntityHelper.propagate(meta, entity, this, prop, Reference.unwrapReference(val), old); @@ -572,7 +572,7 @@ export class EntityMetadata { } this.__helper.__data[prop.name] = val; - this.__helper.__touched = true; + this.__helper.__touched = !this.__helper.hydrator.isRunning(); }, enumerable: true, configurable: true, diff --git a/packages/knex/src/AbstractSqlDriver.ts b/packages/knex/src/AbstractSqlDriver.ts index 93b1d136f5bc..f1c205cab6f2 100644 --- a/packages/knex/src/AbstractSqlDriver.ts +++ b/packages/knex/src/AbstractSqlDriver.ts @@ -349,23 +349,26 @@ export abstract class AbstractSqlDriver this.platform.shouldHaveColumn(prop, hint.children as any || [])) - .forEach(prop => { - if (prop.fieldNames.length > 1) { // composite keys - const fk = prop.fieldNames.map(name => root![`${relationAlias}__${name}` as EntityKey]) as Primary[]; - prop.fieldNames.map(name => delete root![`${relationAlias}__${name}` as EntityKey]); - relationPojo[prop.name] = Utils.mapFlatCompositePrimaryKey(fk, prop) as EntityValue; - } else if (prop.runtimeType === 'Date') { - const alias = `${relationAlias}__${prop.fieldNames[0]}` as EntityKey; - relationPojo[prop.name] = (typeof root![alias] === 'string' ? new Date(root![alias] as string) : root![alias]) as EntityValue; - delete root![alias]; - } else { - const alias = `${relationAlias}__${prop.fieldNames[0]}` as EntityKey; - relationPojo[prop.name] = root![alias]; - delete root![alias]; - } - }); + const props = meta2.props.filter(prop => this.platform.shouldHaveColumn(prop, hint.children as any || [])); + + for (const prop1 of props) { + if (prop1.fieldNames.length > 1) { // composite keys + const fk = prop1.fieldNames.map(name => root![`${relationAlias}__${name}` as EntityKey]) as Primary[]; + relationPojo[prop1.name] = Utils.mapFlatCompositePrimaryKey(fk, prop1) as EntityValue; + } else if (prop1.runtimeType === 'Date') { + const alias = `${relationAlias}__${prop1.fieldNames[0]}` as EntityKey; + relationPojo[prop1.name] = (typeof root![alias] === 'string' ? new Date(root![alias] as string) : root![alias]) as EntityValue; + } else { + const alias = `${relationAlias}__${prop1.fieldNames[0]}` as EntityKey; + relationPojo[prop1.name] = root![alias]; + } + } + + // properties can be mapped to multiple places, e.g. when sharing a column in multiple FKs, + // so we need to delete them after everything is mapped from given level + for (const prop1 of props) { + prop1.fieldNames.map(name => delete root![`${relationAlias}__${name}` as EntityKey]); + } if ([ReferenceKind.MANY_TO_MANY, ReferenceKind.ONE_TO_MANY].includes(prop.kind)) { result[prop.name] ??= [] as EntityValue; diff --git a/tests/issues/GHx9.test.ts b/tests/issues/GHx9.test.ts new file mode 100644 index 000000000000..93048d73032f --- /dev/null +++ b/tests/issues/GHx9.test.ts @@ -0,0 +1,107 @@ +import { Entity, ManyToOne, OneToOne, Opt, PrimaryKey, Property, Ref, wrap } from '@mikro-orm/core'; +import { MikroORM } from '@mikro-orm/sqlite'; +import { v4 } from 'uuid'; + +@Entity() +class Organization { + + @PrimaryKey({ columnType: 'uuid' }) + id = v4(); + +} + +@Entity() +class Project { + + @PrimaryKey({ columnType: 'uuid' }) + id = v4(); + + @ManyToOne({ entity: () => Organization, ref: true, primary: true }) + organization!: Ref; + + @Property({ length: 255 }) + name!: string; + + @OneToOne({ + entity: () => ProjectUpdate, + mappedBy: 'project', + ref: true, + }) + projectUpdate!: Ref & Opt; + +} + +@Entity() +class ProjectUpdate { + + @PrimaryKey({ columnType: 'uuid' }) + id = v4(); + + @ManyToOne({ entity: () => Organization, ref: true, primary: true }) + organization!: Ref; + + @OneToOne({ + entity: () => Project, + ref: true, + joinColumns: ['project_id', 'organization_id'], + }) + project!: Ref; + +} + +let orm: MikroORM; +let org: Organization; +let project: Project; + +beforeAll(async () => { + orm = await MikroORM.init({ + dbName: ':memory:', + entities: [Project, Organization, ProjectUpdate], + }); + + await orm.schema.refreshDatabase(); + + org = new Organization(); + project = orm.em.create(Project, { + organization: org, + name: 'init', + }); + + orm.em.create(ProjectUpdate, { + organization: org.id, + project, + }); + + await orm.em.flush(); + orm.em.clear(); +}); + +afterAll(async () => { + await orm.close(); +}); + +test('extra updates with 1:1 relations (joined)', async () => { + const result = await orm.em.findOneOrFail(Project, { id: project.id, organization: org.id }, { + populate: ['projectUpdate'], + strategy: 'joined', + }); + + expect(wrap(result.projectUpdate.$).isTouched()).toBe(false); + expect(wrap(result).isTouched()).toBe(false); + + orm.em.getUnitOfWork().computeChangeSets(); + expect(orm.em.getUnitOfWork().getChangeSets()).toHaveLength(0); +}); + +test('extra updates with 1:1 relations (select-in)', async () => { + const result = await orm.em.findOneOrFail(Project, { id: project.id, organization: org.id }, { + populate: ['projectUpdate'], + strategy: 'select-in', + }); + + expect(wrap(result.projectUpdate.$).isTouched()).toBe(false); + expect(wrap(result).isTouched()).toBe(false); + + orm.em.getUnitOfWork().computeChangeSets(); + expect(orm.em.getUnitOfWork().getChangeSets()).toHaveLength(0); +});