Skip to content

Commit

Permalink
feat(core): add property serializers
Browse files Browse the repository at this point in the history
This allows to control the name and value of a property when
we serialize the entity (either via `toObject()` or `toJSON()`).

```ts
@entity()
export class Book {

  @manytoone({ serializer: value => value.name, serializedName: 'authorName' })
  author: Author;

}

const author = new Author('God')
const book = new Book(author);
console.log(book.toJSON().authorName); // 'God'
```

Closes #809
  • Loading branch information
B4nan committed Sep 6, 2020
1 parent d33432a commit 3d94b93
Show file tree
Hide file tree
Showing 8 changed files with 72 additions and 11 deletions.
19 changes: 19 additions & 0 deletions docs/docs/serializing.md
Expand Up @@ -81,3 +81,22 @@ book.assign({ count: 123 });
console.log(book.toObject().count); // 123
console.log(book.toJSON().count); // 123
```

## Property Serializers

As an alternative to custom `toJSON()` method, we can also use property serializers.
They allow to specify a callback that will be used when serializing a property:

```typescript
@Entity()
export class Book {

@ManyToOne({ serializer: value => value.name, serializedName: 'authorName' })
author: Author;

}

const author = new Author('God')
const book = new Book(author);
console.log(book.toJSON().authorName); // 'God'
```
2 changes: 2 additions & 0 deletions packages/core/src/decorators/Property.ts
Expand Up @@ -55,6 +55,8 @@ export type PropertyOptions<T> = {
lazy?: boolean;
primary?: boolean;
serializedPrimaryKey?: boolean;
serializer?: (value: any) => any;
serializedName?: string;
comment?: string;
};

Expand Down
32 changes: 25 additions & 7 deletions packages/core/src/entity/EntityTransformer.ts
Expand Up @@ -4,6 +4,7 @@ import { Collection } from './Collection';
import { AnyEntity, EntityData, EntityMetadata, EntityProperty, IPrimaryKey } from '../typings';
import { Reference } from './Reference';
import { wrap } from './wrap';
import { Platform } from '../platforms';

export class EntityTransformer {

Expand All @@ -18,15 +19,17 @@ export class EntityTransformer {
.map(pk => {
let value: unknown;

if (Utils.isEntity(entity[pk], true)) {
if (meta.properties[pk].serializer) {
value = meta.properties[pk].serializer!(entity[pk]);
} else if (Utils.isEntity(entity[pk], true)) {
value = EntityTransformer.processEntity(pk, entity, ignoreFields, visited);
} else {
value = platform.normalizePrimaryKey(Utils.getPrimaryKeyValue<T>(entity, [pk]));
}

return [pk, value] as [string, string];
return [pk, value] as [string & keyof T, string];
})
.forEach(([pk, value]) => ret[platform.getSerializedPrimaryKeyField(pk) as keyof T] = value as unknown as T[keyof T]);
.forEach(([pk, value]) => ret[this.propertyName(meta, pk, platform)] = value as unknown as T[keyof T]);

if ((!wrapped.isInitialized() && Utils.isDefined(wrapped.__primaryKey, true)) || visited.includes(entity.__helper!.__uuid)) {
return ret;
Expand All @@ -39,17 +42,17 @@ export class EntityTransformer {
.filter(prop => this.isVisible(meta, prop as keyof T & string, ignoreFields))
.map(prop => [prop, EntityTransformer.processProperty<T>(prop as keyof T & string, entity, ignoreFields, visited)])
.filter(([, value]) => typeof value !== 'undefined')
.forEach(([prop, value]) => ret[prop as keyof T] = value as T[keyof T]);
.forEach(([prop, value]) => ret[this.propertyName(meta, prop as keyof T & string)] = value as T[keyof T]);

// decorated getters
Object.values<EntityProperty<T>>(meta.properties)
.filter(prop => prop.getter && !prop.hidden && typeof entity[prop.name] !== 'undefined')
.forEach(prop => ret[prop.name] = entity[prop.name]);
.forEach(prop => ret[this.propertyName(meta, prop.name)] = entity[prop.name]);

// decorated get methods
Object.values<EntityProperty<T>>(meta.properties)
.filter(prop => prop.getterName && !prop.hidden && entity[prop.getterName] as unknown instanceof Function)
.forEach(prop => ret[prop.name] = (entity[prop.getterName!] as unknown as () => void)());
.forEach(prop => ret[this.propertyName(meta, prop.name)] = (entity[prop.getterName!] as unknown as () => void)());

return ret;
}
Expand All @@ -59,12 +62,27 @@ export class EntityTransformer {
return visible && !meta.primaryKeys.includes(prop) && !prop.startsWith('_') && !ignoreFields.includes(prop);
}

private static propertyName<T extends AnyEntity<T>>(meta: EntityMetadata<T>, prop: keyof T & string, platform?: Platform): string {
if (meta.properties[prop].serializedName) {
return meta.properties[prop].serializedName!;
}

if (meta.properties[prop].primary && platform) {
return platform.getSerializedPrimaryKeyField(prop);
}

return prop;
}

private static processProperty<T extends AnyEntity<T>>(prop: keyof T & string, entity: T, ignoreFields: string[], visited: string[]): T[keyof T] | undefined {
const wrapped = entity.__helper!;
const property = wrapped.__meta.properties[prop];
const platform = wrapped.__internal.platform;

/* istanbul ignore next */
if (property?.serializer) {
return property.serializer(entity[prop]);
}

if (property?.customType) {
return property.customType.toJSON(entity[prop], platform);
}
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/typings.ts
Expand Up @@ -150,6 +150,8 @@ export interface EntityProperty<T extends AnyEntity<T> = any> {
inverseJoinColumns: string[];
referencedColumnNames: string[];
referencedTableName: string;
serializer?: (value: any) => any;
serializedName?: string;
comment?: string;
}

Expand Down
14 changes: 14 additions & 0 deletions tests/EntityManager.mongo.test.ts
Expand Up @@ -191,6 +191,20 @@ describe('EntityManagerMongo', () => {
expect(a!.baz!.book.title).toBe('FooBar vs FooBaz');
});

test('property serializer', async () => {
const bar = FooBar.create('fb');
bar.baz = FooBaz.create('fz');
await orm.em.persistAndFlush(bar);
orm.em.clear();

const a = await orm.em.findOne(FooBar, bar.id, ['baz']);
expect(wrap(a).toJSON()).toMatchObject({
name: 'fb',
fooBaz: 'FooBaz id: ' + bar.baz.id,
});
expect(wrap(a).toJSON().baz).toBeUndefined();
});

test(`persisting 1:1 from owning side with cycle`, async () => {
const bar = FooBar.create('fb');
const baz = FooBaz.create('fz');
Expand Down
8 changes: 7 additions & 1 deletion tests/EntityManager.postgre.test.ts
Expand Up @@ -4,7 +4,7 @@ import {
TableNotFoundException, NotNullConstraintViolationException, TableExistsException, SyntaxErrorException, NonUniqueFieldNameException, InvalidFieldNameException,
} from '@mikro-orm/core';
import { PostgreSqlDriver, PostgreSqlConnection } from '@mikro-orm/postgresql';
import { Address2, Author2, Book2, BookTag2, FooBar2, FooBaz2, Publisher2, PublisherType, PublisherType2, Test2 } from './entities-sql';
import { Address2, Author2, Book2, BookTag2, FooBar2, FooBaz2, Publisher2, PublisherType, PublisherType2, Test2, Label2 } from './entities-sql';
import { initORMPostgreSql, wipeDatabasePostgreSql } from './bootstrap';
import { performance } from 'perf_hooks';

Expand Down Expand Up @@ -1385,6 +1385,12 @@ describe('EntityManagerPostgre', () => {
await expect(orm.em.findOneOrFail(Author2, { identities: { $contains: ['4'] } })).rejects.toThrowError();
});

test(`toObject uses serializedName on PKs`, async () => {
const l = new Label2('l');
await orm.em.persistAndFlush(l);
expect(wrap(l).toObject()).toMatchObject({ id: 'uuid is ' + l.uuid, name: 'l' });
});

test('exceptions', async () => {
const driver = orm.em.getDriver();
await driver.nativeInsert(Author2.name, { name: 'author', email: 'email' });
Expand Down
4 changes: 2 additions & 2 deletions tests/entities-sql/Label2.ts
Expand Up @@ -4,8 +4,8 @@ import { v4 } from 'uuid';
@Entity()
export class Label2 {

@PrimaryKey({ type: 'uuid' })
uuid: string = v4();
@PrimaryKey({ type: 'uuid', serializedName: 'id', serializer: value => `uuid is ${value}` })
uuid = v4();

@Property()
name: string;
Expand Down
2 changes: 1 addition & 1 deletion tests/entities/FooBar.ts
Expand Up @@ -14,7 +14,7 @@ export default class FooBar {
@Property()
name!: string;

@OneToOne({ entity: () => FooBaz, eager: true, orphanRemoval: true })
@OneToOne({ entity: () => FooBaz, eager: true, orphanRemoval: true, serializedName: 'fooBaz', serializer: value => `FooBaz id: ${value.id}` })
baz!: FooBaz | null;

@OneToOne(() => FooBar)
Expand Down

0 comments on commit 3d94b93

Please sign in to comment.