Skip to content

Commit

Permalink
Merge 4bfde8c into 2d9356a
Browse files Browse the repository at this point in the history
  • Loading branch information
B4nan committed Mar 27, 2019
2 parents 2d9356a + 4bfde8c commit 5083e69
Show file tree
Hide file tree
Showing 18 changed files with 97 additions and 41 deletions.
49 changes: 45 additions & 4 deletions docs/cascading.md
@@ -1,7 +1,7 @@
---
---

# Cascading persist and remove
# Cascading persist, merge and remove

When persisting or removing entity, all your references are by default cascade persisted.
This means that by persisting any entity, ORM will automatically persist all of its
Expand All @@ -13,25 +13,33 @@ You can control this behaviour via `cascade` attribute of `@ManyToOne`, `@ManyTo
> New entities without primary key will be always persisted, regardless of `cascade` value.
```typescript
// cascade persist is default value
// cascade persist & merge is default value
@OneToMany({ entity: () => Book, fk: 'author' })
books = new Collection<Book>(this);

// same as previous definition
@OneToMany({ entity: () => Book, fk: 'author', cascade: [Cascade.PERSIST] })
@OneToMany({ entity: () => Book, fk: 'author', cascade: [Cascade.PERSIST, Cascade.MERGE] })
books = new Collection<Book>(this);

// only cascade remove
@OneToMany({ entity: () => Book, fk: 'author', cascade: [Cascade.REMOVE] })
books = new Collection<Book>(this);

// cascade persist and remove
// cascade persist and remove (but not merge)
@OneToMany({ entity: () => Book, fk: 'author', cascade: [Cascade.PERSIST, Cascade.REMOVE] })
books = new Collection<Book>(this);

// no cascade
@OneToMany({ entity: () => Book, fk: 'author', cascade: [] })
books = new Collection<Book>(this);

// cascade all (persist, merge and remove)
@OneToMany({ entity: () => Book, fk: 'author', cascade: [Cascade.ALL] })
books = new Collection<Book>(this);

// same as previous definition
@OneToMany({ entity: () => Book, fk: 'author', cascade: [Cascade.PERSIST, Cascade.MERGE, Cascade.REMOVE] })
books = new Collection<Book>(this);
```

## Cascade persist
Expand All @@ -49,6 +57,39 @@ await orm.em.persist(book); // all book tags and author will be persisted too
> When cascade persisting collections, keep in mind only fully initialized collections
> will be cascade persisted.
## Cascade merge

When you want to merge entity and all its associations, you can use `Cascade.MERGE`. This
comes handy when you want to clear identity map (e.g. when importing large number of entities),
but you also have to keep your parent entities managed (because otherwise they would be considered
as new entities and insert-persisted, which would fail with non-unique identifier).

In following example, without having `Author.favouriteBook` set to cascade merge, you would
get an error because it would be cascade-inserted with already taken ID.

```typescript
const a1 = new Author(...);
a1.favouriteBook = new Book('the best', ...);
await orm.em.persistAndFlush(a1); // cascade persists favourite book as well

for (let i = 1; i < 1000; i++) {
const book = new Book('...', a1);
orm.em.persistLater(book);

// persist every 100 records
if (i % 100 === 0) {
await orm.em.flush();
orm.em.clear(); // this makes both a1 and his favourite book detached
orm.em.merge(a1); // so we need to merge them to prevent cascade-inserts

// without cascade merge, you would need to manually merge all his associations
orm.em.merge(a1.favouriteBook); // not needed with Cascade.MERGE
}
}

