Skip to content

Commit

Permalink
feat(core): rework deep assigning of entities and enable it by default (
Browse files Browse the repository at this point in the history
  • Loading branch information
B4nan committed Jun 28, 2021
1 parent 091e60d commit 8f455ad
Show file tree
Hide file tree
Showing 8 changed files with 337 additions and 31 deletions.
77 changes: 66 additions & 11 deletions docs/docs/entity-helper.md
Expand Up @@ -59,22 +59,77 @@ wrap(book).assign({ meta: { foo: 4 } });
console.log(book.meta); // { foo: 4 }
```

To get the same behavior as `mergeObjects` flag for m:1 and 1:1 references, enable the `updateNestedEntities` flag.
### Updating deep entity graph

Since v5, `assign` allows updating deep entity graph by default. To update existing
entity, we need to provide its PK in the `data`, as well as to **load that entity first
into current context**.

```typescript
import { wrap } from '@mikro-orm/core';
const book = await em.findOneOrFail(Book, 1, { populate: ['author'] });

// update existing book's author's name
wrap(book).assign({
author: {
id: book.author.id,
name: 'New name...',
},
});
```

If we want to always update the entity, even without the entity PK being present
in `data`, we can use `updateByPrimaryKey: false`:

```typescript
const book = await em.findOneOrFail(Book, 1, { populate: ['author'] });

// update existing book's author's name
wrap(book).assign({
author: {
name: 'New name...',
},
}, { updateByPrimaryKey: false });
```

Otherwise the entity data without PK are considered as new entity, and will trigger
insert query:

```typescript
const book = await em.findOneOrFail(Book, 1, { populate: ['author'] });

// creating new author for given book
wrap(book).assign({
author: {
name: 'New name...',
},
});
```

Same applies to the case when we do not load the child entity first into the context,
e.g. when we try to assign to a relation that was not populated. Even if we provide
its PK, it will be considered as new object and trigger an insert query.

```typescript
const book = await em.findOneOrFail(Book, 1); // author is not populated

// creating new author for given book
wrap(book).assign({
author: {
id: book.author.id,
name: 'New name...',
},
});
```

const authorEntity = new Author('Jon2 Snow', 'snow3@wall.st');
book.author = authorEntity;
When updating collections, we can either pass complete array of all items, or just
a single item - in such case, the new item will be appended to the existing items.

wrap(book).assign({ author: { name: 'Jon Snow2' } }, { updateNestedEntities: true });
console.log(book.author); // { ... name: 'Jon Snow2' ... }
console.log(book.author === authorEntity) // true
```ts
// resets the addresses collection to a single item
wrap(user).assign({ addresses: [new Address(...)] });

//this will have no influence as author is an entity
wrap(book).assign({ author: { name: 'Jon Snow2' } }, { mergeObjects: true });
console.log(book.author); // { ... name: 'Jon Snow2' ... }
console.log(book.author === authorEntity) // false
// adds new address to the collection
wrap(user).assign({ addresses: new Address(...) });
```

## `WrappedEntity` and `wrap()` helper
Expand Down
45 changes: 38 additions & 7 deletions packages/core/src/entity/EntityAssigner.ts
@@ -1,7 +1,7 @@
import { inspect } from 'util';
import { Collection } from './Collection';
import { EntityManager } from '../EntityManager';
import { AnyEntity, EntityData, EntityDTO, EntityMetadata, EntityProperty } from '../typings';
import { AnyEntity, EntityData, EntityDTO, EntityMetadata, EntityProperty, Primary } from '../typings';
import { Utils } from '../utils/Utils';
import { Reference } from './Reference';
import { ReferenceType, SCALAR_TYPES } from '../enums';
Expand All @@ -12,10 +12,12 @@ const validator = new EntityValidator(false);

export class EntityAssigner {

static assign<T extends AnyEntity<T>>(entity: T, data: EntityData<T> | Partial<EntityDTO<T>>, options?: AssignOptions): T;
static assign<T extends AnyEntity<T>>(entity: T, data: EntityData<T> | Partial<EntityDTO<T>>, onlyProperties?: boolean): T;
static assign<T extends AnyEntity<T>>(entity: T, data: EntityData<T> | Partial<EntityDTO<T>>, onlyProperties: AssignOptions | boolean = false): T {
const options = (typeof onlyProperties === 'boolean' ? { onlyProperties } : onlyProperties);
static assign<T extends AnyEntity<T>>(entity: T, data: EntityData<T> | Partial<EntityDTO<T>>, options: AssignOptions = {}): T {
options = {
updateNestedEntities: true,
updateByPrimaryKey: true,
...options,
};
const wrapped = entity.__helper!;
const meta = entity.__meta!;
const em = options.em || wrapped.__em;
Expand Down Expand Up @@ -45,9 +47,22 @@ export class EntityAssigner {

if ([ReferenceType.MANY_TO_ONE, ReferenceType.ONE_TO_ONE].includes(props[prop]?.reference) && Utils.isDefined(value, true) && EntityAssigner.validateEM(em)) {
// eslint-disable-next-line no-prototype-builtins
if (options.updateNestedEntities && entity.hasOwnProperty(prop) && (Utils.isEntity(entity[prop]) || Reference.isReference(entity[prop])) && Utils.isPlainObject(value)) {
if (options.updateNestedEntities && entity.hasOwnProperty(prop) && Utils.isEntity(entity[prop], true) && Utils.isPlainObject(value)) {
const unwrappedEntity = Reference.unwrapReference(entity[prop]);

if (options.updateByPrimaryKey) {
const pk = Utils.extractPK(value, props[prop].targetMeta);

if (pk) {
const ref = em.getReference(props[prop].type, pk as Primary<T>);
if (ref.__helper!.isInitialized()) {
return EntityAssigner.assign(ref, value, options);
}
}

return EntityAssigner.assignReference<T>(entity, value, props[prop], em!, options);
}

if (wrap(unwrappedEntity).isInitialized()) {
return EntityAssigner.assign(unwrappedEntity, value, options);
}
Expand Down Expand Up @@ -125,8 +140,23 @@ export class EntityAssigner {
private static assignCollection<T extends AnyEntity<T>, U extends AnyEntity<U> = AnyEntity>(entity: T, collection: Collection<U>, value: any[], prop: EntityProperty, em: EntityManager, options: AssignOptions): void {
const invalid: any[] = [];
const items = value.map((item: any, idx) => {
if (options.updateNestedEntities && options.updateByPrimaryKey && Utils.isPlainObject(item)) {
const pk = Utils.extractPK(item, prop.targetMeta);

if (pk) {
const ref = em.getReference(prop.type, pk as Primary<U>);

/* istanbul ignore else */
if (ref.__helper!.isInitialized()) {
return EntityAssigner.assign(ref, item as U, options);
}
}

return this.createCollectionItem<U>(item, em, prop, invalid, options);
}

/* istanbul ignore next */
if (options.updateNestedEntities && collection[idx]?.__helper!.isInitialized()) {
if (options.updateNestedEntities && !options.updateByPrimaryKey && collection[idx]?.__helper!.isInitialized()) {
return EntityAssigner.assign(collection[idx], item, options);
}

Expand Down Expand Up @@ -203,6 +233,7 @@ export const assign = EntityAssigner.assign;

export interface AssignOptions {
updateNestedEntities?: boolean;
updateByPrimaryKey?: boolean;
onlyProperties?: boolean;
convertCustomTypes?: boolean;
mergeObjects?: boolean;
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/typings.ts
Expand Up @@ -87,7 +87,7 @@ export interface IWrappedEntity<T extends AnyEntity<T>, PK extends keyof T | unk
toObject(ignoreFields?: string[]): EntityDTO<T>;
toJSON(...args: any[]): EntityDTO<T>;
toPOJO(): EntityDTO<T>;
assign(data: any, options?: AssignOptions | boolean): T;
assign(data: EntityData<T> | Partial<EntityDTO<T>>, options?: AssignOptions | boolean): T;
}

export interface IWrappedEntityInternal<T, PK extends keyof T | unknown = PrimaryProperty<T>, P = keyof T> extends IWrappedEntity<T, PK, P> {
Expand Down Expand Up @@ -136,7 +136,9 @@ export type EntityDataProp<T> = T extends Scalar
? EntityDataNested<U>
: T extends Collection<infer U>
? U | U[] | EntityDataNested<U> | EntityDataNested<U>[]
: EntityDataNested<T>;
: T extends readonly (infer U)[]
? U | U[] | EntityDataNested<U> | EntityDataNested<U>[]
: EntityDataNested<T>;

export type EntityDataNested<T> = T extends undefined
? never
Expand Down
10 changes: 9 additions & 1 deletion tests/bootstrap.ts
@@ -1,5 +1,5 @@
import 'reflect-metadata';
import { EntityManager, JavaScriptMetadataProvider, LoadStrategy, MikroORM, Options, Utils } from '@mikro-orm/core';
import { EntityManager, JavaScriptMetadataProvider, LoadStrategy, Logger, LoggerNamespace, MikroORM, Options, Utils } from '@mikro-orm/core';
import { AbstractSqlDriver, SchemaGenerator, SqlEntityManager, SqlEntityRepository } from '@mikro-orm/knex';
import { SqliteDriver } from '@mikro-orm/sqlite';
import { MongoDriver } from '@mikro-orm/mongodb';
Expand Down Expand Up @@ -243,3 +243,11 @@ export async function wipeDatabaseSqlite2(em: SqlEntityManager) {
await em.execute('pragma foreign_keys = on');
em.clear();
}

export function mockLogger(orm: MikroORM, debugMode: LoggerNamespace[] = ['query', 'query-params']) {
const mock = jest.fn();
const logger = new Logger(mock, debugMode);
Object.assign(orm.config, { logger });

return mock;
}
Expand Up @@ -298,7 +298,7 @@ describe('embedded entities in postgresql', () => {
const user = new User();
wrap(user).assign({
address1: { street: 'Downing street 10', number: 3, postalCode: '123', city: 'London 1', country: 'UK 1' },
address2: { street: 'Downing street 11', number: 3, city: 'London 2', country: 'UK 2' },
address2: { street: 'Downing street 11', city: 'London 2', country: 'UK 2' },
address3: { street: 'Downing street 12', number: 3, postalCode: '789', city: 'London 3', country: 'UK 3' },
address4: { street: 'Downing street 10', number: 3, postalCode: '123', city: 'London 1', country: 'UK 1' },
}, { em: orm.em });
Expand Down
@@ -1,7 +1,7 @@
import { assign, EntityData, expr, MikroORM, wrap } from '@mikro-orm/core';
import { MongoDriver, ObjectId } from '@mikro-orm/mongodb';
import { Author, Book, BookTag } from './entities';
import { initORMMongo, wipeDatabase } from './bootstrap';
import { Author, Book, BookTag } from '../../entities';
import { initORMMongo, wipeDatabase } from '../../bootstrap';

describe('EntityAssignerMongo', () => {

Expand Down Expand Up @@ -71,9 +71,9 @@ describe('EntityAssignerMongo', () => {

test('#assign() should merge collection items', async () => {
const jon = new Author('Jon Snow', 'snow@wall.st');
orm.em.assign(jon, { books: [{ _id: ObjectId.createFromTime(1), title: 'b1' }] }, { merge: false });
orm.em.assign(jon, { books: [{ _id: ObjectId.createFromTime(1), title: 'b1' }] }, { merge: false, updateNestedEntities: false });
expect(wrap(jon.books[0], true).__em).toBeUndefined();
orm.em.assign(jon, { books: [{ _id: ObjectId.createFromTime(2), title: 'b2' }] }, { merge: true });
orm.em.assign(jon, { books: [{ _id: ObjectId.createFromTime(2), title: 'b2' }] }, { merge: true, updateNestedEntities: false });
expect(wrap(jon.books[0], true).__em).not.toBeUndefined();
});

Expand Down
@@ -1,7 +1,7 @@
import { EntityData, MikroORM, Reference, wrap } from '@mikro-orm/core';
import { MikroORM, Reference, wrap } from '@mikro-orm/core';
import { MySqlDriver } from '@mikro-orm/mysql';
import { initORMMySql, wipeDatabaseMySql } from './bootstrap';
import { Author2, Book2, BookTag2, FooBar2, Publisher2, PublisherType } from './entities-sql';
import { initORMMySql, wipeDatabaseMySql } from '../../bootstrap';
import { Author2, Book2, BookTag2, FooBar2, Publisher2, PublisherType } from '../../entities-sql';

describe('EntityAssignerMySql', () => {

Expand All @@ -17,6 +17,7 @@ describe('EntityAssignerMySql', () => {
await orm.em.persistAndFlush(book);
expect(book.title).toBe('Book2');
expect(book.author).toBe(jon);
// @ts-expect-error
wrap(book).assign({ title: 'Better Book2 1', author: god, notExisting: true });
expect(book.author).toBe(god);
expect((book as any).notExisting).toBe(true);
Expand All @@ -30,22 +31,27 @@ describe('EntityAssignerMySql', () => {

test('assign() should fix property types [mysql]', async () => {
const god = new Author2('God', 'hello@heaven.god');
// @ts-expect-error
wrap(god).assign({ createdAt: '2018-01-01', termsAccepted: 1 });
expect(god.createdAt).toEqual(new Date('2018-01-01'));
expect(god.termsAccepted).toBe(true);

const d1 = +new Date('2018-01-01');
// @ts-expect-error
wrap(god).assign({ createdAt: '' + d1, termsAccepted: 0 });
expect(god.createdAt).toEqual(new Date('2018-01-01'));
expect(god.termsAccepted).toBe(false);

// @ts-expect-error
wrap(god).assign({ createdAt: d1, termsAccepted: 0 });
expect(god.createdAt).toEqual(new Date('2018-01-01'));

const d2 = +new Date('2018-01-01 00:00:00.123');
// @ts-expect-error
wrap(god).assign({ createdAt: '' + d2 });
expect(god.createdAt).toEqual(new Date('2018-01-01 00:00:00.123'));

// @ts-expect-error
wrap(god).assign({ createdAt: d2 });
expect(god.createdAt).toEqual(new Date('2018-01-01 00:00:00.123'));
});
Expand Down Expand Up @@ -88,7 +94,7 @@ describe('EntityAssignerMySql', () => {
expect(book1.author.email).toBeUndefined();
expect(book1.author).not.toEqual(jon);

wrap(book2).assign({ author: { name: 'Jon Snow2' } }, { updateNestedEntities: true });
wrap(book2).assign({ author: { name: 'Jon Snow2' } }, { updateByPrimaryKey: false });
expect(book2.author.name).toEqual('Jon Snow2');
expect(book2.author.email).toEqual('snow3@wall.st');
expect(book2.author).toEqual(jon2);
Expand Down Expand Up @@ -147,7 +153,7 @@ describe('EntityAssignerMySql', () => {
const originalRef = book2.publisher!;
expect(originalValue.name).toEqual('Good Books LLC');

wrap(book2).assign({ author: { name: 'Jon Snow2' }, publisher: { name: 'Better Books LLC' } }, { updateNestedEntities: true });
wrap(book2).assign({ author: { name: 'Jon Snow2' }, publisher: { name: 'Better Books LLC' } }, { updateByPrimaryKey: false });

// this means that the original object has been replaced, something updateNestedEntities does not do
expect(book2.publisher).toEqual(originalRef);
Expand Down

0 comments on commit 8f455ad

Please sign in to comment.