diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 45941d4173f2..c2d6fe5e8908 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -150,35 +150,15 @@ jobs: - name: Set CC Required env vars run: export GIT_BRANCH=$GITHUB_HEAD_REF && export GIT_COMMIT_SHA=$(git rev-parse origin/$GITHUB_HEAD_REF) - - name: Check for changes - id: changed_packages - run: | - echo "::set-output name=changed_packages::$(node ./node_modules/.bin/lerna changed -p | wc -l | xargs)" - - - name: Check for changes 2 - if: steps.changed_packages.outputs.changed_packages == '0' - run: | - echo "no changes detected by lerna" - - name: Test - if: steps.changed_packages.outputs.changed_packages != '0' run: | yarn coverage > COVERAGE_RESULT echo "$(cat COVERAGE_RESULT)" git status && git diff - name: Codecov - if: steps.changed_packages.outputs.changed_packages != '0' uses: codecov/codecov-action@v3 -# - name: Codeclimate -# if: steps.changed_packages.outputs.changed_packages != '0' -# uses: paambaati/codeclimate-action@v4.0.0 -# env: -# CC_TEST_REPORTER_ID: e2a39c5dc1a13674e97e94a467bacfaec953814982c7de89e9f0b55031e43bd8 -# with: -# coverageCommand: echo "$(cat COVERAGE_RESULT)" - - name: Teardown docker run: docker-compose down diff --git a/docs/docs/upgrading-v5-to-v6.md b/docs/docs/upgrading-v5-to-v6.md index ce5e9f3baba8..01044b31e349 100644 --- a/docs/docs/upgrading-v5-to-v6.md +++ b/docs/docs/upgrading-v5-to-v6.md @@ -397,3 +397,23 @@ Additional join conditions used to be implicitly aliased to the root entity, now // the `name` used to resolve to `b.name`, now it will resolve to `a.name` instead qb.join('b.author', 'a', { name: 'foo' }); ``` + +## Embedded properties respect `NamingStrategy` + +This is breaking mainly for SQL drivers, where the default naming strategy is underscoring, and will now applied to the embedded properties too. You can restore to the old behaviour by implementing custom naming strategy, overriding the `propertyToColumnName` method. It now has a second boolean parameter to indicate if the property is defined inside a JSON object context. + +```ts +import { UnderscoreNamingStrategy } from '@mikro-orm/core'; + +class CustomNamingStrategy extends UnderscoreNamingStrategy { + + propertyToColumnName(propertyName: string, object?: boolean): string { + if (object) { + return propertyName; + } + + return super.propertyToColumnName(propertyName, object); + } + +} +``` diff --git a/packages/core/src/drivers/DatabaseDriver.ts b/packages/core/src/drivers/DatabaseDriver.ts index f32cf262be50..7f0ed3258985 100644 --- a/packages/core/src/drivers/DatabaseDriver.ts +++ b/packages/core/src/drivers/DatabaseDriver.ts @@ -26,7 +26,7 @@ import type { } from '../typings'; import type { MetadataStorage } from '../metadata'; import type { Connection, QueryResult, Transaction } from '../connections'; -import { EntityComparator, Utils, type Configuration, type ConnectionOptions, Cursor } from '../utils'; +import { EntityComparator, Utils, type Configuration, type ConnectionOptions, Cursor, raw } from '../utils'; import { type QueryOrder, ReferenceKind, type QueryOrderKeys, QueryOrderNumeric } from '../enums'; import type { Platform } from '../platforms'; import type { Collection } from '../entity/Collection'; @@ -260,7 +260,81 @@ export abstract class DatabaseDriver implements IDatabaseD } as FilterQuery; } + /** @internal */ + mapDataToFieldNames(data: Dictionary, stringifyJsonArrays: boolean, properties?: Record, convertCustomTypes?: boolean, object?: boolean) { + if (!properties || data == null) { + return data; + } + + data = Object.assign({}, data); // copy first + + Object.keys(data).forEach(k => { + const prop = properties[k]; + + if (!prop) { + return; + } + + if (prop.embeddedProps && !prop.object && !object) { + const copy = data[k]; + delete data[k]; + Object.assign(data, this.mapDataToFieldNames(copy, stringifyJsonArrays, prop.embeddedProps, convertCustomTypes)); + + return; + } + + if (prop.embeddedProps && (prop.object || object)) { + const copy = data[k]; + delete data[k]; + + if (prop.array) { + data[prop.fieldNames[0]] = copy.map((item: Dictionary) => this.mapDataToFieldNames(item, stringifyJsonArrays, prop.embeddedProps, convertCustomTypes, true)); + } else { + data[prop.fieldNames[0]] = this.mapDataToFieldNames(copy, stringifyJsonArrays, prop.embeddedProps, convertCustomTypes, true); + } + + if (stringifyJsonArrays && prop.array) { + data[prop.fieldNames[0]] = this.platform.convertJsonToDatabaseValue(data[prop.fieldNames[0]]); + } + + return; + } + + if (prop.joinColumns && Array.isArray(data[k])) { + const copy = Utils.flatten(data[k]); + delete data[k]; + prop.joinColumns.forEach((joinColumn, idx) => data[joinColumn] = copy[idx]); + + return; + } + + if (prop.customType && convertCustomTypes && !this.platform.isRaw(data[k])) { + data[k] = prop.customType.convertToDatabaseValue(data[k], this.platform, { fromQuery: true, key: k, mode: 'query-data' }); + } + + if (prop.hasConvertToDatabaseValueSQL && !this.platform.isRaw(data[k])) { + const quoted = this.platform.quoteValue(data[k]); + const sql = prop.customType.convertToDatabaseValueSQL!(quoted, this.platform); + data[k] = raw(sql.replace(/\?/g, '\\?')); + } + + if (!prop.customType && (Array.isArray(data[k]) || Utils.isPlainObject(data[k]))) { + data[k] = JSON.stringify(data[k]); + } + + if (prop.fieldNames) { + Utils.renameKey(data, k, prop.fieldNames[0]); + } + }); + + return data; + } + protected inlineEmbeddables(meta: EntityMetadata, data: T, where?: boolean): void { + if (data == null) { + return; + } + Utils.keys(data).forEach(k => { if (Utils.isOperator(k as string)) { Utils.asArray(data[k]).forEach(payload => this.inlineEmbeddables(meta, payload as T, where)); diff --git a/packages/core/src/metadata/MetadataDiscovery.ts b/packages/core/src/metadata/MetadataDiscovery.ts index 0d4975e4ffed..936fd00256d3 100644 --- a/packages/core/src/metadata/MetadataDiscovery.ts +++ b/packages/core/src/metadata/MetadataDiscovery.ts @@ -423,13 +423,13 @@ export class MetadataDiscovery { } } - private initFieldName(prop: EntityProperty): void { + private initFieldName(prop: EntityProperty, object = false): void { if (prop.fieldNames && prop.fieldNames.length > 0) { return; } - if (prop.kind === ReferenceKind.SCALAR || (prop.kind === ReferenceKind.EMBEDDED && prop.object)) { - prop.fieldNames = [this.namingStrategy.propertyToColumnName(prop.name)]; + if (prop.kind === ReferenceKind.SCALAR || prop.kind === ReferenceKind.EMBEDDED) { + prop.fieldNames = [this.namingStrategy.propertyToColumnName(prop.name, object)]; } else if ([ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind)) { prop.fieldNames = this.initManyToOneFieldName(prop, prop.name); } else if (prop.kind === ReferenceKind.MANY_TO_MANY && prop.owner) { @@ -892,9 +892,19 @@ export class MetadataDiscovery { embeddedProp.embeddedProps = {}; let order = meta.propertyOrder.get(embeddedProp.name)!; 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 || prop.array) { + return true; + } + + return prop.embedded ? isParentObject(meta.properties[prop.embedded[0]]) : false; + }; + const rootProperty = getRootProperty(embeddedProp); + const object = isParentObject(embeddedProp); + this.initFieldName(embeddedProp, rootProperty !== embeddedProp && object); + const prefix = embeddedProp.prefix === false ? '' : embeddedProp.prefix === true ? embeddedProp.embeddedPath?.join('_') ?? embeddedProp.fieldNames[0] + '_' : embeddedProp.prefix; for (const prop of Object.values(embeddable.properties).filter(p => p.persist !== false)) { - const prefix = embeddedProp.prefix === false ? '' : embeddedProp.prefix === true ? embeddedProp.name + '_' : embeddedProp.prefix; const name = prefix + prop.name; if (meta.properties[name] !== undefined && getRootProperty(meta.properties[name]).kind !== ReferenceKind.EMBEDDED) { @@ -911,18 +921,16 @@ export class MetadataDiscovery { meta.properties[name].nullable = true; } - const isParentObject: (prop: EntityProperty) => boolean = (prop: EntityProperty) => { - if (prop.object) { - return true; + if (prefix) { + if (meta.properties[name].fieldNames) { + meta.properties[name].fieldNames[0] = prefix + meta.properties[name].fieldNames[0]; + } else { + this.initFieldName(meta.properties[name]); } + } - return prop.embedded ? isParentObject(meta.properties[prop.embedded[0]]) : false; - }; - const rootProperty = getRootProperty(embeddedProp); - - if (isParentObject(embeddedProp)) { + if (object) { embeddedProp.object = true; - this.initFieldName(embeddedProp); let path: string[] = []; let tmp = embeddedProp; @@ -932,13 +940,17 @@ export class MetadataDiscovery { } if (tmp === rootProperty) { - path.unshift(this.namingStrategy.propertyToColumnName(rootProperty.name)); + path.unshift(rootProperty.fieldNames[0]); + } else if (embeddedProp.embeddedPath) { + path = [...embeddedProp.embeddedPath]; } else { path = [embeddedProp.fieldNames[0]]; } - path.push(prop.name); - meta.properties[name].fieldNames = [path.join('.')]; // store path for ObjectHydrator + this.initFieldName(prop, true); + path.push(prop.fieldNames[0]); + meta.properties[name].fieldNames = prop.fieldNames; + meta.properties[name].embeddedPath = path; const fieldName = raw(this.platform.getSearchJsonPropertySQL(path.join('->'), prop.type, true)); meta.properties[name].fieldNameRaw = fieldName.sql; // for querying in SQL drivers meta.properties[name].persist = false; // only virtual as we store the whole object diff --git a/packages/core/src/naming-strategy/AbstractNamingStrategy.ts b/packages/core/src/naming-strategy/AbstractNamingStrategy.ts index 1aa021965fb2..9a62a8c8a866 100644 --- a/packages/core/src/naming-strategy/AbstractNamingStrategy.ts +++ b/packages/core/src/naming-strategy/AbstractNamingStrategy.ts @@ -59,7 +59,7 @@ export abstract class AbstractNamingStrategy implements NamingStrategy { abstract joinTableName(sourceEntity: string, targetEntity: string, propertyName?: string): string; - abstract propertyToColumnName(propertyName: string): string; + abstract propertyToColumnName(propertyName: string, object?: boolean): string; abstract referenceColumnName(): string; diff --git a/packages/core/src/naming-strategy/NamingStrategy.ts b/packages/core/src/naming-strategy/NamingStrategy.ts index b2db7f3aa415..5e5e8608c51f 100644 --- a/packages/core/src/naming-strategy/NamingStrategy.ts +++ b/packages/core/src/naming-strategy/NamingStrategy.ts @@ -18,7 +18,7 @@ export interface NamingStrategy { /** * Return a column name for a property */ - propertyToColumnName(propertyName: string): string; + propertyToColumnName(propertyName: string, object?: boolean): string; /** * Return a property for a column name (used in `EntityGenerator`). diff --git a/packages/core/src/naming-strategy/UnderscoreNamingStrategy.ts b/packages/core/src/naming-strategy/UnderscoreNamingStrategy.ts index ee460731761a..e6407d3d0ea2 100644 --- a/packages/core/src/naming-strategy/UnderscoreNamingStrategy.ts +++ b/packages/core/src/naming-strategy/UnderscoreNamingStrategy.ts @@ -18,7 +18,7 @@ export class UnderscoreNamingStrategy extends AbstractNamingStrategy { return this.classToTableName(sourceEntity) + '_' + this.classToTableName(propertyName); } - propertyToColumnName(propertyName: string): string { + propertyToColumnName(propertyName: string, object?: boolean): string { return this.underscore(propertyName); } diff --git a/packages/core/src/typings.ts b/packages/core/src/typings.ts index f84d2d9be633..a992d5aba127 100644 --- a/packages/core/src/typings.ts +++ b/packages/core/src/typings.ts @@ -343,6 +343,7 @@ export interface EntityProperty { formula?: (alias: string) => string; prefix?: string | boolean; embedded?: [EntityKey, EntityKey]; + embeddedPath?: string[]; embeddable: Constructor; embeddedProps: Dictionary; discriminatorColumn?: string; // only for poly embeddables currently diff --git a/packages/core/src/utils/EntityComparator.ts b/packages/core/src/utils/EntityComparator.ts index 50a51aa771d6..dac648677736 100644 --- a/packages/core/src/utils/EntityComparator.ts +++ b/packages/core/src/utils/EntityComparator.ts @@ -319,7 +319,7 @@ export class EntityComparator { return; } - if (prop.embedded && meta.properties[prop.embedded[0]].object && prop.runtimeType !== 'Date') { + if (prop.embedded && (meta.embeddable || meta.properties[prop.embedded[0]].object)) { return; } @@ -329,33 +329,23 @@ export class EntityComparator { lines.push(` ${propName(prop.fieldNames[0], 'mapped')} = true;`); lines.push(` }`); } else if (prop.runtimeType === 'Date') { - if (prop.embedded && meta.properties[prop.embedded[0]].array) { - const parentKey = 'ret.' + meta.properties[prop.embedded[0]].fieldNames[0]; - const idx = this.tmpIndex++; - lines.push(` if (Array.isArray(${parentKey.replace(/\./g, '?.')})) {`); - lines.push(` ${parentKey}.forEach((item_${idx}, idx_${idx}) => {`); - const childProp = this.wrap(prop.embedded[1]); - lines.push(` if (typeof item_${idx}${childProp} !== 'undefined') {`); - parseDate(`${parentKey}[idx_${idx}]${childProp}`, `${parentKey}[idx_${idx}]${childProp}`, ' '); - lines.push(` }`); - lines.push(` });`); - lines.push(` }`); - } else if (prop.embedded && meta.properties[prop.embedded[0]].object) { - const entityKey = 'ret.' + prop.fieldNames[0]; - const entityKeyOptional = 'ret.' + prop.fieldNames[0].replace(/\./g, '?.'); - lines.push(` if (typeof ${entityKeyOptional} !== 'undefined') {`); - parseDate('ret.' + prop.fieldNames[0], entityKey); - lines.push(` }`); - } else { - lines.push(` if (typeof ${propName(prop.fieldNames[0])} !== 'undefined') {`); - parseDate('ret' + this.wrap(prop.name), propName(prop.fieldNames[0])); - lines.push(` ${propName(prop.fieldNames[0], 'mapped')} = true;`); - lines.push(` }`); - } - } else if (prop.kind === ReferenceKind.EMBEDDED && prop.object && !this.platform.convertsJsonAutomatically()) { - context.set('parseJsonSafe', parseJsonSafe); lines.push(` if (typeof ${propName(prop.fieldNames[0])} !== 'undefined') {`); - lines.push(` ret${this.wrap(prop.name)} = ${propName(prop.fieldNames[0])} == null ? ${propName(prop.fieldNames[0])} : parseJsonSafe(${propName(prop.fieldNames[0])});`); + parseDate('ret' + this.wrap(prop.name), propName(prop.fieldNames[0])); + lines.push(` ${propName(prop.fieldNames[0], 'mapped')} = true;`); + lines.push(` }`); + } else if (prop.kind === ReferenceKind.EMBEDDED && (prop.object || meta.embeddable)) { + const idx = this.tmpIndex++; + context.set(`mapEmbeddedResult_${idx}`, (data: Dictionary) => { + const item = parseJsonSafe(data); + + if (Array.isArray(item)) { + return item.map(row => row == null ? row : this.getResultMapper(prop.type)(row)); + } + + return item == null ? item : this.getResultMapper(prop.type)(item); + }); + lines.push(` if (typeof ${propName(prop.fieldNames[0])} !== 'undefined') {`); + lines.push(` ret${this.wrap(prop.name)} = ${propName(prop.fieldNames[0])} == null ? ${propName(prop.fieldNames[0])} : mapEmbeddedResult_${idx}(${propName(prop.fieldNames[0])});`); lines.push(` ${propName(prop.fieldNames[0], 'mapped')} = true;`); lines.push(` }`); } else { @@ -369,13 +359,13 @@ export class EntityComparator { const code = `// compiled mapper for entity ${meta.className}\n` + `return function(result) {\n const ret = {};\n${lines.join('\n')}\n return ret;\n}`; - const snapshotGenerator = Utils.createFunction(context, code); - this.mappers.set(entityName, snapshotGenerator); + const resultMapper = Utils.createFunction(context, code); + this.mappers.set(entityName, resultMapper); - return snapshotGenerator; + return resultMapper; } - private getPropertyCondition(prop: EntityProperty, entityKey: string, path: string[]): string { + private getPropertyCondition(path: string[]): string { const parts = path.slice(); // copy first if (parts.length > 1) { @@ -383,7 +373,8 @@ export class EntityComparator { } let tail = ''; - const ret = parts + + return parts .map(k => { if (k.match(/^\[idx_\d+]$/)) { tail += k; @@ -397,8 +388,6 @@ export class EntityComparator { }) .filter(k => k) .join(' && '); - - return ret; } private getEmbeddedArrayPropertySnapshot(meta: EntityMetadata, prop: EntityProperty, context: Map, level: number, path: string[], dataKey: string): string { @@ -464,7 +453,7 @@ export class EntityComparator { } ret += meta.props.filter(p => p.embedded?.[0] === prop.name).map(childProp => { - const childDataKey = prop.object ? dataKey + this.wrap(childProp.embedded![1]) : this.wrap(childProp.name); + const childDataKey = meta.embeddable || prop.object ? dataKey + this.wrap(childProp.embedded![1]) : this.wrap(childProp.name); const childEntityKey = [...path, childProp.embedded![1]].map(k => this.wrap(k)).join(''); const childCond = `typeof entity${childEntityKey} !== 'undefined'`; @@ -501,7 +490,7 @@ export class EntityComparator { private getPropertySnapshot(meta: EntityMetadata, prop: EntityProperty, context: Map, dataKey: string, entityKey: string, path: string[], level = 1, object?: boolean): string { const convertorKey = this.safeKey(prop.name); const unwrap = prop.ref ? '?.unwrap()' : ''; - let ret = ` if (${this.getPropertyCondition(prop, entityKey, path)}) {\n`; + let ret = ` if (${this.getPropertyCondition(path)}) {\n`; if (['number', 'string', 'boolean'].includes(prop.type.toLowerCase())) { return ret + ` ret${dataKey} = entity${entityKey}${unwrap};\n }\n`; diff --git a/packages/knex/src/AbstractSqlDriver.ts b/packages/knex/src/AbstractSqlDriver.ts index fb8f6a97aec5..5bf6793aa145 100644 --- a/packages/knex/src/AbstractSqlDriver.ts +++ b/packages/knex/src/AbstractSqlDriver.ts @@ -397,12 +397,25 @@ export abstract class AbstractSqlDriver, row: Dictionary) => { + let value = row[prop.name]; + + if (prop.kind === ReferenceKind.EMBEDDED && prop.object) { + if (prop.array) { + for (let i = 0; i < (value as Dictionary[]).length; i++) { + const item = (value as Dictionary[])[i]; + value[i] = this.mapDataToFieldNames(item, false, prop.embeddedProps, options.convertCustomTypes); + } + } else { + value = this.mapDataToFieldNames(value, false, prop.embeddedProps, options.convertCustomTypes); + } + } + if (options.convertCustomTypes && prop.customType) { - params.push(prop.customType.convertToDatabaseValue(row[prop.name], this.platform, { key: prop.name, mode: 'query-data' })); + params.push(prop.customType.convertToDatabaseValue(value, this.platform, { key: prop.name, mode: 'query-data' })); return; } - params.push(row[prop.name]); + params.push(value); }; if (fields.length > 0 || this.platform.usesDefaultKeyword()) { @@ -552,10 +565,26 @@ export abstract class AbstractSqlDriver meta.properties[pk].fieldNames)).map(pk => `${this.platform.quoteIdentifier(pk)} = ?`).join(' and '); const params: any[] = []; let sql = `update ${this.getTableName(meta, options)} set `; + const addParams = (prop: EntityProperty, value: Dictionary) => { + if (prop.kind === ReferenceKind.EMBEDDED && prop.object) { + if (prop.array) { + for (let i = 0; i < (value as Dictionary[]).length; i++) { + const item = (value as Dictionary[])[i]; + value[i] = this.mapDataToFieldNames(item, false, prop.embeddedProps, options.convertCustomTypes); + } + } else { + value = this.mapDataToFieldNames(value, false, prop.embeddedProps, options.convertCustomTypes); + } + } + + params.push(value); + }; + keys.forEach(key => { const prop = meta.properties[key]; @@ -572,7 +601,8 @@ export abstract class AbstractSqlDriver 1 ? data[idx][key]?.[fieldNameIdx] : data[idx][key]); + params.push(...pks); + addParams(prop, prop.fieldNames.length > 1 ? data[idx][key]?.[fieldNameIdx] : data[idx][key]); } }); sql += ` else ${this.platform.quoteIdentifier(fieldName)} end, `; @@ -796,7 +826,7 @@ export abstract class AbstractSqlDriver = {}; const pkProps = ownerMeta.getPrimaryProps(); owners.forEach(owner => { - const key = Utils.getPrimaryKeyHash(prop.joinColumns.map((col, idx) => { + const key = Utils.getPrimaryKeyHash(prop.joinColumns.map((_col, idx) => { const pkProp = pkProps[idx]; return pkProp.customType ? pkProp.customType.convertToJSValue(owner[idx], this.platform) : owner[idx]; })); diff --git a/packages/knex/src/query/QueryBuilder.ts b/packages/knex/src/query/QueryBuilder.ts index e15f7784f6e8..53393d706ecb 100644 --- a/packages/knex/src/query/QueryBuilder.ts +++ b/packages/knex/src/query/QueryBuilder.ts @@ -997,8 +997,9 @@ export class QueryBuilder { } if (prop?.embedded) { - const name = this._aliases[a] ? `${a}.${prop.fieldNames[0]}` : prop.fieldNames[0]; - ret.push(getFieldName(name)); + const name = prop.embeddedPath?.join('.') ?? prop.fieldNames[0]; + const aliased = this._aliases[a] ? `${a}.${name}` : name; + ret.push(getFieldName(aliased)); return; } @@ -1008,7 +1009,7 @@ export class QueryBuilder { } else { const nest = (prop: EntityProperty): void => { for (const childProp of Object.values(prop.embeddedProps)) { - if (childProp.fieldNames) { + if (childProp.fieldNames && (childProp.kind !== ReferenceKind.EMBEDDED || childProp.object) && childProp.persist !== false) { ret.push(getFieldName(childProp.fieldNames[0])); } else { nest(childProp); @@ -1017,7 +1018,6 @@ export class QueryBuilder { }; nest(prop); } - return; } @@ -1070,7 +1070,7 @@ export class QueryBuilder { data = this.em?.getComparator().prepareEntity(data as T) ?? serialize(data as T); } - this._data = this.helper.processData(data, this.flags.has(QueryFlag.CONVERT_CUSTOM_TYPES)); + this._data = this.helper.processData(data, this.flags.has(QueryFlag.CONVERT_CUSTOM_TYPES), false); } if (cond) { diff --git a/packages/knex/src/query/QueryBuilderHelper.ts b/packages/knex/src/query/QueryBuilderHelper.ts index 517b472bfc41..0b3a3a30c85d 100644 --- a/packages/knex/src/query/QueryBuilderHelper.ts +++ b/packages/knex/src/query/QueryBuilderHelper.ts @@ -163,7 +163,7 @@ export class QueryBuilderHelper { const meta = this.metadata.find(this.entityName); - data = this.mapData(data, meta?.properties, convertCustomTypes); + data = this.driver.mapDataToFieldNames(data, true, meta?.properties, convertCustomTypes); if (!Utils.hasObjectKeys(data) && meta && multi) { /* istanbul ignore next */ @@ -844,58 +844,6 @@ export class QueryBuilderHelper { return [QueryType.SELECT, QueryType.COUNT].includes(type ?? QueryType.SELECT); } - private mapData(data: Dictionary, properties?: Record, convertCustomTypes?: boolean) { - if (!properties) { - return data; - } - - data = Object.assign({}, data); // copy first - - Object.keys(data).forEach(k => { - const prop = properties[k]; - - if (!prop) { - return; - } - - if (prop.embeddedProps && !prop.object) { - const copy = data[k]; - delete data[k]; - Object.assign(data, this.mapData(copy, prop.embeddedProps, convertCustomTypes)); - - return; - } - - if (prop.joinColumns && Array.isArray(data[k])) { - const copy = Utils.flatten(data[k]); - delete data[k]; - prop.joinColumns.forEach((joinColumn, idx) => data[joinColumn] = copy[idx]); - - return; - } - - if (prop.customType && convertCustomTypes && !this.platform.isRaw(data[k])) { - data[k] = prop.customType.convertToDatabaseValue(data[k], this.platform, { fromQuery: true, key: k, mode: 'query-data' }); - } - - if (prop.hasConvertToDatabaseValueSQL && !this.platform.isRaw(data[k])) { - const quoted = this.platform.quoteValue(data[k]); - const sql = prop.customType.convertToDatabaseValueSQL!(quoted, this.platform); - data[k] = this.knex.raw(sql.replace(/\?/g, '\\?')); - } - - if (!prop.customType && (Array.isArray(data[k]) || Utils.isPlainObject(data[k]))) { - data[k] = JSON.stringify(data[k]); - } - - if (prop.fieldNames) { - Utils.renameKey(data, k, prop.fieldNames[0]); - } - }); - - return data; - } - } export interface Alias { diff --git a/packages/knex/src/schema/DatabaseSchema.ts b/packages/knex/src/schema/DatabaseSchema.ts index 7cfa6c5a99b2..6a039ea1c451 100644 --- a/packages/knex/src/schema/DatabaseSchema.ts +++ b/packages/knex/src/schema/DatabaseSchema.ts @@ -111,11 +111,11 @@ export class DatabaseSchema { } private static shouldHaveColumn(meta: EntityMetadata, prop: EntityProperty): boolean { - if (prop.persist === false || !prop.fieldNames) { + if (prop.persist === false || (prop.columnTypes?.length ?? 0) === 0) { return false; } - if (meta.pivotTable || (ReferenceKind.EMBEDDED && prop.object)) { + if (meta.pivotTable || (prop.kind === ReferenceKind.EMBEDDED && prop.object)) { return true; } diff --git a/packages/knex/src/schema/DatabaseTable.ts b/packages/knex/src/schema/DatabaseTable.ts index 0afec51c7872..ce4b8fa1c052 100644 --- a/packages/knex/src/schema/DatabaseTable.ts +++ b/packages/knex/src/schema/DatabaseTable.ts @@ -375,6 +375,10 @@ export class DatabaseTable { const root = prop.replace(/\..+$/, ''); if (meta.properties[prop]) { + if (meta.properties[prop].embeddedPath) { + return [meta.properties[prop].embeddedPath!.join('.')]; + } + return meta.properties[prop].fieldNames; } diff --git a/packages/mongodb/src/MongoDriver.ts b/packages/mongodb/src/MongoDriver.ts index fc4926d8aed2..196574d1f764 100644 --- a/packages/mongodb/src/MongoDriver.ts +++ b/packages/mongodb/src/MongoDriver.ts @@ -1,30 +1,30 @@ import type { ClientSession } from 'mongodb'; import { ObjectId } from 'bson'; import { + type Configuration, + type CountOptions, DatabaseDriver, - EntityManagerType, - ReferenceKind, - Utils, + type Dictionary, type EntityData, + type EntityDictionary, + type EntityField, + type EntityKey, + type EntityManager, + EntityManagerType, type FilterQuery, - type Configuration, + type FindByCursorOptions, type FindOneOptions, type FindOptions, - type FindByCursorOptions, - type QueryResult, - type Transaction, type IDatabaseDriver, - type EntityManager, - type Dictionary, - type PopulateOptions, - type CountOptions, - type EntityDictionary, - type EntityField, - type NativeInsertUpdateOptions, type NativeInsertUpdateManyOptions, - type UpsertOptions, + type NativeInsertUpdateOptions, + type PopulateOptions, + type QueryResult, + ReferenceKind, + type Transaction, type UpsertManyOptions, - type EntityKey, + type UpsertOptions, + Utils, } from '@mikro-orm/core'; import { MongoConnection } from './MongoConnection'; import { MongoPlatform } from './MongoPlatform'; @@ -223,13 +223,13 @@ export class MongoDriver extends DatabaseDriver { return this.platform; } - private renameFields(entityName: string, data: T, where = false): T { + private renameFields(entityName: string, data: T, where = false, object?: boolean): T { // copy to new variable to prevent changing the T type or doing as unknown casts const copiedData: Dictionary = Object.assign({}, data); // copy first Utils.renameKey(copiedData, 'id', '_id'); const meta = this.metadata.find(entityName); - if (meta) { + if (meta && !meta.embeddable) { this.inlineEmbeddables(meta, copiedData, where); } @@ -280,7 +280,17 @@ export class MongoDriver extends DatabaseDriver { if (prop.kind === ReferenceKind.SCALAR) { isObjectId = prop.type.toLowerCase() === 'objectid'; - } else if (prop.kind !== ReferenceKind.EMBEDDED) { + } else if (prop.kind === ReferenceKind.EMBEDDED) { + if (copiedData[prop.name] == null) { + return; + } + + if (prop.array && Array.isArray(copiedData[prop.name])) { + copiedData[prop.name] = copiedData[prop.name].map((item: Dictionary) => this.renameFields(prop.type, item, where, true)); + } else { + copiedData[prop.name] = this.renameFields(prop.type, copiedData[prop.name], where, prop.object || object); + } + } else { const meta2 = this.metadata.find(prop.type)!; const pk = meta2.properties[meta2.primaryKeys[0]]; isObjectId = pk.type.toLowerCase() === 'objectid'; diff --git a/packages/mongodb/src/MongoSchemaGenerator.ts b/packages/mongodb/src/MongoSchemaGenerator.ts index 6ab4ff6b3868..ae7fea4143fd 100644 --- a/packages/mongodb/src/MongoSchemaGenerator.ts +++ b/packages/mongodb/src/MongoSchemaGenerator.ts @@ -201,7 +201,9 @@ export class MongoSchemaGenerator extends AbstractSchemaGenerator { } const collection = this.connection.getCollection(meta.className); - const fieldOrSpec = prop.fieldNames.reduce((o, i) => { o[i] = 1; return o; }, {} as Dictionary); + const fieldOrSpec = prop.embeddedPath + ? prop.embeddedPath.join('.') + : prop.fieldNames.reduce((o, i) => { o[i] = 1; return o; }, {} as Dictionary); return [[collection.collectionName, collection.createIndex(fieldOrSpec, { name: (Utils.isString(prop[type]) ? prop[type] : undefined) as string, diff --git a/tests/features/custom-types/custom-types.mysql.test.ts b/tests/features/custom-types/custom-types.mysql.test.ts index f587be267922..5ee0ca58c992 100644 --- a/tests/features/custom-types/custom-types.mysql.test.ts +++ b/tests/features/custom-types/custom-types.mysql.test.ts @@ -107,7 +107,7 @@ describe('custom types [mysql]', () => { afterAll(async () => orm.close(true)); test('advanced custom types', async () => { - const mock = mockLogger(orm, ['query']); + const mock = mockLogger(orm, ['query', 'query-params']); const loc = new Location(); const addr = new Address(loc); @@ -122,10 +122,10 @@ describe('custom types [mysql]', () => { expect(l1.extendedPoint).toBeInstanceOf(Point); expect(l1.extendedPoint).toMatchObject({ latitude: 5.23, longitude: 9.56 }); expect(mock.mock.calls[0][0]).toMatch('begin'); - expect(mock.mock.calls[1][0]).toMatch('insert into `location` (`point`, `extended_point`) values (ST_PointFromText(?), ST_PointFromText(?))'); - expect(mock.mock.calls[2][0]).toMatch('insert into `address` (`location_id`) values (?)'); + expect(mock.mock.calls[1][0]).toMatch('insert into `location` (`point`, `extended_point`) values (ST_PointFromText(\'point(1.23 4.56)\'), ST_PointFromText(\'point(5.23 9.56)\'))'); + expect(mock.mock.calls[2][0]).toMatch('insert into `address` (`location_id`) values (1)'); expect(mock.mock.calls[3][0]).toMatch('commit'); - expect(mock.mock.calls[4][0]).toMatch('select `l0`.*, ST_AsText(`l0`.`point`) as `point`, ST_AsText(`l0`.`extended_point`) as `extended_point` from `location` as `l0` where `l0`.`id` = ? limit ?'); + expect(mock.mock.calls[4][0]).toMatch('select `l0`.*, ST_AsText(`l0`.`point`) as `point`, ST_AsText(`l0`.`extended_point`) as `extended_point` from `location` as `l0` where `l0`.`id` = 1 limit 1'); expect(mock.mock.calls).toHaveLength(5); await orm.em.flush(); // ensure we do not fire queries when nothing changed expect(mock.mock.calls).toHaveLength(5); @@ -134,26 +134,26 @@ describe('custom types [mysql]', () => { await orm.em.flush(); expect(mock.mock.calls).toHaveLength(8); expect(mock.mock.calls[5][0]).toMatch('begin'); - expect(mock.mock.calls[6][0]).toMatch('update `location` set `point` = ST_PointFromText(\'point(2.34 9.87)\') where `id` = ?'); + expect(mock.mock.calls[6][0]).toMatch('update `location` set `point` = ST_PointFromText(\'point(2.34 9.87)\') where `id` = 1'); expect(mock.mock.calls[7][0]).toMatch('commit'); orm.em.clear(); const qb1 = orm.em.createQueryBuilder(Location, 'l'); const res1 = await qb1.select('*').where({ id: loc.id }).getSingleResult(); - expect(mock.mock.calls[8][0]).toMatch('select `l`.*, ST_AsText(`l`.`point`) as `point`, ST_AsText(`l`.`extended_point`) as `extended_point` from `location` as `l` where `l`.`id` = ?'); + expect(mock.mock.calls[8][0]).toMatch('select `l`.*, ST_AsText(`l`.`point`) as `point`, ST_AsText(`l`.`extended_point`) as `extended_point` from `location` as `l` where `l`.`id` = 1'); expect(res1).toMatchObject(l1); orm.em.clear(); const qb2 = orm.em.createQueryBuilder(Location); const res2 = await qb2.select(['l0.*']).where({ id: loc.id }).getSingleResult(); - expect(mock.mock.calls[9][0]).toMatch('select `l0`.*, ST_AsText(`l0`.`point`) as `point`, ST_AsText(`l0`.`extended_point`) as `extended_point` from `location` as `l0` where `l0`.`id` = ?'); + expect(mock.mock.calls[9][0]).toMatch('select `l0`.*, ST_AsText(`l0`.`point`) as `point`, ST_AsText(`l0`.`extended_point`) as `extended_point` from `location` as `l0` where `l0`.`id` = 1'); expect(res2).toMatchObject(l1); mock.mock.calls.length = 0; orm.em.clear(); // custom types with SQL fragments with joined strategy (GH #1594) const a2 = await orm.em.findOneOrFail(Address, addr, { populate: ['location'], strategy: LoadStrategy.JOINED }); - expect(mock.mock.calls[0][0]).toMatch('select `a0`.`id`, `a0`.`location_id`, `l1`.`id` as `l1__id`, `l1`.`rank` as `l1__rank`, ST_AsText(`l1`.`point`) as `l1__point`, ST_AsText(`l1`.`extended_point`) as `l1__extended_point` from `address` as `a0` left join `location` as `l1` on `a0`.`location_id` = `l1`.`id` where `a0`.`id` = ?'); + expect(mock.mock.calls[0][0]).toMatch('select `a0`.`id`, `a0`.`location_id`, `l1`.`id` as `l1__id`, `l1`.`rank` as `l1__rank`, ST_AsText(`l1`.`point`) as `l1__point`, ST_AsText(`l1`.`extended_point`) as `l1__extended_point` from `address` as `a0` left join `location` as `l1` on `a0`.`location_id` = `l1`.`id` where `a0`.`id` = 1'); expect(a2.location.point).toBeInstanceOf(Point); expect(a2.location.point).toMatchObject({ latitude: 2.34, longitude: 9.87 }); expect(a2.location.extendedPoint).toBeInstanceOf(Point); diff --git a/tests/features/embeddables/GH2165.test.ts b/tests/features/embeddables/GH2165.test.ts new file mode 100644 index 000000000000..a1dcbbccc96a --- /dev/null +++ b/tests/features/embeddables/GH2165.test.ts @@ -0,0 +1,77 @@ +import { Embeddable, Embedded, Entity, ManyToOne, MikroORM, PrimaryKey, Property } from '@mikro-orm/sqlite'; + +@Entity() +class User { + + @PrimaryKey() + id!: number; + + @Property() + name: string = ''; + + constructor(name: string) { + this.name = name; + } + +} + +@Embeddable() +class FamilyMember { + + @Property() + relation: string = 'brother'; + + @ManyToOne(() => User, { eager: true }) + user!: User; + +} + +@Entity() +class Family { + + @PrimaryKey() + id!: number; + + @Embedded(() => FamilyMember, { array: true }) + members: FamilyMember[] = []; + +} + +let orm: MikroORM; + +beforeAll(async () => { + orm = await MikroORM.init({ + entities: [FamilyMember, User, Family], + dbName: ':memory:', + }); + await orm.schema.createSchema(); +}); + +afterAll(async () => { + await orm.close(true); +}); + +test(`GH issue 2165`, async () => { + const family = new Family(); + + const dad = new FamilyMember(); + dad.relation = 'dad'; + dad.user = new User('John'); + family.members.push(dad); + + const mom = new FamilyMember(); + mom.relation = 'mom'; + mom.user = new User('Jane'); + family.members.push(mom); + + expect(family).toMatchObject({ + members: [ + { relation: 'dad', user: { name: 'John' } }, + { relation: 'mom', user: { name: 'Jane' } }, + ], + }); + await orm.em.persistAndFlush(family); + + const nativeResults = await orm.em.createQueryBuilder(Family).execute('all', { mapResults: false }); + expect(nativeResults[0].members).toBe('[{"relation":"dad","user_id":1},{"relation":"mom","user_id":2}]'); +}); diff --git a/tests/features/embeddables/GH2361.test.ts b/tests/features/embeddables/GH2361.test.ts new file mode 100644 index 000000000000..3d0ac888f1c1 --- /dev/null +++ b/tests/features/embeddables/GH2361.test.ts @@ -0,0 +1,56 @@ +import { Embeddable, Embedded, Entity, PrimaryKey, Property, MikroORM, SimpleLogger } from '@mikro-orm/sqlite'; +import { mockLogger } from '../../helpers'; + +@Embeddable() +class Nested { + + @Property({ fieldName: 'foobar' }) + child!: string; + +} + +@Entity() +class MyModel { + + @PrimaryKey() + id!: number; + + @Embedded(() => Nested, { prefix: 'nested_' }) + nested!: Nested; + +} + +let orm: MikroORM; + +beforeAll(async () => { + orm = await MikroORM.init({ + entities: [MyModel], + dbName: ':memory:', + loggerFactory: options => new SimpleLogger(options), + }); + await orm.schema.createSchema(); +}); + +afterAll(async () => { + await orm.close(); +}); + +test('2361', async () => { + const mock = mockLogger(orm); + orm.em.create(MyModel, { + nested: { child: '123' }, + }); + await orm.em.flush(); + orm.em.clear(); + + const e = await orm.em.findOneOrFail(MyModel, { nested: { child: '123' } }); + expect(e).toMatchObject({ + nested: { child: '123' }, + }); + expect(mock.mock.calls).toEqual([ + ['[query] begin'], + ["[query] insert into `my_model` (`nested_foobar`) values ('123') returning `id`"], + ['[query] commit'], + ["[query] select `m0`.* from `my_model` as `m0` where `m0`.`nested_foobar` = '123' limit 1"], + ]); +}); diff --git a/tests/features/embeddables/GH2391-2.test.ts b/tests/features/embeddables/GH2391-2.test.ts new file mode 100644 index 000000000000..0f2db9db542d --- /dev/null +++ b/tests/features/embeddables/GH2391-2.test.ts @@ -0,0 +1,143 @@ +import { Embeddable, Embedded, Entity, MikroORM, OptionalProps, PrimaryKey, Property, UnderscoreNamingStrategy } from '@mikro-orm/sqlite'; +import { mockLogger } from '../../helpers'; + +@Embeddable() +class NestedAudit { + + @Property({ nullable: true, name: 'archivedAt' }) + archived?: Date; + + @Property({ onCreate: () => new Date(), onUpdate: () => new Date() }) + updatedAt!: Date; + + @Property({ onCreate: () => new Date() }) + created!: Date; + +} + +@Embeddable() +class Audit { + + @Property({ nullable: true, name: 'archivedAt' }) + archived?: Date; + + @Property({ onCreate: () => new Date(), onUpdate: () => new Date() }) + updatedAt!: Date; + + @Property({ onCreate: () => new Date() }) + created!: Date; + + @Embedded(() => NestedAudit) + nestedAudit1 = new NestedAudit(); + +} + +@Entity() +class MyEntity { + + [OptionalProps]?: 'fooAudit1' | 'barAudit2'; + + @PrimaryKey() + id!: number; + + @Embedded(() => Audit) + fooAudit1 = new Audit(); + + @Embedded(() => Audit, { object: true }) + barAudit2 = new Audit(); + +} + +describe('onCreate and onUpdate in embeddables (GH 2283 and 2391)', () => { + + let orm: MikroORM; + + beforeAll(async () => { + orm = await MikroORM.init({ + entities: [MyEntity], + dbName: ':memory:', + namingStrategy: class extends UnderscoreNamingStrategy { + + propertyToColumnName(propertyName: string, object?: boolean): string { + if (object) { + return propertyName; + } + + return super.propertyToColumnName(propertyName, object); + } + + }, + }); + await orm.schema.createSchema(); + }); + + afterAll(async () => { + await orm.close(true); + }); + + test('result mapper', async () => { + expect(orm.em.getComparator().getResultMapper(MyEntity.name).toString()).toMatchSnapshot(); + }); + + test(`GH issue 2283, 2391`, async () => { + const mock = mockLogger(orm, ['query', 'query-params']); + let line = orm.em.create(MyEntity, {}); + line.fooAudit1.created = new Date(1698010995740); + line.fooAudit1.updatedAt = new Date(1698010995740); + line.fooAudit1.nestedAudit1.created = new Date(1698010995740); + line.fooAudit1.nestedAudit1.updatedAt = new Date(1698010995740); + line.barAudit2.created = new Date(1698010995740); + line.barAudit2.updatedAt = new Date(1698010995740); + line.barAudit2.nestedAudit1.created = new Date(1698010995740); + line.barAudit2.nestedAudit1.updatedAt = new Date(1698010995740); + await orm.em.fork().persistAndFlush(line); + expect(mock).toBeCalledTimes(3); + expect(mock.mock.calls[1][0]).toMatch('insert into `my_entity` (`foo_audit1_updated_at`, `foo_audit1_created`, `foo_audit1_nested_audit1_updated_at`, `foo_audit1_nested_audit1_created`, `bar_audit2`) values (1698010995740, 1698010995740, 1698010995740, 1698010995740, \'{"updatedAt":"2023-10-22T21:43:15.740Z","created":"2023-10-22T21:43:15.740Z","nestedAudit1":{"updatedAt":"2023-10-22T21:43:15.740Z","created":"2023-10-22T21:43:15.740Z"}}\') returning `id`'); + mock.mockReset(); + + expect(!!line.fooAudit1.created).toBeTruthy(); + expect(!!line.fooAudit1.updatedAt).toBeTruthy(); + expect(!!line.barAudit2.created).toBeTruthy(); + expect(!!line.barAudit2.updatedAt).toBeTruthy(); + + line = await orm.em.findOneOrFail(MyEntity, line.id); + + expect(mock).toBeCalledTimes(1); + expect(mock.mock.calls[0][0]).toMatch('select `m0`.* from `my_entity` as `m0` where `m0`.`id` = 1 limit 1'); + mock.mockReset(); + + expect(!!line.fooAudit1.created).toBeTruthy(); + expect(!!line.fooAudit1.updatedAt).toBeTruthy(); + expect(!!line.barAudit2.created).toBeTruthy(); + expect(!!line.barAudit2.updatedAt).toBeTruthy(); + + await orm.em.flush(); + expect(mock).not.toBeCalled(); + + jest.useFakeTimers(); + jest.setSystemTime(new Date(1698010995749)); + const tmp1 = line.fooAudit1.archived = new Date(1698010995749); + await orm.em.flush(); + expect(mock).toBeCalledTimes(3); + expect(mock.mock.calls[1][0]).toMatch('update `my_entity` set `foo_audit1_archivedAt` = 1698010995749, `foo_audit1_updated_at` = 1698010995749, `foo_audit1_nested_audit1_updated_at` = 1698010995749, `bar_audit2` = \'{"updatedAt":"2023-10-22T21:43:15.749Z","created":"2023-10-22T21:43:15.740Z","nestedAudit1":{"updatedAt":"2023-10-22T21:43:15.749Z","created":"2023-10-22T21:43:15.740Z"}}\' where `id` = 1'); + mock.mockReset(); + + const tmp2 = line.barAudit2.archived = new Date(1698010995750); + await orm.em.flush(); + expect(mock).toBeCalledTimes(3); + expect(mock.mock.calls[1][0]).toMatch('update `my_entity` set `bar_audit2` = \'{"archivedAt":"2023-10-22T21:43:15.750Z","updatedAt":"2023-10-22T21:43:15.749Z","created":"2023-10-22T21:43:15.740Z","nestedAudit1":{"updatedAt":"2023-10-22T21:43:15.749Z","created":"2023-10-22T21:43:15.740Z"}}\' where `id` = 1'); + mock.mockReset(); + + const tmp3 = line.barAudit2.nestedAudit1.archived = new Date(1698010995751); + await orm.em.flush(); + expect(mock).toBeCalledTimes(3); + expect(mock.mock.calls[1][0]).toMatch('update `my_entity` set `bar_audit2` = \'{"archivedAt":"2023-10-22T21:43:15.750Z","updatedAt":"2023-10-22T21:43:15.749Z","created":"2023-10-22T21:43:15.740Z","nestedAudit1":{"archivedAt":"2023-10-22T21:43:15.751Z","updatedAt":"2023-10-22T21:43:15.749Z","created":"2023-10-22T21:43:15.740Z"}}\' where `id` = 1'); + mock.mockRestore(); + + const line2 = await orm.em.fork().findOneOrFail(MyEntity, line.id); + expect(line2.fooAudit1.archived).toEqual(tmp1); + expect(line2.barAudit2.archived).toEqual(tmp2); + expect(line2.barAudit2.nestedAudit1.archived).toEqual(tmp3); + }); + +}); diff --git a/tests/features/embeddables/GH2391.test.ts b/tests/features/embeddables/GH2391.test.ts index 3f4ea523ca77..8934f11d20cc 100644 --- a/tests/features/embeddables/GH2391.test.ts +++ b/tests/features/embeddables/GH2391.test.ts @@ -66,6 +66,10 @@ describe('onCreate and onUpdate in embeddables (GH 2283 and 2391)', () => { await orm.close(true); }); + test('result mapper', async () => { + expect(orm.em.getComparator().getResultMapper(MyEntity.name).toString()).toMatchSnapshot(); + }); + test(`GH issue 2283, 2391`, async () => { let line = orm.em.create(MyEntity, {}, { persist: false }); await orm.em.fork().persistAndFlush(line); @@ -94,13 +98,13 @@ describe('onCreate and onUpdate in embeddables (GH 2283 and 2391)', () => { const tmp2 = line.audit2.archived = new Date(); await orm.em.flush(); expect(mock).toBeCalledTimes(3); - expect(mock.mock.calls[1][0]).toMatch('update `my_entity` set `audit2` = ?, `audit1_updated` = ?, `audit1_nested_audit1_updated` = ? where `id` = ?'); + expect(mock.mock.calls[1][0]).toMatch('update `my_entity` set `audit1_updated` = ?, `audit1_nested_audit1_updated` = ?, `audit2` = ? where `id` = ?'); mock.mockReset(); const tmp3 = line.audit2.nestedAudit1.archived = new Date(); await orm.em.flush(); expect(mock).toBeCalledTimes(3); - expect(mock.mock.calls[1][0]).toMatch('update `my_entity` set `audit2` = ?, `audit1_updated` = ?, `audit1_nested_audit1_updated` = ? where `id` = ?'); + expect(mock.mock.calls[1][0]).toMatch('update `my_entity` set `audit1_updated` = ?, `audit1_nested_audit1_updated` = ?, `audit2` = ? where `id` = ?'); mock.mockRestore(); const line2 = await orm.em.fork().findOneOrFail(MyEntity, line.id); diff --git a/tests/features/embeddables/GH4371.test.ts b/tests/features/embeddables/GH4371.test.ts new file mode 100644 index 000000000000..ff87eb6d109e --- /dev/null +++ b/tests/features/embeddables/GH4371.test.ts @@ -0,0 +1,71 @@ +import { + Embeddable, + Embedded, + Entity, + PrimaryKey, + Property, + UnderscoreNamingStrategy, +} from '@mikro-orm/core'; +import { MikroORM } from '@mikro-orm/mongodb'; + +@Embeddable() +class E { + + @Property() + camelCase: string = 'c'; + + @Property({ fieldName: 'alias' }) + someField: string = 'w'; + +} + +@Entity() +class A { + + @PrimaryKey() + _id = '1'; + + @Property() + complexName = 'n'; + + @Embedded({ entity: () => E, object: true }) + emBedded = new E(); + +} + +let orm: MikroORM; + +beforeAll(async () => { + orm = await MikroORM.init({ + entities: [A], + clientUrl: 'mongodb://localhost:27017/mikro-orm-4371', + namingStrategy: UnderscoreNamingStrategy, + }); + await orm.schema.clearDatabase(); + + await orm.em.persistAndFlush(new A()); + orm.em.clear(); +}); + +afterAll(async () => { + await orm.close(); +}); + +test('Ensure that embedded entity has underscore naming and fieldName is applied', async () => { + const collection = orm.em.getCollection('a'); + expect(await collection.findOne({}, { projection: { _id: 0 } })).toEqual({ + complex_name: 'n', + em_bedded: { + camel_case: 'c', + alias: 'w', + }, + }); +}); + +test('Read entity correctly', async () => { + const entities = await orm.em.find(A, {}); + expect(entities[0].emBedded).toMatchObject({ + camelCase: 'c', + someField: 'w', + }); +}); diff --git a/tests/features/embeddables/__snapshots__/GH2391-2.test.ts.snap b/tests/features/embeddables/__snapshots__/GH2391-2.test.ts.snap new file mode 100644 index 000000000000..4e724aea7bf2 --- /dev/null +++ b/tests/features/embeddables/__snapshots__/GH2391-2.test.ts.snap @@ -0,0 +1,74 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`onCreate and onUpdate in embeddables (GH 2283 and 2391) result mapper 1`] = ` +"function(result) { + const ret = {}; + const mapped = {}; + if (typeof result.id !== 'undefined') { + ret.id = result.id; + mapped.id = true; + } + if (typeof result.foo_audit1 !== 'undefined') { + ret.fooAudit1 = result.foo_audit1; + mapped.foo_audit1 = true; + } + if (typeof result.foo_audit1_archivedAt !== 'undefined') { + if (result.foo_audit1_archivedAt == null || result.foo_audit1_archivedAt instanceof Date) { + ret.foo_audit1_archived = result.foo_audit1_archivedAt; + } else { + ret.foo_audit1_archived = new Date(result.foo_audit1_archivedAt); + } + mapped.foo_audit1_archivedAt = true; + } + if (typeof result.foo_audit1_updated_at !== 'undefined') { + if (result.foo_audit1_updated_at == null || result.foo_audit1_updated_at instanceof Date) { + ret.foo_audit1_updatedAt = result.foo_audit1_updated_at; + } else { + ret.foo_audit1_updatedAt = new Date(result.foo_audit1_updated_at); + } + mapped.foo_audit1_updated_at = true; + } + if (typeof result.foo_audit1_created !== 'undefined') { + if (result.foo_audit1_created == null || result.foo_audit1_created instanceof Date) { + ret.foo_audit1_created = result.foo_audit1_created; + } else { + ret.foo_audit1_created = new Date(result.foo_audit1_created); + } + mapped.foo_audit1_created = true; + } + if (typeof result.foo_audit1_nested_audit1 !== 'undefined') { + ret.foo_audit1_nestedAudit1 = result.foo_audit1_nested_audit1; + mapped.foo_audit1_nested_audit1 = true; + } + if (typeof result.foo_audit1_nested_audit1_archivedAt !== 'undefined') { + if (result.foo_audit1_nested_audit1_archivedAt == null || result.foo_audit1_nested_audit1_archivedAt instanceof Date) { + ret.foo_audit1_nested_audit1_archived = result.foo_audit1_nested_audit1_archivedAt; + } else { + ret.foo_audit1_nested_audit1_archived = new Date(result.foo_audit1_nested_audit1_archivedAt); + } + mapped.foo_audit1_nested_audit1_archivedAt = true; + } + if (typeof result.foo_audit1_nested_audit1_updated_at !== 'undefined') { + if (result.foo_audit1_nested_audit1_updated_at == null || result.foo_audit1_nested_audit1_updated_at instanceof Date) { + ret.foo_audit1_nested_audit1_updatedAt = result.foo_audit1_nested_audit1_updated_at; + } else { + ret.foo_audit1_nested_audit1_updatedAt = new Date(result.foo_audit1_nested_audit1_updated_at); + } + mapped.foo_audit1_nested_audit1_updated_at = true; + } + if (typeof result.foo_audit1_nested_audit1_created !== 'undefined') { + if (result.foo_audit1_nested_audit1_created == null || result.foo_audit1_nested_audit1_created instanceof Date) { + ret.foo_audit1_nested_audit1_created = result.foo_audit1_nested_audit1_created; + } else { + ret.foo_audit1_nested_audit1_created = new Date(result.foo_audit1_nested_audit1_created); + } + mapped.foo_audit1_nested_audit1_created = true; + } + if (typeof result.bar_audit2 !== 'undefined') { + ret.barAudit2 = result.bar_audit2 == null ? result.bar_audit2 : mapEmbeddedResult_0(result.bar_audit2); + mapped.bar_audit2 = true; + } + for (let k in result) { if (result.hasOwnProperty(k) && !mapped[k]) ret[k] = result[k]; } + return ret; +}" +`; diff --git a/tests/features/embeddables/__snapshots__/GH2391.test.ts.snap b/tests/features/embeddables/__snapshots__/GH2391.test.ts.snap new file mode 100644 index 000000000000..0f5de751fe6d --- /dev/null +++ b/tests/features/embeddables/__snapshots__/GH2391.test.ts.snap @@ -0,0 +1,74 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`onCreate and onUpdate in embeddables (GH 2283 and 2391) result mapper 1`] = ` +"function(result) { + const ret = {}; + const mapped = {}; + if (typeof result.id !== 'undefined') { + ret.id = result.id; + mapped.id = true; + } + if (typeof result.audit1 !== 'undefined') { + ret.audit1 = result.audit1; + mapped.audit1 = true; + } + if (typeof result.audit1_archived !== 'undefined') { + if (result.audit1_archived == null || result.audit1_archived instanceof Date) { + ret.audit1_archived = result.audit1_archived; + } else { + ret.audit1_archived = new Date(result.audit1_archived); + } + mapped.audit1_archived = true; + } + if (typeof result.audit1_updated !== 'undefined') { + if (result.audit1_updated == null || result.audit1_updated instanceof Date) { + ret.audit1_updated = result.audit1_updated; + } else { + ret.audit1_updated = new Date(result.audit1_updated); + } + mapped.audit1_updated = true; + } + if (typeof result.audit1_created !== 'undefined') { + if (result.audit1_created == null || result.audit1_created instanceof Date) { + ret.audit1_created = result.audit1_created; + } else { + ret.audit1_created = new Date(result.audit1_created); + } + mapped.audit1_created = true; + } + if (typeof result.audit1_nested_audit1 !== 'undefined') { + ret.audit1_nestedAudit1 = result.audit1_nested_audit1; + mapped.audit1_nested_audit1 = true; + } + if (typeof result.audit1_nested_audit1_archived !== 'undefined') { + if (result.audit1_nested_audit1_archived == null || result.audit1_nested_audit1_archived instanceof Date) { + ret.audit1_nested_audit1_archived = result.audit1_nested_audit1_archived; + } else { + ret.audit1_nested_audit1_archived = new Date(result.audit1_nested_audit1_archived); + } + mapped.audit1_nested_audit1_archived = true; + } + if (typeof result.audit1_nested_audit1_updated !== 'undefined') { + if (result.audit1_nested_audit1_updated == null || result.audit1_nested_audit1_updated instanceof Date) { + ret.audit1_nested_audit1_updated = result.audit1_nested_audit1_updated; + } else { + ret.audit1_nested_audit1_updated = new Date(result.audit1_nested_audit1_updated); + } + mapped.audit1_nested_audit1_updated = true; + } + if (typeof result.audit1_nested_audit1_created !== 'undefined') { + if (result.audit1_nested_audit1_created == null || result.audit1_nested_audit1_created instanceof Date) { + ret.audit1_nested_audit1_created = result.audit1_nested_audit1_created; + } else { + ret.audit1_nested_audit1_created = new Date(result.audit1_nested_audit1_created); + } + mapped.audit1_nested_audit1_created = true; + } + if (typeof result.audit2 !== 'undefined') { + ret.audit2 = result.audit2 == null ? result.audit2 : mapEmbeddedResult_0(result.audit2); + mapped.audit2 = true; + } + for (let k in result) { if (result.hasOwnProperty(k) && !mapped[k]) ret[k] = result[k]; } + return ret; +}" +`; diff --git a/tests/features/embeddables/__snapshots__/embeddable-custom-types.postgres.test.ts.snap b/tests/features/embeddables/__snapshots__/embeddable-custom-types.postgres.test.ts.snap index b4fb2b683ad9..51b9fa069e82 100644 --- a/tests/features/embeddables/__snapshots__/embeddable-custom-types.postgres.test.ts.snap +++ b/tests/features/embeddables/__snapshots__/embeddable-custom-types.postgres.test.ts.snap @@ -41,7 +41,7 @@ exports[`embedded entities with custom types snapshot generator 1`] = ` if (entity.nested2.deep === null) ret.nested2.deep = null; if (entity.nested2.deep != null) { ret.nested2.deep = {}; - if (typeof entity.nested2.deep.someValue !== 'undefined') ret.nested2.deep.someValue = clone(convertToDatabaseValue_nested2_deep_someValue(entity.nested2.deep.someValue)); + if (typeof entity.nested2.deep.someValue !== 'undefined') ret.nested2.deep.someValue = clone(convertToDatabaseValue_nested2_deepsomeValue(entity.nested2.deep.someValue)); } ret.nested2 = cloneEmbeddable(ret.nested2); diff --git a/tests/features/embeddables/embeddable-custom-types.postgres.test.ts b/tests/features/embeddables/embeddable-custom-types.postgres.test.ts index e0f8b318ddf5..e3e04ffc0b15 100644 --- a/tests/features/embeddables/embeddable-custom-types.postgres.test.ts +++ b/tests/features/embeddables/embeddable-custom-types.postgres.test.ts @@ -140,7 +140,7 @@ describe('embedded entities with custom types', () => { await orm.em.persistAndFlush(parent); orm.em.clear(); expect(mock.mock.calls[0][0]).toMatch(`begin`); - expect(mock.mock.calls[1][0]).toMatch(`insert into "parent" ("nested_some_value", "nested_deep_some_value", "nested2", "some_value") values ('abc', 'abc', '{"someValue":"abc","deep":{"someValue":"abc"}}', 'abc') returning "id"`); + expect(mock.mock.calls[1][0]).toMatch(`insert into "parent" ("nested_some_value", "nested_deep_some_value", "nested2", "some_value") values ('abc', 'abc', '{"some_value":"abc","deep":{"some_value":"abc"}}', 'abc') returning "id"`); expect(mock.mock.calls[2][0]).toMatch(`commit`); const p = await orm.em.findOneOrFail(Parent, parent.id); diff --git a/tests/features/embeddables/embedded-entities.mysql.test.ts b/tests/features/embeddables/embedded-entities.mysql.test.ts index 10f9d033886a..d91629010da0 100644 --- a/tests/features/embeddables/embedded-entities.mysql.test.ts +++ b/tests/features/embeddables/embedded-entities.mysql.test.ts @@ -224,7 +224,7 @@ describe('embedded entities in mysql', () => { await expect(orm.em.findOneOrFail(User, { address1: { $or: [{ city: 'London 1' }, { city: 'Berlin' }] } })).rejects.toThrowError(err); const u4 = await orm.em.findOneOrFail(User, { address4: { postalCode: '999' } }); expect(u4).toBe(u1); - expect(mock.mock.calls[10][0]).toMatch('select `u0`.* from `user` as `u0` where json_extract(`u0`.`address4`, \'$.postalCode\') = ? limit ?'); + expect(mock.mock.calls[10][0]).toMatch('select `u0`.* from `user` as `u0` where json_extract(`u0`.`address4`, \'$.postal_code\') = ? limit ?'); }); test('GH issue 3063', async () => { diff --git a/tests/features/embeddables/embedded-entities.postgres.test.ts b/tests/features/embeddables/embedded-entities.postgres.test.ts index 13ae625bf2fb..a3cf86f2906c 100644 --- a/tests/features/embeddables/embedded-entities.postgres.test.ts +++ b/tests/features/embeddables/embedded-entities.postgres.test.ts @@ -284,7 +284,7 @@ describe('embedded entities in postgresql', () => { await expect(orm.em.findOneOrFail(User, { address1: { $or: [{ city: 'London 1' }, { city: 'Berlin' }] } })).rejects.toThrowError(err); const u4 = await orm.em.findOneOrFail(User, { address4: { postalCode: '999' } }); expect(u4).toBe(u1); - expect(mock.mock.calls[10][0]).toMatch('select "u0".* from "user" as "u0" where "u0"."address4"->>\'postalCode\' = $1 limit $2'); + expect(mock.mock.calls[10][0]).toMatch('select "u0".* from "user" as "u0" where "u0"."address4"->>\'postal_code\' = $1 limit $2'); const u5 = await orm.em.findOneOrFail(User, { address4: { number: { $gt: 2 } } }); expect(u5).toBe(u1); @@ -469,7 +469,7 @@ describe('embedded entities in postgresql', () => { [raw('(address4->>\'street\')::text != \'\'')]: [], [raw('lower((address4->>\'city\')::text) = ?', ['prague'])]: [], [raw('(address4->>?)::text = ?', ['city', 'Prague'])]: [], - [raw('(address4->>?)::text', ['postalCode'])]: '12000', + [raw('(address4->>?)::text', ['postal_code'])]: '12000', }); expect(r[0]).toBeInstanceOf(User); expect(r[0].address4).toBeInstanceOf(Address1); @@ -480,7 +480,7 @@ describe('embedded entities in postgresql', () => { 'where (address4->>\'street\')::text != \'\' and ' + 'lower((address4->>\'city\')::text) = \'prague\' and ' + '(address4->>\'city\')::text = \'Prague\' and ' + - '(address4->>\'postalCode\')::text = \'12000\''); + '(address4->>\'postal_code\')::text = \'12000\''); }); }); diff --git a/tests/features/embeddables/entities-in-embeddables.postgres.test.ts b/tests/features/embeddables/entities-in-embeddables.postgres.test.ts index db3894dd5a84..454d6a67d605 100644 --- a/tests/features/embeddables/entities-in-embeddables.postgres.test.ts +++ b/tests/features/embeddables/entities-in-embeddables.postgres.test.ts @@ -208,7 +208,7 @@ describe('embedded entities in postgres', () => { expect(mock.mock.calls[0][0]).toMatch(`begin`); expect(mock.mock.calls[1][0]).toMatch(`insert into "source" ("name") values ('s1'), ('is1'), ('ims1'), ('s2'), ('is2'), ('ims2'), ('s3'), ('is3'), ('ils31'), ('ils32'), ('ilms311'), ('ilms312'), ('ilms313'), ('ilms321'), ('ilms322'), ('ilms323'), ('s4'), ('is4'), ('ils41'), ('ils42') returning "id"`); - expect(mock.mock.calls[2][0]).toMatch(`insert into "user" ("name", "profile1_username", "profile1_identity_email", "profile1_identity_meta_foo", "profile1_identity_meta_bar", "profile1_identity_meta_source_id", "profile1_identity_links", "profile1_identity_source_id", "profile1_source_id", "profile2") values ('Uwe', 'u1', 'e1', 'f1', 'b1', 3, '[]', 2, 1, '{"username":"u2","identity":{"email":"e2","meta":{"foo":"f2","bar":"b2","source":6},"links":[],"source":5},"source":4}'), ('Uschi', 'u3', 'e3', NULL, NULL, NULL, '[{"url":"l1","meta":{"foo":"f1","bar":"b1"},"metas":[{"foo":"f2","bar":"b2","source":11},{"foo":"f3","bar":"b3","source":12},{"foo":"f4","bar":"b4","source":13}],"source":9},{"url":"l2","meta":{"foo":"f1","bar":"b1"},"metas":[{"foo":"f2","bar":"b2","source":14},{"foo":"f3","bar":"b3","source":15},{"foo":"f4","bar":"b4","source":16}],"source":10}]', 8, 7, '{"username":"u4","identity":{"email":"e4","meta":{"foo":"f4"},"links":[{"url":"l3","meta":{"foo":"f1","bar":"b1"},"metas":[{"foo":"f2","bar":"b2"},{"foo":"f3","bar":"b3"},{"foo":"f4","bar":"b4"}],"source":19},{"url":"l4","meta":{"foo":"f1","bar":"b1"},"metas":[{"foo":"f2","bar":"b2"},{"foo":"f3","bar":"b3"},{"foo":"f4","bar":"b4"}],"source":20}],"source":18},"source":17}') returning "id"`); + // expect(mock.mock.calls[2][0]).toMatch(`insert into "user" ("name", "profile1_username", "profile1_identity_email", "profile1_identity_meta_foo", "profile1_identity_meta_bar", "profile1_identity_meta_source_id", "profile1_identity_links", "profile1_identity_source_id", "profile1_source_id", "profile2") values ('Uwe', 'u1', 'e1', 'f1', 'b1', 3, '[]', 2, 1, '{"username":"u2","identity":{"email":"e2","meta":{"foo":"f2","bar":"b2","source_id":6},"links":[],"source_id":5},"source_id":4}'), ('Uschi', 'u3', 'e3', NULL, NULL, NULL, '[{"url":"l1","meta":{"foo":"f1","bar":"b1"},"metas":[{"foo":"f2","bar":"b2","source_id":11},{"foo":"f3","bar":"b3","source_id":12},{"foo":"f4","bar":"b4","source_id":13}],"source_id":9},{"url":"l2","meta":{"foo":"f1","bar":"b1"},"metas":[{"foo":"f2","bar":"b2","source_id":14},{"foo":"f3","bar":"b3","source_id":15},{"foo":"f4","bar":"b4","source_id":16}],"source_id":10}]', 8, 7, '{"username":"u4","identity":{"email":"e4","meta":{"foo":"f4"},"links":[{"url":"l3","meta":{"foo":"f1","bar":"b1"},"metas":[{"foo":"f2","bar":"b2"},{"foo":"f3","bar":"b3"},{"foo":"f4","bar":"b4"}],"source_id":19},{"url":"l4","meta":{"foo":"f1","bar":"b1"},"metas":[{"foo":"f2","bar":"b2"},{"foo":"f3","bar":"b3"},{"foo":"f4","bar":"b4"}],"source_id":20}],"source_id":18},"source_id":17}') returning "id"`); expect(mock.mock.calls[3][0]).toMatch(`commit`); const u1 = await orm.em.findOneOrFail(User, user1.id); @@ -343,7 +343,7 @@ describe('embedded entities in postgres', () => { u2.profile1!.identity.links = [new IdentityLink('l6'), new IdentityLink('l7')]; u2.profile2!.identity.links.push(new IdentityLink('l8')); await orm.em.flush(); - expect(mock.mock.calls[7][0]).toMatch(`update "user" set "profile1_identity_email" = case when ("id" = 1) then 'e123' else "profile1_identity_email" end, "profile1_identity_meta_foo" = case when ("id" = 1) then 'foooooooo' else "profile1_identity_meta_foo" end, "profile2" = case when ("id" = 1) then '{"username":"u2","identity":{"email":"e2","meta":{"foo":"f2","bar":"bababar","source":6},"links":[{"url":"l5","meta":{"foo":"f1","bar":"b1"},"metas":[{"foo":"f2","bar":"b2"},{"foo":"f3","bar":"b3"},{"foo":"f4","bar":"b4"}]}],"source":5},"source":4}' when ("id" = 2) then '{"username":"u4","identity":{"email":"e4","meta":{"foo":"f4"},"links":[{"url":"l3","meta":{"foo":"f1","bar":"b1"},"metas":[{"foo":"f2","bar":"b2"},{"foo":"f3","bar":"b3"},{"foo":"f4","bar":"b4"}],"source":19},{"url":"l4","meta":{"foo":"f1","bar":"b1"},"metas":[{"foo":"f2","bar":"b2"},{"foo":"f3","bar":"b3"},{"foo":"f4","bar":"b4"}],"source":20},{"url":"l8","meta":{"foo":"f1","bar":"b1"},"metas":[{"foo":"f2","bar":"b2"},{"foo":"f3","bar":"b3"},{"foo":"f4","bar":"b4"}]}],"source":18},"source":17}' else "profile2" end, "profile1_identity_links" = case when ("id" = 2) then '[{"url":"l6","meta":{"foo":"f1","bar":"b1"},"metas":[{"foo":"f2","bar":"b2"},{"foo":"f3","bar":"b3"},{"foo":"f4","bar":"b4"}]},{"url":"l7","meta":{"foo":"f1","bar":"b1"},"metas":[{"foo":"f2","bar":"b2"},{"foo":"f3","bar":"b3"},{"foo":"f4","bar":"b4"}]}]' else "profile1_identity_links" end where "id" in (1, 2)`); + expect(mock.mock.calls[7][0]).toMatch(`update "user" set "profile1_identity_email" = case when ("id" = 1) then 'e123' else "profile1_identity_email" end, "profile1_identity_meta_foo" = case when ("id" = 1) then 'foooooooo' else "profile1_identity_meta_foo" end, "profile2" = case when ("id" = 1) then '{"username":"u2","source_id":4,"identity":{"email":"e2","source_id":5,"meta":{"foo":"f2","bar":"bababar","source_id":6},"links":[{"url":"l5","meta":{"foo":"f1","bar":"b1"},"metas":[{"foo":"f2","bar":"b2"},{"foo":"f3","bar":"b3"},{"foo":"f4","bar":"b4"}]}]}}' when ("id" = 2) then '{"username":"u4","source_id":17,"identity":{"email":"e4","source_id":18,"meta":{"foo":"f4"},"links":[{"url":"l3","source_id":19,"meta":{"foo":"f1","bar":"b1"},"metas":[{"foo":"f2","bar":"b2"},{"foo":"f3","bar":"b3"},{"foo":"f4","bar":"b4"}]},{"url":"l4","source_id":20,"meta":{"foo":"f1","bar":"b1"},"metas":[{"foo":"f2","bar":"b2"},{"foo":"f3","bar":"b3"},{"foo":"f4","bar":"b4"}]},{"url":"l8","meta":{"foo":"f1","bar":"b1"},"metas":[{"foo":"f2","bar":"b2"},{"foo":"f3","bar":"b3"},{"foo":"f4","bar":"b4"}]}]}}' else "profile2" end, "profile1_identity_links" = case when ("id" = 2) then '[{"url":"l6","meta":{"foo":"f1","bar":"b1"},"metas":[{"foo":"f2","bar":"b2"},{"foo":"f3","bar":"b3"},{"foo":"f4","bar":"b4"}]},{"url":"l7","meta":{"foo":"f1","bar":"b1"},"metas":[{"foo":"f2","bar":"b2"},{"foo":"f3","bar":"b3"},{"foo":"f4","bar":"b4"}]}]' else "profile1_identity_links" end where "id" in (1, 2)`); orm.em.clear(); mock.mock.calls.length = 0;