Skip to content

Commit

Permalink
fix(core): fix support for nested composite PKs
Browse files Browse the repository at this point in the history
Closes #2647
  • Loading branch information
B4nan committed Feb 2, 2022
1 parent 531fe79 commit 14dcff8
Show file tree
Hide file tree
Showing 7 changed files with 230 additions and 23 deletions.
4 changes: 2 additions & 2 deletions packages/core/src/entity/EntityFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,10 @@ export class EntityFactory {
createReference<T>(entityName: EntityName<T>, id: Primary<T> | Primary<T>[] | Record<string, Primary<T>>, options: Pick<FactoryOptions, 'merge' | 'convertCustomTypes' | 'schema'> = {}): T {
options.convertCustomTypes ??= true;
entityName = Utils.className(entityName);
const meta = this.metadata.get(entityName);
const meta = this.metadata.get<T>(entityName);

if (Array.isArray(id)) {
id = Utils.getPrimaryKeyCondFromArray(id, meta.primaryKeys);
id = Utils.getPrimaryKeyCondFromArray(id, meta);
}

const pks = Utils.getOrderedPrimaryKeys<T>(id, meta, this.platform, options.convertCustomTypes);
Expand Down
50 changes: 37 additions & 13 deletions packages/core/src/utils/EntityComparator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,21 +236,45 @@ export class EntityComparator {
const context = new Map<string, any>();
const propName = (name: string, parent = 'result') => parent + this.wrap(name);

// respects nested composite keys, e.g. `[1, [2, 3]]`
const createCompositeKeyArray = (prop: EntityProperty, fieldNames = prop.fieldNames, idx = 0): string => {
if (!prop.targetMeta) {
return propName(fieldNames[idx++]);
}

const parts: string[] = [];

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

if (parts.length > 1) {
return '[' + parts.join(', ') + ']';
}

return parts[0];
};

lines.push(` const mapped = {};`);
meta.props.forEach(prop => {
if (prop.fieldNames) {
if (prop.fieldNames.length > 1) {
lines.push(` if (${prop.fieldNames.map(field => `${propName(field)} != null`).join(' && ')}) {\n ret${this.wrap(prop.name)} = [${prop.fieldNames.map(field => `${propName(field)}`).join(', ')}];`);
lines.push(...prop.fieldNames.map(field => ` ${propName(field, 'mapped')} = true;`));
lines.push(` } else if (${prop.fieldNames.map(field => `${propName(field)} == null`).join(' && ')}) {\n ret${this.wrap(prop.name)} = null;`);
lines.push(...prop.fieldNames.map(field => ` ${propName(field, 'mapped')} = true;`), ' }');
} else {
if (prop.type === 'boolean') {
lines.push(` if (typeof ${propName(prop.fieldNames[0])} !== 'undefined') { ret${this.wrap(prop.name)} = ${propName(prop.fieldNames[0])} == null ? ${propName(prop.fieldNames[0])} : !!${propName(prop.fieldNames[0])}; mapped.${prop.fieldNames[0]} = true; }`);
} else {
lines.push(` if (typeof ${propName(prop.fieldNames[0])} !== 'undefined') { ret${this.wrap(prop.name)} = ${propName(prop.fieldNames[0])}; ${propName(prop.fieldNames[0], 'mapped')} = true; }`);
}
}
if (!prop.fieldNames) {
return;
}

if (prop.targetMeta && prop.fieldNames.length > 1) {
lines.push(` if (${prop.fieldNames.map(field => `${propName(field)} != null`).join(' && ')}) {`);
lines.push(` ret${this.wrap(prop.name)} = ${createCompositeKeyArray(prop)};`);
lines.push(...prop.fieldNames.map(field => ` ${propName(field, 'mapped')} = true;`));
lines.push(` } else if (${prop.fieldNames.map(field => `${propName(field)} == null`).join(' && ')}) {\n ret${this.wrap(prop.name)} = null;`);
lines.push(...prop.fieldNames.map(field => ` ${propName(field, 'mapped')} = true;`), ' }');
return;
}

if (prop.type === 'boolean') {
lines.push(` if (typeof ${propName(prop.fieldNames[0])} !== 'undefined') { ret${this.wrap(prop.name)} = ${propName(prop.fieldNames[0])} == null ? ${propName(prop.fieldNames[0])} : !!${propName(prop.fieldNames[0])}; mapped.${prop.fieldNames[0]} = true; }`);
} else {
lines.push(` if (typeof ${propName(prop.fieldNames[0])} !== 'undefined') { ret${this.wrap(prop.name)} = ${propName(prop.fieldNames[0])}; ${propName(prop.fieldNames[0], 'mapped')} = true; }`);
}
});
lines.push(` for (let k in result) { if (result.hasOwnProperty(k) && !mapped[k]) ret[k] = result[k]; }`);
Expand Down
11 changes: 8 additions & 3 deletions packages/core/src/utils/Utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,9 +475,14 @@ export class Utils {
return cond;
}

static getPrimaryKeyCondFromArray<T extends AnyEntity<T>>(pks: Primary<T>[], primaryKeys: string[]): Record<string, Primary<T>> {
return primaryKeys.reduce((o, pk, idx) => {
o[pk] = Utils.extractPK<T>(pks[idx]);
static getPrimaryKeyCondFromArray<T extends AnyEntity<T>>(pks: Primary<T>[], meta: EntityMetadata<T>): Record<string, Primary<T>> {
return meta.getPrimaryProps().reduce((o, pk, idx) => {
if (Array.isArray(pks[idx]) && pk.targetMeta) {
o[pk.name] = Utils.getPrimaryKeyCondFromArray(pks[idx] as unknown[], pk.targetMeta);
} else {
o[pk.name] = Utils.extractPK<T>(pks[idx]);
}

return o;
}, {} as any);
}
Expand Down
12 changes: 9 additions & 3 deletions packages/knex/src/query/CriteriaNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,13 @@ export class CriteriaNode implements ICriteriaNode {
const meta = parent && metadata.find(parent.entityName);

if (meta && key) {
Utils.splitPrimaryKeys(key).forEach(k => {
const pks = Utils.splitPrimaryKeys(key);

if (pks.length > 1) {
return;
}

pks.forEach(k => {
this.prop = meta.props.find(prop => prop.name === k || (prop.fieldNames || []).includes(k));

// do not validate if the key is prefixed or type casted (e.g. `k::text`)
Expand Down Expand Up @@ -66,14 +72,14 @@ export class CriteriaNode implements ICriteriaNode {
}

renameFieldToPK<T>(qb: IQueryBuilder<T>): string {
const alias = qb.getAliasForJoinPath(this.getPath());
const alias = qb.getAliasForJoinPath(this.getPath()) ?? qb.alias;

if (this.prop!.reference === ReferenceType.MANY_TO_MANY) {
return Utils.getPrimaryKeyHash(this.prop!.inverseJoinColumns.map(col => `${alias}.${col}`));
}

if (this.prop!.joinColumns.length > 1) {
return Utils.getPrimaryKeyHash(this.prop!.joinColumns);
return Utils.getPrimaryKeyHash(this.prop!.joinColumns.map(col => `${alias}.${col}`));
}

return Utils.getPrimaryKeyHash(this.prop!.referencedColumnNames.map(col => `${alias}.${col}`));
Expand Down
15 changes: 14 additions & 1 deletion packages/knex/src/query/QueryBuilderHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,20 @@ export class QueryBuilderHelper {
const fields = Utils.splitPrimaryKeys(field);

if (fields.length > 1) {
return this.knex.raw('(' + fields.map(f => this.knex.ref(this.mapper(f, type, value, alias))).join(', ') + ')');
const parts: string[] = [];

for (const p of fields) {
const [a, f] = this.splitField(p);
const prop = this.getProperty(f, a);

if (prop) {
parts.push(...prop.fieldNames.map(f => this.mapper(f, type, value, alias)));
} else {
parts.push(this.mapper(`${a}.${f}`, type, value, alias));
}
}

return this.knex.raw('(' + parts.map(part => this.knex.ref(part)).join(', ') + ')');
}

let ret = field;
Expand Down
2 changes: 1 addition & 1 deletion tests/QueryBuilder.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { inspect } from 'util';
import { expr, LockMode, MikroORM, QueryFlag, QueryOrder, UnderscoreNamingStrategy } from '@mikro-orm/core';
import type { PostgreSqlDriver } from '@mikro-orm/postgresql';
import { QueryBuilder } from '@mikro-orm/postgresql';
import { CriteriaNode } from '@mikro-orm/knex';
import { MySqlDriver } from '@mikro-orm/mysql';
import { Address2, Author2, Book2, BookTag2, Car2, CarOwner2, Configuration2, FooBar2, FooBaz2, FooParam2, Publisher2, PublisherType, Test2, User2 } from './entities-sql';
import { initORMMySql } from './bootstrap';
import { BaseEntity2 } from './entities-sql/BaseEntity2';
import { performance } from 'perf_hooks';
import { BaseEntity22 } from './entities-sql/BaseEntity22';
import { QueryBuilder } from '@mikro-orm/postgresql';

describe('QueryBuilder', () => {

Expand Down
159 changes: 159 additions & 0 deletions tests/issues/GH2647.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { Entity, ManyToOne, MikroORM, PrimaryKey } from '@mikro-orm/core';

@Entity()
export class A {

@PrimaryKey()
id: number;

constructor(id: number) {
this.id = id;
}

}

@Entity()
export class B {

@PrimaryKey()
id: number;

constructor(id: number) {
this.id = id;
}

}

@Entity()
export class C {

@PrimaryKey()
id: number;

constructor(id: number) {
this.id = id;
}

}

@Entity()
export class D {

@PrimaryKey()
id: number;

constructor(id: number) {
this.id = id;
}

}

@Entity()
export class AB {

@ManyToOne(() => A, { eager: true, primary: true })
a: A;

@ManyToOne(() => B, { eager: true, primary: true })
b: B;

constructor(a: A, b: B) {
this.a = a;
this.b = b;
}

}

@Entity()
export class CAB {

@ManyToOne(() => C, { eager: true, primary: true })
c: C;

@ManyToOne(() => AB, { eager: true, primary: true })
ab: AB;

constructor(c: C, ab: AB) {
this.c = c;
this.ab = ab;
}

}

@Entity()
export class DCAB {

@ManyToOne(() => D, { eager: true, primary: true })
d: D;

@ManyToOne(() => CAB, { eager: true, primary: true })
cab: CAB;

constructor(d: D, cab: CAB) {
this.d = d;
this.cab = cab;
}

}

describe('GH #2647', () => {

let orm: MikroORM;

beforeAll(async () => {
orm = await MikroORM.init({
entities: [A, B, C, D, AB, CAB, DCAB],
dbName: `:memory:`,
type: 'sqlite',
});
await orm.getSchemaGenerator().createSchema();
});

afterAll(async () => {
await orm.close(true);
});

beforeEach(async () => {
await orm.em.nativeDelete(DCAB, {});
await orm.em.nativeDelete(CAB, {});
await orm.em.nativeDelete(AB, {});
await orm.em.nativeDelete(D, {});
await orm.em.nativeDelete(C, {});
await orm.em.nativeDelete(B, {});
await orm.em.nativeDelete(A, {});
});

function createEntities(pks: [number, number, number, number]) {
const a = new A(pks[0]);
const b = new B(pks[1]);
const c = new C(pks[2]);
const d = new D(pks[3]);
const ab = new AB(a, b);
const cab = new CAB(c, ab);
const dcab = new DCAB(d, cab);
orm.em.persist([a, b, c, d, ab, cab, dcab]);

return { d, cab };
}

it('should be able to find entity with nested composite key', async () => {
const { d, cab } = createEntities([1, 2, 3, 4]);
await orm.em.flush();
orm.em.clear();

const res = await orm.em.find(DCAB, { d, cab });
expect(res).toHaveLength(1);
});

it('should be able to find entity with nested composite key (multi insert)', async () => {
createEntities([11, 12, 13, 14]);
createEntities([21, 22, 23, 24]);
createEntities([31, 32, 33, 34]);
await orm.em.flush();
orm.em.clear();

const res = await orm.em.find(DCAB, {});
expect(res).toHaveLength(3);
});

});

0 comments on commit 14dcff8

Please sign in to comment.