Skip to content

Commit

Permalink
feat(core): support embeddable arrays (mikro-orm#1496)
Browse files Browse the repository at this point in the history
```ts
  @Embedded(() => Address, { array: true })
  addresses: Address[] = [];
```

Closes mikro-orm#1369
  • Loading branch information
B4nan authored Feb 26, 2021
1 parent 629aae9 commit 57b605c
Show file tree
Hide file tree
Showing 17 changed files with 252 additions and 122 deletions.
6 changes: 3 additions & 3 deletions .codeclimate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ version: "2"
checks:
file-lines:
config:
threshold: 500
threshold: 1000
method-lines:
config:
threshold: 50
threshold: 200
method-count:
config:
threshold: 50
threshold: 100
return-statements:
config:
threshold: 7
Expand Down
10 changes: 10 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ services:

mysql:
image: mysql:5.7
platform: linux/x86_64
restart: unless-stopped
ports:
- 3307:3306
Expand Down Expand Up @@ -33,5 +34,14 @@ services:

volumes:
mysql:
driver_opts:
type: tmpfs
device: tmpfs
mariadb:
driver_opts:
type: tmpfs
device: tmpfs
postgre:
driver_opts:
type: tmpfs
device: tmpfs
10 changes: 10 additions & 0 deletions docs/docs/embeddables.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,16 @@ In SQL drivers, this will use a JSON column to store the value.
> This part of documentation is highly inspired by [doctrine tutorial](https://www.doctrine-project.org/projects/doctrine-orm/en/latest/tutorials/embeddables.html)
> as the behaviour here is pretty much the same.
## Array of embeddables

Embedded arrays are always stored as JSON. It is possible to use them inside
nested embeddables.

```ts
@Embedded(() => Address, { array: true })
addresses: Address[] = [];
```

## Nested embeddables

Starting with v4.4, we can also nest embeddables, both in inline mode and object mode:
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/decorators/Embedded.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,5 @@ export type EmbeddedOptions = {
prefix?: string | boolean;
nullable?: boolean;
object?: boolean;
array?: boolean;
};
217 changes: 134 additions & 83 deletions packages/core/src/hydration/ObjectHydrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ export class ObjectHydrator extends Hydrator {
return exists;
}

let tmpCounter = 0;
const lines: string[] = [];
const context = new Map<string, any>();
const props = this.getProperties(meta, type);
Expand Down Expand Up @@ -70,22 +69,13 @@ export class ObjectHydrator extends Hydrator {
return ret;
};

const scalarHydrator = <T, U>(prop: EntityProperty<T>, object?: boolean, path: string[] = [prop.name]): string[] => {
const hydrateScalar = <T, U>(prop: EntityProperty<T>, object: boolean | undefined, path: string[], dataKey: string): string[] => {
const entityKey = path.join('.');
const dataKey = object ? entityKey : prop.name;
const preCond = preCondition(dataKey);
const convertorKey = path.join('_');
const convertorKey = path.join('_').replace(/\[idx]$/, '');
const ret: string[] = [];

if (prop.reference === ReferenceType.EMBEDDED) {
context.set(`prototype_${convertorKey}`, prop.embeddable.prototype);
tmpCounter++;
ret.push(` const tmp_${tmpCounter} = entity.${entityKey};`);
ret.push(` entity.${entityKey} = Object.create(prototype_${convertorKey});`);
const children = meta.props.filter(p => p.embedded?.[0] === prop.name);
children.forEach(childProp => ret.push(...scalarHydrator(childProp, object || prop.object, [...path, childProp.embedded![1]])));
ret.push(` if (Object.keys(entity.${entityKey}).filter(k => entity.${entityKey}[k] != null).length === 0) entity.${entityKey} = tmp_${tmpCounter}`);
} else if (prop.type.toLowerCase() === 'date') {
if (prop.type.toLowerCase() === 'date') {
ret.push(
` if (${preCond}data.${dataKey}) entity.${entityKey} = new Date(data.${dataKey});`,
` else if (${preCond}data.${dataKey} === null) entity.${entityKey} = null;`,
Expand All @@ -112,93 +102,154 @@ export class ObjectHydrator extends Hydrator {
return ret;
};

for (const prop of props) {
if (prop.reference === ReferenceType.MANY_TO_ONE || prop.reference === ReferenceType.ONE_TO_ONE) {
lines.push(` if (data.${prop.name} === null) {\n entity.${prop.name} = null;`);
lines.push(` } else if (typeof data.${prop.name} !== 'undefined') {`);
lines.push(` if (isPrimaryKey(data.${prop.name}, true)) {`);

if (prop.mapToPk) {
lines.push(` entity.${prop.name} = data.${prop.name};`);
} else if (prop.wrappedReference) {
lines.push(` entity.${prop.name} = new Reference(factory.createReference('${prop.type}', data.${prop.name}, { merge: true }));`);
} else {
lines.push(` entity.${prop.name} = factory.createReference('${prop.type}', data.${prop.name}, { merge: true });`);
}
const hydrateToOne = (prop: EntityProperty) => {
const ret: string[] = [];

lines.push(` } else if (data.${prop.name} && typeof data.${prop.name} === 'object') {`);
ret.push(` if (data.${prop.name} === null) {\n entity.${prop.name} = null;`);
ret.push(` } else if (typeof data.${prop.name} !== 'undefined') {`);
ret.push(` if (isPrimaryKey(data.${prop.name}, true)) {`);

if (prop.mapToPk) {
lines.push(` entity.${prop.name} = data.${prop.name};`);
} else if (prop.wrappedReference) {
lines.push(` entity.${prop.name} = new Reference(factory.create('${prop.type}', data.${prop.name}, { initialized: true, merge: true }));`);
} else {
lines.push(` entity.${prop.name} = factory.create('${prop.type}', data.${prop.name}, { initialized: true, merge: true, newEntity });`);
}
if (prop.mapToPk) {
ret.push(` entity.${prop.name} = data.${prop.name};`);
} else if (prop.wrappedReference) {
ret.push(` entity.${prop.name} = new Reference(factory.createReference('${prop.type}', data.${prop.name}, { merge: true }));`);
} else {
ret.push(` entity.${prop.name} = factory.createReference('${prop.type}', data.${prop.name}, { merge: true });`);
}

lines.push(` }`);
lines.push(` }`);
ret.push(` } else if (data.${prop.name} && typeof data.${prop.name} === 'object') {`);

if (prop.reference === ReferenceType.ONE_TO_ONE && !prop.mapToPk) {
const meta2 = this.metadata.get(prop.type);
const prop2 = meta2.properties[prop.inversedBy || prop.mappedBy];
if (prop.mapToPk) {
ret.push(` entity.${prop.name} = data.${prop.name};`);
} else if (prop.wrappedReference) {
ret.push(` entity.${prop.name} = new Reference(factory.create('${prop.type}', data.${prop.name}, { initialized: true, merge: true }));`);
} else {
ret.push(` entity.${prop.name} = factory.create('${prop.type}', data.${prop.name}, { initialized: true, merge: true, newEntity });`);
}

if (prop2) {
lines.push(` if (entity.${prop.name} && !entity.${prop.name}.${prop2.name}) {`);
lines.push(` entity.${prop.name}.${prop.wrappedReference ? 'unwrap().' : ''}${prop2.name} = ${prop2.wrappedReference ? 'new Reference(entity)' : 'entity'};`);
lines.push(` }`);
}
}
ret.push(` }`);
ret.push(` }`);

if (prop.customType) {
context.set(`convertToDatabaseValue_${prop.name}`, (val: any) => prop.customType.convertToDatabaseValue(val, this.platform));
if (prop.reference === ReferenceType.ONE_TO_ONE && !prop.mapToPk) {
const meta2 = this.metadata.get(prop.type);
const prop2 = meta2.properties[prop.inversedBy || prop.mappedBy];

lines.push(` if (data.${prop.name} != null && convertCustomTypes) {`);
lines.push(` data.${prop.name} = convertToDatabaseValue_${prop.name}(entity.${prop.name}.__helper.getPrimaryKey());`); // make sure the value is comparable
lines.push(` }`);
if (prop2) {
ret.push(` if (entity.${prop.name} && !entity.${prop.name}.${prop2.name}) {`);
ret.push(` entity.${prop.name}.${prop.wrappedReference ? 'unwrap().' : ''}${prop2.name} = ${prop2.wrappedReference ? 'new Reference(entity)' : 'entity'};`);
ret.push(` }`);
}
} else if (prop.reference === ReferenceType.ONE_TO_MANY || prop.reference === ReferenceType.MANY_TO_MANY) {
lines.push(...this.createCollectionItemMapper(prop));
lines.push(` if (Array.isArray(data.${prop.name})) {`);
lines.push(` const items = data.${prop.name}.map(value => createCollectionItem_${prop.name}(value));`);
lines.push(` const coll = Collection.create(entity, '${prop.name}', items, newEntity);`);
lines.push(` if (newEntity) {`);
lines.push(` coll.setDirty();`);
lines.push(` } else {`);
lines.push(` coll.takeSnapshot();`);
lines.push(` }`);
lines.push(` } else if (!entity.${prop.name} && data.${prop.name} instanceof Collection) {`);
lines.push(` entity.${prop.name} = data.${prop.name};`);
lines.push(` } else if (!entity.${prop.name}) {`);
const items = this.platform.usesPivotTable() || !prop.owner ? 'undefined' : '[]';
lines.push(` const coll = Collection.create(entity, '${prop.name}', ${items}, !!data.${prop.name} || newEntity);`);
lines.push(` coll.setDirty(false);`);
lines.push(` }`);
} else if (prop.reference === ReferenceType.EMBEDDED) {
context.set(`prototype_${prop.name}`, prop.embeddable.prototype);
const conds: string[] = [];
}

if (prop.object) {
conds.push(`data.${prop.name} != null`);
} else {
meta.props
.filter(p => p.embedded?.[0] === prop.name)
.forEach(p => conds.push(`data.${p.name} != null`));
}
if (prop.customType) {
context.set(`convertToDatabaseValue_${prop.name}`, (val: any) => prop.customType.convertToDatabaseValue(val, this.platform));

ret.push(` if (data.${prop.name} != null && convertCustomTypes) {`);
ret.push(` data.${prop.name} = convertToDatabaseValue_${prop.name}(entity.${prop.name}.__helper.getPrimaryKey());`); // make sure the value is comparable
ret.push(` }`);
}

return ret;
};

const hydrateToMany = (prop: EntityProperty) => {
const ret: string[] = [];

ret.push(...this.createCollectionItemMapper(prop));
ret.push(` if (Array.isArray(data.${prop.name})) {`);
ret.push(` const items = data.${prop.name}.map(value => createCollectionItem_${prop.name}(value));`);
ret.push(` const coll = Collection.create(entity, '${prop.name}', items, newEntity);`);
ret.push(` if (newEntity) {`);
ret.push(` coll.setDirty();`);
ret.push(` } else {`);
ret.push(` coll.takeSnapshot();`);
ret.push(` }`);
ret.push(` } else if (!entity.${prop.name} && data.${prop.name} instanceof Collection) {`);
ret.push(` entity.${prop.name} = data.${prop.name};`);
ret.push(` } else if (!entity.${prop.name}) {`);
const items = this.platform.usesPivotTable() || !prop.owner ? 'undefined' : '[]';
ret.push(` const coll = Collection.create(entity, '${prop.name}', ${items}, !!data.${prop.name} || newEntity);`);
ret.push(` coll.setDirty(false);`);
ret.push(` }`);

return ret;
};

const hydrateEmbedded = (prop: EntityProperty, object: boolean | undefined, path: string[], dataKey: string): string[] => {
const entityKey = path.join('.');
const convertorKey = path.join('_').replace(/\[idx]$/, '');
const ret: string[] = [];
const conds: string[] = [];
context.set(`prototype_${convertorKey}`, prop.embeddable.prototype);

lines.push(` if (${conds.join(' || ')}) {`);
lines.push(` entity.${prop.name} = Object.create(prototype_${prop.name});`);
if (prop.object) {
conds.push(`data.${dataKey} != null`);
} else {
meta.props
.filter(p => p.embedded?.[0] === prop.name)
.forEach(childProp => lines.push(...scalarHydrator(childProp, prop.object, [prop.name, childProp.embedded![1]]).map(l => ' ' + l)));
lines.push(` }`);
.forEach(p => conds.push(`data.${p.name} != null`));
}

ret.push(` if (${conds.join(' || ')}) {`);
ret.push(` entity.${entityKey} = Object.create(prototype_${convertorKey});`);
meta.props
.filter(p => p.embedded?.[0] === prop.name)
.forEach(childProp => {
const childDataKey = prop.object ? dataKey + '.' + childProp.embedded![1] : childProp.name;
// eslint-disable-next-line @typescript-eslint/no-use-before-define
ret.push(...hydrateProperty(childProp, prop.object, [...path, childProp.embedded![1]], childDataKey).map(l => ' ' + l));
});
ret.push(` }`);

return ret;
};

const hydrateEmbeddedArray = (prop: EntityProperty, path: string[]): string[] => {
const entityKey = path.join('.');
const dataKey = prop.fieldNames[0];
const convertorKey = path.join('_').replace(/\[idx]$/, '');
const ret: string[] = [];

context.set(`prototype_${convertorKey}`, prop.embeddable.prototype);
ret.push(` if (Array.isArray(data.${dataKey})) {`);
ret.push(` entity.${entityKey} = [];`);
ret.push(` data.${dataKey}.forEach((_, idx) => {`);
const last = path.pop();
ret.push(...hydrateEmbedded(prop, true, [...path, last + '[idx]'], dataKey + '[idx]').map(l => ' ' + l));
ret.push(` });`);
ret.push(` }`);

return ret;
};

const hydrateProperty = (prop: EntityProperty, object = prop.object, path: string[] = [prop.name], dataKey?: string): string[] => {
const entityKey = path.join('.');
dataKey = dataKey ?? (object ? entityKey : prop.name);
const ret: string[] = [];

if (prop.reference === ReferenceType.MANY_TO_ONE || prop.reference === ReferenceType.ONE_TO_ONE) {
ret.push(...hydrateToOne(prop));
} else if (prop.reference === ReferenceType.ONE_TO_MANY || prop.reference === ReferenceType.MANY_TO_MANY) {
ret.push(...hydrateToMany(prop));
} else if (prop.reference === ReferenceType.EMBEDDED) {
if (prop.array) {
ret.push(...hydrateEmbeddedArray(prop, path));
} else {
ret.push(...hydrateEmbedded(prop, object, path, dataKey));
}
} else { // ReferenceType.SCALAR
lines.push(...scalarHydrator(prop));
ret.push(...hydrateScalar(prop, object, path, dataKey));
}

if (this.config.get('forceUndefined')) {
lines.push(` if (data.${prop.name} === null) entity.${prop.name} = undefined;`);
ret.push(` if (data.${dataKey} === null) entity.${entityKey} = undefined;`);
}

return ret;
};

for (const prop of props) {
lines.push(...hydrateProperty(prop));
}

const code = `return function(entity, data, factory, newEntity, convertCustomTypes) {\n${lines.join('\n')}\n}`;
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/metadata/EntitySchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,11 @@ export class EntitySchema<T extends AnyEntity<T> = AnyEntity, U extends AnyEntit

addEmbedded<K = unknown>(name: string & keyof T, options: EmbeddedOptions): void {
Utils.defaultValue(options, 'prefix', true);

if (options.array) {
options.object = true; // force object mode for arrays
}

this._meta.properties[name] = {
name,
type: this.normalizeType(options),
Expand Down
25 changes: 18 additions & 7 deletions packages/core/src/metadata/MetadataDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -587,23 +587,34 @@ export class MetadataDiscovery {
}

const getRootProperty: (prop: EntityProperty) => EntityProperty = (prop: EntityProperty) => prop.embedded ? getRootProperty(meta.properties[prop.embedded[0]]) : prop;
const isParentObject: (prop: EntityProperty) => boolean = (prop: EntityProperty) => {
if (prop.object) {
return true;
}

return prop.embedded ? isParentObject(meta.properties[prop.embedded[0]]) : false;
};
const rootProperty = getRootProperty(embeddedProp);

if (rootProperty.object) {
if (isParentObject(embeddedProp)) {
embeddedProp.object = true;
this.initFieldName(embeddedProp);
const path: string[] = [];
let path: string[] = [];
let tmp = embeddedProp;

while (tmp.embedded) {
const fieldName = tmp.embedded![1];
path.unshift(fieldName);
while (tmp.embedded && tmp.object) {
path.unshift(tmp.embedded![1]);
tmp = meta.properties[tmp.embedded[0]];
}

path.unshift(this.namingStrategy.propertyToColumnName(rootProperty.name));
path.push(prop.name);
if (tmp === rootProperty) {
path.unshift(this.namingStrategy.propertyToColumnName(rootProperty.name));
} else {
path = [embeddedProp.fieldNames[0]];
}

path.push(prop.name);
meta.properties[name].fieldNames = [path.join('.')]; // store path for ObjectHydrator
meta.properties[name].fieldNameRaw = this.platform.getSearchJsonPropertySQL(path.join('->'), prop.type); // for querying in SQL drivers
meta.properties[name].persist = false; // only virtual as we store the whole object
}
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/platforms/Platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,10 @@ export abstract class Platform {
return value;
}

cloneEmbeddable<T>(data: T): T {
return JSON.parse(JSON.stringify(data));
}

setConfig(config: Configuration): void {
this.config = config;

Expand Down
Loading

0 comments on commit 57b605c

Please sign in to comment.