Skip to content

Commit

Permalink
fix(core): fix extra updates for composite FKs that share a column
Browse files Browse the repository at this point in the history
  • Loading branch information
B4nan committed Dec 15, 2023
1 parent 7de7a48 commit 78772fb
Show file tree
Hide file tree
Showing 6 changed files with 187 additions and 17 deletions.
37 changes: 37 additions & 0 deletions packages/core/src/EntityManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1267,6 +1267,38 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
* 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<T>)` 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<Entity extends object>(entityName: EntityName<Entity>, data: RequiredEntityData<Entity>, 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<T>)` 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<Entity extends object>(entityName: EntityName<Entity>, data: EntityData<Entity>, 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<T>)` 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<Entity extends object>(entityName: EntityName<Entity>, data: RequiredEntityData<Entity>, options: CreateOptions = {}): Entity {
const em = this.getContext();
Expand Down Expand Up @@ -1902,9 +1934,14 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
}

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 {
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/entity/EntityHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,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();
},
});
});
Expand Down Expand Up @@ -156,7 +156,7 @@ export class EntityHelper {
if (val && hydrator.isRunning() && wrapped.__originalEntityData && prop.owner) {
wrapped.__originalEntityData[prop.name as string] = 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);
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/typings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,7 @@ export class EntityMetadata<T = any> {
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);
Expand All @@ -470,7 +470,7 @@ export class EntityMetadata<T = any> {
},
set(val: unknown) {
this.__helper.__data[prop.name] = val;
this.__helper.__touched = true;
this.__helper.__touched = !this.__helper.hydrator.isRunning();
},
enumerable: true,
configurable: true,
Expand Down
22 changes: 22 additions & 0 deletions packages/core/src/utils/Utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -945,6 +945,28 @@ export class Utils {
return false;
}

/**
* Maps nested FKs from `[1, 2, 3]` to `[1, [2, 3]]`.
*/
static mapFlatCompositePrimaryKey(fk: Primary<any>[], prop: EntityProperty, fieldNames = prop.fieldNames, idx = 0): Primary<any> | Primary<any>[] {
if (!prop.targetMeta) {
return fk[idx++];
}

const parts: Primary<any>[] = [];

for (const pk of prop.targetMeta.getPrimaryProps()) {
parts.push(this.mapFlatCompositePrimaryKey(fk, pk, fieldNames, idx));
idx += pk.fieldNames.length;
}

if (parts.length < 2) {
return parts[0];
}

return parts;
}

static getGlobalStorage(namespace: string): Dictionary {
const key = `mikro-orm-${namespace}`;
global[key] = global[key] || {};
Expand Down
28 changes: 16 additions & 12 deletions packages/knex/src/AbstractSqlDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,18 +269,22 @@ export abstract class AbstractSqlDriver<Connection extends AbstractSqlConnection
}
});

meta2.props
.filter(prop => this.platform.shouldHaveColumn(prop, p.children || []))
.forEach(prop => {
if (prop.fieldNames.length > 1) { // composite keys
relationPojo[prop.name] = prop.fieldNames.map(name => root![`${relationAlias}__${name}`]);
prop.fieldNames.map(name => delete root![`${relationAlias}__${name}`]);
} else {
const alias = `${relationAlias}__${prop.fieldNames[0]}`;
relationPojo[prop.name] = root![alias];
delete root![alias];
}
});
const props = meta2.props.filter(prop => this.platform.shouldHaveColumn(prop, p.children || []));

for (const prop of props) {
if (prop.fieldNames.length > 1) { // composite keys
relationPojo[prop.name] = prop.fieldNames.map(name => root![`${relationAlias}__${name}`]);
} else {
const alias = `${relationAlias}__${prop.fieldNames[0]}`;
relationPojo[prop.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 prop of props) {
prop.fieldNames.map(name => delete root![`${relationAlias}__${name}`]);
}

if ([ReferenceType.MANY_TO_MANY, ReferenceType.ONE_TO_MANY].includes(relation.reference)) {
result[relation.name] = result[relation.name] || [] as unknown as T[keyof T & string];
Expand Down
107 changes: 107 additions & 0 deletions tests/issues/GHx9.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { Entity, LoadStrategy, ManyToOne, OneToOne, 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<Organization>;

@Property({ length: 255 })
name!: string;

@OneToOne({
entity: () => ProjectUpdate,
mappedBy: 'project',
ref: true,
})
projectUpdate?: Ref<ProjectUpdate>;

}

@Entity()
class ProjectUpdate {

@PrimaryKey({ columnType: 'uuid' })
id = v4();

@ManyToOne({ entity: () => Organization, ref: true, primary: true })
organization!: Ref<Organization>;

@OneToOne({
entity: () => Project,
ref: true,
joinColumns: ['project_id', 'organization_id'],
})
project!: Ref<Project>;

}

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: LoadStrategy.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: LoadStrategy.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);
});

0 comments on commit 78772fb

Please sign in to comment.