await orm.em.flush();
```

## Cascade remove

Cascade remove works same way as cascade persist, just for removing entities. Following
Expand Down
31 changes: 17 additions & 14 deletions lib/EntityManager.ts
@@ -1,11 +1,11 @@
import { Configuration, RequestContext, Utils } from './utils';
import { EntityRepository, EntityAssigner, EntityFactory, EntityLoader, EntityValidator, ReferenceType } from './entity';
import { EntityAssigner, EntityFactory, EntityLoader, EntityRepository, EntityValidator, ReferenceType } from './entity';
import { UnitOfWork } from './unit-of-work';
import { FilterQuery, IDatabaseDriver } from './drivers/IDatabaseDriver';
import { FilterQuery, IDatabaseDriver } from './drivers';
import { EntityData, EntityName, IEntity, IEntityType, IPrimaryKey } from './decorators';
import { QueryBuilder, QueryOrder, SmartQueryHelper } from './query';
import { MetadataStorage } from './metadata';
import { Connection } from './connections/Connection';
import { Connection } from './connections';

export class EntityManager {

Expand Down Expand Up @@ -158,18 +158,21 @@ export class EntityManager {
return this.driver.aggregate(entityName, pipeline);
}

merge<T extends IEntityType<T>>(entityName: EntityName<T>, data: EntityData<T>): T {
entityName = Utils.className(entityName);
const meta = this.metadata[entityName];

if (!data || (!data[meta.primaryKey] && !data[meta.serializedPrimaryKey])) {
throw new Error('You cannot merge entity without identifier!');
merge<T extends IEntityType<T>>(entity: T): T;
merge<T extends IEntityType<T>>(entityName: EntityName<T>, data: EntityData<T>): T;
merge<T extends IEntityType<T>>(entityName: EntityName<T> | T, data?: EntityData<T>): T {
if (Utils.isEntity(entityName)) {
return this.merge(entityName.constructor.name, entityName as EntityData<T>);
}

const entity = Utils.isEntity<T>(data) ? data : this.getEntityFactory().create<T>(entityName, data, true);
this.getUnitOfWork().addToIdentityMap(entity); // add to IM immediately - needed for self-references that can be part of `data`
EntityAssigner.assign(entity, data, true);
this.getUnitOfWork().addToIdentityMap(entity); // add to IM again so we have correct payload saved to change set computation
entityName = Utils.className(entityName);
this.validator.validatePrimaryKey(data!, this.metadata[entityName]);
const entity = Utils.isEntity<T>(data) ? data : this.getEntityFactory().create<T>(entityName, data!, true);

// add to IM immediately - needed for self-references that can be part of `data` (and do not trigger cascade merge)
this.getUnitOfWork().merge(entity, [entity]);
EntityAssigner.assign(entity, data!, true);
this.getUnitOfWork().merge(entity); // add to IM again so we have correct payload saved to change set computation

return entity;
}
Expand All @@ -192,7 +195,7 @@ export class EntityManager {
}

const entity = this.getEntityFactory().createReference<T>(entityName, id);
this.getUnitOfWork().addToIdentityMap(entity);
this.getUnitOfWork().merge(entity);

return entity;
}
Expand Down
2 changes: 1 addition & 1 deletion lib/MikroORM.ts
@@ -1,5 +1,5 @@
import { EntityManager } from './EntityManager';
import { IDatabaseDriver } from './drivers/IDatabaseDriver';
import { IDatabaseDriver } from './drivers';
import { MetadataDiscovery } from './metadata';
import { Configuration, Logger, Options } from './utils';
import { EntityMetadata } from './decorators';
Expand Down
2 changes: 1 addition & 1 deletion lib/decorators/ManyToMany.ts
Expand Up @@ -14,7 +14,7 @@ export function ManyToMany(options: ManyToManyOptions): Function {
throw new Error(`'@ManyToMany({ entity: string | Function })' is required in '${target.constructor.name}.${propertyName}'`);
}

const property = { name: propertyName, reference: ReferenceType.MANY_TO_MANY, owner: !!options.inversedBy, cascade: [Cascade.PERSIST] };
const property = { name: propertyName, reference: ReferenceType.MANY_TO_MANY, owner: !!options.inversedBy, cascade: [Cascade.PERSIST, Cascade.MERGE] };
meta.properties[propertyName] = Object.assign(property, options) as EntityProperty;
};
}
Expand Down
2 changes: 1 addition & 1 deletion lib/decorators/ManyToOne.ts
Expand Up @@ -8,7 +8,7 @@ export function ManyToOne(options: ManyToOneOptions = {}): Function {
return function (target: IEntity, propertyName: string) {
const meta = MetadataStorage.getMetadata(target.constructor.name);
Utils.lookupPathFromDecorator(meta);
const property = { name: propertyName, reference: ReferenceType.MANY_TO_ONE, cascade: [Cascade.PERSIST] };
const property = { name: propertyName, reference: ReferenceType.MANY_TO_ONE, cascade: [Cascade.PERSIST, Cascade.MERGE] };
meta.properties[propertyName] = Object.assign(property, options) as EntityProperty;
};
}
Expand Down
2 changes: 1 addition & 1 deletion lib/decorators/OneToMany.ts
Expand Up @@ -14,7 +14,7 @@ export function OneToMany(options: OneToManyOptions): Function {
throw new Error(`'@OneToMany({ entity: string | Function })' is required in '${target.constructor.name}.${propertyName}'`);
}

const property = { name: propertyName, reference: ReferenceType.ONE_TO_MANY, cascade: [Cascade.PERSIST] };
const property = { name: propertyName, reference: ReferenceType.ONE_TO_MANY, cascade: [Cascade.PERSIST, Cascade.MERGE] };
meta.properties[propertyName] = Object.assign(property, options) as EntityProperty;
};
}
Expand Down
2 changes: 1 addition & 1 deletion lib/drivers/AbstractSqlDriver.ts
@@ -1,6 +1,6 @@
import { EntityData, IEntityType, IPrimaryKey } from '../decorators';
import { DatabaseDriver } from './DatabaseDriver';
import { Connection, QueryResult } from '../connections/Connection';
import { Connection, QueryResult } from '../connections';
import { ReferenceType } from '../entity';
import { FilterQuery } from './IDatabaseDriver';
import { QueryBuilder, QueryOrder } from '../query';
Expand Down
2 changes: 1 addition & 1 deletion lib/drivers/PostgreSqlDriver.ts
Expand Up @@ -3,7 +3,7 @@ import { AbstractSqlDriver } from './AbstractSqlDriver';
import { EntityData, IEntityType } from '../decorators';
import { QueryType } from '../query';
import { PostgreSqlPlatform } from '../platforms/PostgreSqlPlatform';
import { QueryResult } from '../connections/Connection';
import { QueryResult } from '../connections';

export class PostgreSqlDriver extends AbstractSqlDriver<PostgreSqlConnection> {

Expand Down
10 changes: 8 additions & 2 deletions lib/entity/EntityValidator.ts
@@ -1,5 +1,5 @@
import { SCALAR_TYPES } from './EntityFactory';
import { EntityMetadata, EntityProperty, IEntityType } from '../decorators';
import { EntityData, EntityMetadata, EntityProperty, IEntityType } from '../decorators';
import { Utils, ValidationError } from '../utils';
import { ReferenceType } from './enums';

Expand Down Expand Up @@ -66,7 +66,13 @@ export class EntityValidator {
}
}

private validateCollection<T>(entity: IEntityType<T>, prop: EntityProperty): void {
validatePrimaryKey<T extends IEntityType<T>>(entity: EntityData<T>, meta: EntityMetadata): void {
if (!entity || (!entity[meta.primaryKey] && !entity[meta.serializedPrimaryKey])) {
throw ValidationError.fromMergeWithoutPK(meta);
}
}

private validateCollection<T extends IEntityType<T>>(entity: T, prop: EntityProperty): void {
if (!entity[prop.name as keyof T]) {
throw ValidationError.fromCollectionNotInitialized(entity, prop);
}
Expand Down
2 changes: 2 additions & 0 deletions lib/entity/enums.ts
Expand Up @@ -7,5 +7,7 @@ export enum ReferenceType {

export enum Cascade {
PERSIST = 'persist',
MERGE = 'merge',
REMOVE = 'remove',
ALL = 'all',
}
2 changes: 1 addition & 1 deletion lib/metadata/JavaScriptMetadataProvider.ts
Expand Up @@ -45,7 +45,7 @@ export class JavaScriptMetadataProvider extends MetadataProvider {
}

if (prop.reference !== ReferenceType.SCALAR && typeof prop.cascade === 'undefined') {
prop.cascade = [Cascade.PERSIST];
prop.cascade = [Cascade.PERSIST, Cascade.MERGE];
}
}

Expand Down
2 changes: 1 addition & 1 deletion lib/metadata/MetadataDiscovery.ts
Expand Up @@ -219,7 +219,7 @@ export class MetadataDiscovery {
}

private definePivotProperty(prop: EntityProperty, name: string): EntityProperty {
const ret = { name, type: name, reference: ReferenceType.MANY_TO_ONE, cascade: [Cascade.PERSIST, Cascade.REMOVE] } as EntityProperty;
const ret = { name, type: name, reference: ReferenceType.MANY_TO_ONE, cascade: [Cascade.ALL] } as EntityProperty;

if (name === prop.type) {
const meta = this.metadata[name];
Expand Down
5 changes: 3 additions & 2 deletions lib/schema/SchemaGenerator.ts
Expand Up @@ -127,9 +127,10 @@ export class SchemaGenerator {
const meta2 = this.metadata[prop.type];
const pk2 = meta2.properties[meta2.primaryKey].fieldName;
let ret = `REFERENCES ${this.helper.quoteIdentifier(meta2.collection)} (${this.helper.quoteIdentifier(pk2)})`;
ret += ` ON DELETE ${prop.cascade.includes(Cascade.REMOVE) ? 'CASCADE' : 'SET NULL'}`;
const cascade = prop.cascade.includes(Cascade.REMOVE) || prop.cascade.includes(Cascade.ALL);
ret += ` ON DELETE ${cascade ? 'CASCADE' : 'SET NULL'}`;

if (prop.cascade.includes(Cascade.PERSIST)) {
if (prop.cascade.includes(Cascade.PERSIST) || prop.cascade.includes(Cascade.ALL)) {
ret += ' ON UPDATE CASCADE';
}

Expand Down
2 changes: 1 addition & 1 deletion lib/unit-of-work/ChangeSetPersister.ts
Expand Up @@ -3,7 +3,7 @@ import { EntityMetadata, EntityProperty, IEntityType } from '../decorators';
import { EntityIdentifier } from '../entity';
import { ChangeSet, ChangeSetType } from './ChangeSet';
import { IDatabaseDriver } from '..';
import { QueryResult } from '../connections/Connection';
import { QueryResult } from '../connections';

export class ChangeSetPersister {

Expand Down
15 changes: 7 additions & 8 deletions lib/unit-of-work/UnitOfWork.ts
Expand Up @@ -30,12 +30,10 @@ export class UnitOfWork {

constructor(private readonly em: EntityManager) { }

addToIdentityMap(entity: IEntity, initialized = true): void {
merge<T extends IEntityType<T>>(entity: T, visited: IEntity[] = []): void {
this.identityMap[`${entity.constructor.name}-${entity.__serializedPrimaryKey}`] = entity;

if (initialized) {
this.originalEntityData[entity.__uuid] = Utils.copy(entity);
}
this.originalEntityData[entity.__uuid] = Utils.copy(entity);
this.cascade(entity, Cascade.MERGE, visited);
}

getById<T extends IEntityType<T>>(entityName: string, id: IPrimaryKey): T {
Expand Down Expand Up @@ -179,7 +177,7 @@ export class UnitOfWork {
await this.changeSetPersister.persistToDatabase(changeSet);

if (changeSet.type !== ChangeSetType.DELETE) {
this.em.merge(changeSet.name, changeSet.entity);
this.em.merge(changeSet.entity);
}

await this.runHooks(`after${type}`, changeSet.entity);
Expand Down Expand Up @@ -234,7 +232,8 @@ export class UnitOfWork {
visited.push(entity);

switch (type) {
case Cascade.PERSIST: this.persist<T>(entity, visited); break;
case Cascade.PERSIST: this.persist(entity, visited); break;
case Cascade.MERGE: this.merge(entity, visited); break;
case Cascade.REMOVE: this.remove(entity, visited); break;
}

Expand All @@ -246,7 +245,7 @@ export class UnitOfWork {
}

private cascadeReference<T extends IEntityType<T>>(entity: T, prop: EntityProperty, type: Cascade, visited: IEntity[]): void {
if (!prop.cascade || !prop.cascade.includes(type)) {
if (!prop.cascade || !(prop.cascade.includes(type) || prop.cascade.includes(Cascade.ALL))) {
return;
}

Expand Down
4 changes: 4 additions & 0 deletions lib/utils/ValidationError.ts
Expand Up @@ -62,6 +62,10 @@ export class ValidationError extends Error {
return ValidationError.fromMessage(meta, prop, `needs to have one of 'owner', 'mappedBy' or 'inversedBy' attributes`);
}

static fromMergeWithoutPK(meta: EntityMetadata): void {
throw new Error(`You cannot merge entity '${meta.name}' without identifier!`);
}

private static fromMessage(meta: EntityMetadata, prop: EntityProperty, message: string): ValidationError {
return new ValidationError(`${meta.name}.${prop.name} ${message}`);
}
Expand Down
2 changes: 1 addition & 1 deletion tests/EntityManager.mongo.test.ts
Expand Up @@ -199,7 +199,7 @@ describe('EntityManagerMongo', () => {

test('should throw when trying to merge entity without id', async () => {
const author = new Author('test', 'test');
expect(() => orm.em.merge(Author, author)).toThrowError('You cannot merge entity without identifier!');
expect(() => orm.em.merge(author)).toThrowError(`You cannot merge entity 'Author' without identifier!`);
});

test('fork', async () => {
Expand Down
2 changes: 1 addition & 1 deletion tests/UnitOfWork.test.ts
Expand Up @@ -76,7 +76,7 @@ describe('UnitOfWork', () => {
test('changeSet is null for empty payload', async () => {
const author = new Author('test', 'test');
author.id = '00000001885f0a3cc37dc9f0';
uow.addToIdentityMap(author); // add entity to IM first
uow.merge(author); // add entity to IM first
const changeSet = await computer.computeChangeSet(author); // then try to persist it again
expect(changeSet).toBeNull();
expect(uow.getIdentityMap()).not.toEqual({});
Expand Down

0 comments on commit 5083e69

Please sign in to comment.