Skip to content

Commit

Permalink
feat(core): add support for serialization groups (#5416)
Browse files Browse the repository at this point in the history
Every property can specify its serialization groups, which are then used
with explicit serialization.

> Properties without the `groups` option are always included.

Let's consider the following entity:

```ts
@entity()
class User {

  @PrimaryKey()
  id!: number;

  @Property()
  username!: string;

  @Property({ groups: ['public', 'private'] })
  name!: string;

  @Property({ groups: ['private'] })
  email!: string;

}
```

Now when you call `serialize()`:
- without the `groups` option, you get all the properties
- with `groups: ['public']` you get `id`, `username` and `name`
properties
- with `groups: ['private']` you get `id`, `username`, `name` and
`email` properties
- with `groups: []` you get only the `id` and `username` properties
(those without groups)

```ts
const dto1 = serialize(user);
// User { id: 1, username: 'foo', name: 'Jon', email: 'jon@example.com' }

const dto2 = serialize(user, { groups: ['public'] });
// User { id: 1, username: 'foo', name: 'Jon' }
```
  • Loading branch information
B4nan committed Apr 4, 2024
1 parent 779fa15 commit 818c290
Show file tree
Hide file tree
Showing 8 changed files with 151 additions and 39 deletions.
39 changes: 20 additions & 19 deletions docs/docs/decorators.md
Expand Up @@ -35,25 +35,26 @@ export class Author { ... }

`@Property()` decorator is used to define regular entity property. All following decorators extend the `@Property()` decorator, so you can also use its parameters there.

| Parameter | Type | Optional | Description |
|--------------------|---------------------------------------------------|----------|-----------------------------------------------------------------------------------------------------------------------------------------------------|
| `fieldName` | `string` | yes | Override default property name (see [Naming Strategy](./naming-strategy.md)). |
| `type` | `string` &#124; `Constructor<Type>` &#124; `Type` | yes | Explicitly specify the runtime type (see [Metadata Providers](./metadata-providers.md) and [Custom Types](./custom-types.md)). |
| `returning` | `boolean` | yes | Whether this property should be part of `returning` clause. Works only in PostgreSQL and SQLite drivers. |
| `onUpdate` | `() => any` | yes | Automatically update the property value every time entity gets updated. |
| `persist` | `boolean` | yes | Set to `false` to define [Shadow Property](serializing.md#shadow-properties). |
| `hydrate` | `boolean` | yes | Set to `false` to disable hydration of this property. Useful for persisted getters. |
| `hidden` | `boolean` | yes | Set to `true` to omit the property when [Serializing](serializing.md). |
| `columnType` | `string` | yes | Specify exact database column type for [Schema Generator](schema-generator.md). **(SQL only)** |
| `length` | `number` | yes | Length/precision of database column, used for `datetime/timestamp/varchar` column types for [Schema Generator](schema-generator.md). **(SQL only)** |
| `default` | `any` | yes | Specify default column value for [Schema Generator](schema-generator.md). **(SQL only)** |
| `unique` | `boolean` | yes | Set column as unique for [Schema Generator](schema-generator.md). **(SQL only)** |
| `nullable` | `boolean` | yes | Set column as nullable for [Schema Generator](schema-generator.md). **(SQL only)** |
| `unsigned` | `boolean` | yes | Set column as unsigned for [Schema Generator](schema-generator.md). **(SQL only)** |
| `comment` | `string` | yes | Specify comment of column for [Schema Generator](schema-generator.md). **(SQL only)** |
| `version` | `boolean` | yes | Set to true to enable [Optimistic Locking](transactions.md#optimistic-locking) via version field. **(SQL only)** |
| `concurrencyCheck` | `boolean` | yes | Set to true to enable [Concurrency Check](transactions.md#concurrency-checks) via concurrency fields. |
| `customOrder` | `string[]` &#124; `number[]` &#124; `boolean[]` | yes | Specify a custom order for the column. **(SQL only)** |
| Parameter | Type | Optional | Description |
|--------------------|---------------------------------------------------|----------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `fieldName` | `string` | yes | Override default property name (see [Naming Strategy](./naming-strategy.md)). |
| `type` | `string` &#124; `Constructor<Type>` &#124; `Type` | yes | Explicitly specify the runtime type (see [Metadata Providers](./metadata-providers.md) and [Custom Types](./custom-types.md)). |
| `returning` | `boolean` | yes | Whether this property should be part of `returning` clause. Works only in PostgreSQL and SQLite drivers. |
| `onUpdate` | `() => any` | yes | Automatically update the property value every time entity gets updated. |
| `persist` | `boolean` | yes | Set to `false` to define [Shadow Property](serializing.md#shadow-properties). |
| `hydrate` | `boolean` | yes | Set to `false` to disable hydration of this property. Useful for persisted getters. |
| `hidden` | `boolean` | yes | Set to `true` to omit the property when [Serializing](serializing.md). |
| `groups` | `string[]` | yes | Specify serialization groups for [explicit serialization](serializing.md#explicit-serialization). If a property does not specify any group, it will be included, otherwise only properties with a matching group are included. |
| `columnType` | `string` | yes | Specify exact database column type for [Schema Generator](schema-generator.md). **(SQL only)** |
| `length` | `number` | yes | Length/precision of database column, used for `datetime/timestamp/varchar` column types for [Schema Generator](schema-generator.md). **(SQL only)** |
| `default` | `any` | yes | Specify default column value for [Schema Generator](schema-generator.md). **(SQL only)** |
| `unique` | `boolean` | yes | Set column as unique for [Schema Generator](schema-generator.md). **(SQL only)** |
| `nullable` | `boolean` | yes | Set column as nullable for [Schema Generator](schema-generator.md). **(SQL only)** |
| `unsigned` | `boolean` | yes | Set column as unsigned for [Schema Generator](schema-generator.md). **(SQL only)** |
| `comment` | `string` | yes | Specify comment of column for [Schema Generator](schema-generator.md). **(SQL only)** |
| `version` | `boolean` | yes | Set to true to enable [Optimistic Locking](transactions.md#optimistic-locking) via version field. **(SQL only)** |
| `concurrencyCheck` | `boolean` | yes | Set to true to enable [Concurrency Check](transactions.md#concurrency-checks) via concurrency fields. |
| `customOrder` | `string[]` &#124; `number[]` &#124; `boolean[]` | yes | Specify a custom order for the column. **(SQL only)** |

> You can use property initializers as usual.
Expand Down
44 changes: 44 additions & 0 deletions docs/docs/serializing.md
Expand Up @@ -221,6 +221,9 @@ interface SerializeOptions<T extends object, P extends string = never, E extends

/** Skip properties with `null` value. */
skipNull?: boolean;

/** Only include properties for a specific group. If a property does not specify any group, it will be included, otherwise only properties with a matching group are included. */
groups?: string[];
}
```

Expand All @@ -238,3 +241,44 @@ const dto = wrap(author).serialize({
```

If you try to populate a relation that is not initialized, it will have same effect as the `forceObject` option - the value will be represented as object with just the primary key available.

### Serialization groups

Every property can specify its serialization groups, which are then used with explicit serialization.

> Properties without the `groups` option are always included.
Let's consider the following entity:

```ts
@Entity()
class User {

@PrimaryKey()
id!: number;

@Property()
username!: string;

@Property({ groups: ['public', 'private'] })
name!: string;

@Property({ groups: ['private'] })
email!: string;

}
```

Now when you call `serialize()`:
- without the `groups` option, you get all the properties
- with `groups: ['public']` you get `id`, `username` and `name` properties
- with `groups: ['private']` you get `id`, `username`, `name` and `email` properties
- with `groups: []` you get only the `id` and `username` properties (those without groups)

```ts
const dto1 = serialize(user);
// User { id: 1, username: 'foo', name: 'Jon', email: 'jon@example.com' }

const dto2 = serialize(user, { groups: ['public'] });
// User { id: 1, username: 'foo', name: 'Jon' }
```
1 change: 1 addition & 0 deletions packages/core/src/decorators/Embedded.ts
Expand Up @@ -29,4 +29,5 @@ export type EmbeddedOptions = {
hidden?: boolean;
serializer?: (value: any) => any;
serializedName?: string;
groups?: string[];
};
5 changes: 5 additions & 0 deletions packages/core/src/decorators/Property.ts
Expand Up @@ -234,6 +234,11 @@ export type PropertyOptions<Owner> = {
* Specify name of key for the serialized value.
*/
serializedName?: string;
/**
* Specify serialization groups for `serialize()` calls. If a property does not specify any group, it will be included,
* otherwise only properties with a matching group are included.
*/
groups?: string[];
/**
* Specify a custom order based on the values. (SQL only)
*/
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/serialization/EntitySerializer.ts
Expand Up @@ -24,6 +24,12 @@ import { SerializationContext } from './SerializationContext';
import { RawQueryFragment } from '../utils/RawQueryFragment';

function isVisible<T extends object>(meta: EntityMetadata<T>, propName: EntityKey<T>, options: SerializeOptions<T, any, any>): boolean {
const prop = meta.properties[propName];

if (options.groups && prop?.groups) {
return prop.groups.some(g => options.groups!.includes(g));
}

if (Array.isArray(options.populate) && options.populate?.find(item => item === propName || item.startsWith(propName + '.') || item === '*')) {
return true;
}
Expand All @@ -32,7 +38,6 @@ function isVisible<T extends object>(meta: EntityMetadata<T>, propName: EntityKe
return false;
}

const prop = meta.properties[propName];
const visible = prop && !prop.hidden;
const prefixed = prop && !prop.primary && propName.startsWith('_'); // ignore prefixed properties, if it's not a PK

Expand Down Expand Up @@ -274,6 +279,9 @@ export interface SerializeOptions<T, P extends string = never, E extends string

/** Skip properties with `null` value. */
skipNull?: boolean;

/** Only include properties for a specific group. If a property does not specify any group, it will be included, otherwise only properties with a matching group are included. */
groups?: string[];
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/typings.ts
Expand Up @@ -422,6 +422,7 @@ export interface EntityProperty<Owner = any, Target = any> {
returning?: boolean;
primary?: boolean;
serializedPrimaryKey: boolean;
groups?: string[];
lazy?: boolean;
array?: boolean;
length?: number;
Expand Down
38 changes: 19 additions & 19 deletions tests/entities-sql/Author2.ts
Expand Up @@ -75,54 +75,54 @@ export class Author2 extends BaseEntity2 {
@Property()
name: string;

@Property({ unique: 'custom_email_unique_name' })
@Property({ unique: 'custom_email_unique_name', groups: ['personal', 'admin'] })
@Index({ name: 'custom_email_index_name' })
email: string;

@Property({ nullable: true, default: null })
@Property({ nullable: true, default: null, groups: ['personal', 'admin'] })
age?: number;

@Index()
@Property()
@Property({ groups: ['admin'] })
termsAccepted: Opt<boolean> = false;

@Property({ nullable: true })
@Property({ nullable: true, groups: ['personal'] })
optional?: boolean;

@Property({ nullable: true })
@Property({ nullable: true, groups: ['admin'] })
identities?: string[];

@Property({ type: 'date', index: true, nullable: true })
@Property({ type: 'date', index: true, nullable: true, groups: ['personal', 'admin'] })
born?: string;

@Property({ type: t.time, index: 'born_time_idx', nullable: true })
@Property({ type: t.time, index: 'born_time_idx', nullable: true, groups: ['personal', 'admin'] })
bornTime?: string;

@OneToMany({ entity: () => Book2, mappedBy: 'author', orderBy: { title: QueryOrder.ASC } })
@OneToMany({ entity: () => Book2, mappedBy: 'author', orderBy: { title: QueryOrder.ASC }, groups: ['personal'] })
books = new Collection<Book2>(this);

@OneToMany({ entity: () => Book2, mappedBy: 'author', strategy: LoadStrategy.JOINED, orderBy: { title: QueryOrder.ASC } })
@OneToMany({ entity: () => Book2, mappedBy: 'author', strategy: LoadStrategy.JOINED, orderBy: { title: QueryOrder.ASC }, groups: ['personal'] })
books2 = new Collection<Book2>(this);

@OneToOne({ entity: () => Address2, mappedBy: address => address.author, cascade: [Cascade.ALL] })
@OneToOne({ entity: () => Address2, mappedBy: address => address.author, cascade: [Cascade.ALL], groups: ['personal', 'admin'] })
address?: Address2;

@ManyToMany({ entity: () => Author2, pivotTable: 'author_to_friend' })
@ManyToMany({ entity: () => Author2, pivotTable: 'author_to_friend', groups: ['personal'] })
friends = new Collection<Author2>(this);

@ManyToMany(() => Author2)
@ManyToMany(() => Author2, undefined, { groups: ['personal'] })
following = new Collection<Author2>(this);

@ManyToMany(() => Author2, a => a.following)
@ManyToMany(() => Author2, a => a.following, { groups: ['personal'] })
followers = new Collection<Author2>(this);

@ManyToOne({ nullable: true, updateRule: 'no action', deleteRule: 'cascade' })
@ManyToOne({ nullable: true, updateRule: 'no action', deleteRule: 'cascade', groups: ['personal'] })
favouriteBook?: Book2;

@ManyToOne(() => Author2, { nullable: true })
@ManyToOne(() => Author2, { nullable: true, groups: ['personal'] })
favouriteAuthor?: Author2 | null;

@Embedded(() => Identity, { nullable: true, object: true })
@Embedded(() => Identity, { nullable: true, object: true, groups: ['personal', 'admin'] })
identity?: Identity;

@Property({ persist: false })
Expand All @@ -131,7 +131,7 @@ export class Author2 extends BaseEntity2 {
@Property({ persist: false })
versionAsString!: string & Opt;

@Property({ persist: false })
@Property({ persist: false, groups: ['admin'] })
code!: string & Opt;

@Property({ persist: false })
Expand Down Expand Up @@ -191,12 +191,12 @@ export class Author2 extends BaseEntity2 {
Author2.afterDestroyCalled += 1;
}

@Property({ name: 'code' })
@Property({ name: 'code', groups: ['admin'] })
getCode(): string & Opt {
return `${this.email} - ${this.name}`;
}

@Property({ persist: false })
@Property({ persist: false, groups: ['admin'] })
get code2(): string & Opt {
return `${this.email} - ${this.name}`;
}
Expand Down
52 changes: 52 additions & 0 deletions tests/features/serialization/explicit-serialization.test.ts
Expand Up @@ -43,6 +43,58 @@ test('explicit serialization with ORM BaseEntity', async () => {
});
});

test('explicit serialization with groups', async () => {
const { god, author, publisher, book1, book2, book3 } = await createEntities();
const jon = await orm.em.findOneOrFail(Author2, author, { populate: ['*'] })!;
jon.age = 34;

const o1 = wrap(jon).serialize({ groups: [] });
expect(o1).toEqual({
id: jon.id,
createdAt: jon.createdAt,
updatedAt: jon.updatedAt,
name: 'Jon Snow',
});
const o2 = wrap(jon).serialize({ groups: ['personal'] });
expect(o2).toEqual({
id: jon.id,
createdAt: jon.createdAt,
updatedAt: jon.updatedAt,
books: [book1.uuid, book2.uuid, book3.uuid],
books2: [book1.uuid, book2.uuid, book3.uuid],
favouriteBook: jon.favouriteBook!.uuid,
born: '1990-03-23',
email: 'snow@wall.st',
name: 'Jon Snow',
age: 34,
address: null,
identity: null,
optional: null,
bornTime: null,
favouriteAuthor: null,
followers: [],
following: [],
friends: [],
});
const o3 = wrap(jon).serialize({ groups: ['admin'] });
expect(o3).toEqual({
id: jon.id,
createdAt: jon.createdAt,
updatedAt: jon.updatedAt,
name: 'Jon Snow',
email: 'snow@wall.st',
age: 34,
termsAccepted: false,
identities: null,
born: '1990-03-23',
bornTime: null,
address: null,
identity: null,
code: 'snow@wall.st - Jon Snow',
code2: 'snow@wall.st - Jon Snow',
});
});

test('explicit serialization', async () => {
const { god, author, publisher, book1, book2, book3 } = await createEntities();
const jon = await orm.em.findOneOrFail(Author2, author, { populate: ['*'] })!;
Expand Down

0 comments on commit 818c290

Please sign in to comment.