Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(value-objects) - Add value objects to mikro-orm #5000

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/core/src/decorators/Property.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@ import type {
EntityKey,
} from '../typings';
import type { Type, types } from '../types';
import { ValueObject } from '@mikro-orm/core';
import { VoType } from '../types/VoType';

export function Property<T extends object>(options: PropertyOptions<T> = {}) {
return function (target: any, propertyName: string) {
const meta = MetadataStorage.getMetadataFromDecorator(target.constructor as T);
const type = Utils.detectType(target, propertyName);
const desc = Object.getOwnPropertyDescriptor(target, propertyName) || {};
MetadataValidator.validateSingleDecorator(meta, propertyName, ReferenceKind.SCALAR);
const name = options.name || propertyName;
Expand All @@ -38,6 +41,17 @@ export function Property<T extends object>(options: PropertyOptions<T> = {}) {
prop.name = name as EntityKey<T>;
}

if (type && Utils.extendsFrom(ValueObject, type.prototype)) {
let instance = new type(null, true).getDatabaseValues();
prop.length = instance.max;
prop.precision = instance.precision;
prop.scale = instance.scale;
prop.type = instance.type;
prop.customType = new VoType(type);
prop.isValueObject = true;
instance = null; // Garbage collector
}

if (check) {
meta.checks.push({ property: prop.name, expression: check });
}
Expand Down
11 changes: 10 additions & 1 deletion packages/core/src/entity/EntityFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import type { Configuration } from '../utils/Configuration';
import type { EventManager } from '../events/EventManager';
import type { MetadataStorage } from '../metadata/MetadataStorage';
import type { VoType } from '../types/VoType';

export interface FactoryOptions {
initialized?: boolean;
Expand Down Expand Up @@ -64,6 +65,15 @@
entityName = Utils.className(entityName);
const meta = this.metadata.get(entityName);

for (const key in data) {
const keyData = key as keyof typeof data;
if (keyData === '__proto__') { continue; }
if (meta.properties[key]?.isValueObject && !Utils.isObject(data[keyData])) {
const vo = (meta.properties[key].customType as VoType<any>);
data[keyData] = vo.convertToJSValue(data[keyData]!, this.platform) as any;
mlusca marked this conversation as resolved.
Show resolved Hide resolved
}
}

if (meta.virtual) {
data = { ...data };
const entity = this.createEntity<T>(data, meta, options);
Expand Down Expand Up @@ -248,7 +258,6 @@

// creates new instance via constructor as this is the new entity
const entity = new Entity(...params);

// creating managed entity instance when `forceEntityConstructor` is enabled,
// we need to wipe all the values as they would cause update queries on next flush
if (!options.initialized && this.config.get('forceEntityConstructor')) {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/hydration/ObjectHydrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export class ObjectHydrator extends Hydrator {
ret.push(` entity${entityKey} = ${nullVal};`);
ret.push(` } else if (typeof data${dataKey} !== 'undefined') {`);

if (prop.customType) {
if (prop.customType && !prop.isValueObject) {
context.set(`convertToJSValue_${convertorKey}`, (val: any) => prop.customType.convertToJSValue(val, this.platform));
context.set(`convertToDatabaseValue_${convertorKey}`, (val: any) => prop.customType.convertToDatabaseValue(val, this.platform, { mode: 'hydration' }));

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ export * from './naming-strategy';
export * from './metadata';
export * from './cache';
export * from './decorators';
export * from './value-objects';
26 changes: 26 additions & 0 deletions packages/core/src/types/VoType.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Type } from './Type';
import type { Platform } from '../platforms';
import type { ValueObject } from '../value-objects/value-object';

export class VoType<T extends { new(...args: any[]): ValueObject<any, T> }> extends Type<ValueObject<any, any>, any> {

private readonly vo: T;

constructor(vo: T) {
super();
this.vo = vo;
}

override ensureComparable(): boolean {
return false;
}

override convertToJSValue(value: any, platform: Platform): ValueObject<any, any> {
return new this.vo(value);
}

override convertToDatabaseValue(value: ValueObject<any, any>, platform: Platform): ValueObject<any, any> {
return value.getValue();
}

}
3 changes: 2 additions & 1 deletion packages/core/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ import { StringType } from './StringType';
import { UuidType } from './UuidType';
import { TextType } from './TextType';
import { UnknownType } from './UnknownType';
import { VoType } from './VoType';

export {
Type, DateType, TimeType, DateTimeType, BigIntType, BlobType, Uint8ArrayType, ArrayType, EnumArrayType, EnumType,
JsonType, IntegerType, SmallIntType, TinyIntType, MediumIntType, FloatType, DoubleType, BooleanType, DecimalType,
StringType, UuidType, TextType, UnknownType, TransformContext,
StringType, UuidType, TextType, UnknownType, TransformContext, VoType,
};

export const types = {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/typings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,7 @@ export interface EntityProperty<Owner = any, Target = any> {
extra?: string;
userDefined?: boolean;
optional?: boolean; // for ts-morph
isValueObject: boolean;
ignoreSchemaChanges?: ('type' | 'extra')[];
}

Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/utils/QueryHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { Platform } from '../platforms';
import type { MetadataStorage } from '../metadata/MetadataStorage';
import { JsonType } from '../types/JsonType';
import { helper } from '../entity/wrap';
import type { VoType } from '../types/VoType';

export class QueryHelper {

Expand Down Expand Up @@ -142,6 +143,11 @@ export class QueryHelper {
const keys = prop?.joinColumns?.length ?? 0;
const composite = keys > 1;

if (meta?.properties?.[key as keyof typeof where ]?.isValueObject && !Utils.isObject(value) && convertCustomTypes) {
const vo = (meta.properties[key as keyof typeof where].customType as VoType<any>);
value = vo.convertToJSValue(value, platform) as any;
}

if (key in GroupOperator) {
o[key] = (value as unknown[]).map((sub: any) => QueryHelper.processWhere<T>({ ...options, where: sub, root }));
return o;
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/utils/Utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1288,4 +1288,20 @@ export class Utils {
}, {} as T);
}

static extendsFrom(baseClass: any, instance: any) {
if (!instance) { return false; }
let proto = Object.getPrototypeOf(instance);
while (proto) {
if (proto === baseClass.prototype) {
return true;
}
proto = Object.getPrototypeOf(proto);
}
return false;
}

static detectType(target: any, propertyName: string) {
return Reflect.getMetadata('design:type', target, propertyName);
}

}
11 changes: 11 additions & 0 deletions packages/core/src/value-objects/email.vo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { ValueObject } from './value-object';

const REGEX = /^[a-z0-9.]+@[a-z0-9]+\.[a-z]+(\.[a-z]+)?$/i;

export class Email extends ValueObject<string, Email> {

protected validate(value: string): boolean {
return REGEX.test(value);
}

}
3 changes: 3 additions & 0 deletions packages/core/src/value-objects/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './value-object';
export * from './email.vo';

174 changes: 174 additions & 0 deletions packages/core/src/value-objects/value-object.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { type ColumnType, ValidationError } from '@mikro-orm/core';

type VoExtended<T, Vo> = Vo extends ValueObject<T, Vo> ? Vo : ValueObject<T, Vo>;
type DatabaseValues = {
max?: number;
min?: number;
precision?: number;
scale?: number;
type?: ColumnType;
};


export abstract class ValueObject<T, Vo> {

/**
* Validates the value of the Value Object.
* It is abstract so that each Value Object can implement its own validation.
* It is protected from being called directly.
*
* @param value
* @protected
*/
protected abstract validate(value: T): boolean;

/**
* The maximum length of the Value Object.
* Its value is optional and will be used in database if this Value Object is used as a column.
* If the value is string, it will be the maximum number of characters.
* If the value is number, it will be the maximum number.
*/
protected max?: number;

/**
* The minimum length of the Value Object.
* Its value is optional.
* If the value is string, it will be the minimum number of characters.
* If the value is number, it will be the minimum number.
*/
protected min?: number;

/**
* The precision of the Value Object.
* Its value is optional and will be used in database if this Value Object is used as a column.
* It is the number of digits in a number.
*/
protected precision?: number;

/**
* The scale of the Value Object.
* Its value is optional and will be used in database if this Value Object is used as a column.
* It is the number of digits to the right of the decimal point in a number.
*/
protected scale?: number;

/**
* The type of database column.
*
*/
protected columnType?: ColumnType = 'varchar';

/**
* Value of the Value Object.
*
* It is private so that it cannot be changed directly.
* @private
*/
private value: T | undefined;

constructor(value: T, skipValidation = false) {
if (!skipValidation && (!this.validate(value) || !this.validateDatabase(value))) {
throw new ValidationError(`Invalid value for ${this.constructor.name}`);
}

this.setValue(value);
}

/**
* Creates a Value Object instance from a value.
*
* @example
* Email.from('[email protected]');
*
* @param value
*/
static from<T, Vo>(this: new (value: T) => VoExtended<T, Vo>, value: T): VoExtended<T, Vo> {
return new this(value);
}

/**
* Returns the scalar value of the Value Object.
*
*/
public getValue(): T {
return this.value!;
}

/**
* Compares the value of the Value Object with another Value Object.
*
* @param vo
*/
public equals(vo: ValueObject<T, Vo>): boolean {
return this.getValue() === vo.getValue();
}

/**
* Returns the database settings of the Value Object.
*
* @returns
*/
public getDatabaseValues(): DatabaseValues {
return {
max: this.max,
min: this.min,
type: this.columnType,
scale: this.scale,
precision: this.precision,
};
}

/**
* Sets the value of the Value Object.
*
* @param value
* @private
*/
private setValue(value: T) {
this.value = value;
}

/**
* Validates the value of the Value Object.
* It is private so that it can only be called by the constructor.
*
* @param value
* @returns
*/
private validateDatabase<T>(value: T): boolean {
if (typeof value === 'string') {
if (this.max !== undefined && value.length > this.max) {
throw new ValidationError(`Value exceeds maximum length of ${this.max}`);
}

if (this.min !== undefined && value.length < this.min) {
throw new ValidationError(`Value is less than minimum length of ${this.min}`);
}
} else if (typeof value === 'number') {
if (this.max !== undefined && value > this.max) {
throw new ValidationError(`Value exceeds maximum value of ${this.max}`);
}

if (this.min !== undefined && value < this.min) {
throw new ValidationError(`Value is less than minimum value of ${this.min}`);
}

if (this.precision !== undefined) {
const totalDigits = value.toString().replace('.', '').length;
if (totalDigits > this.precision) {
throw new ValidationError(`Value exceeds precision of ${this.precision}`);
}
}

if (this.scale !== undefined) {
const decimalDigits = (value.toString().split('.')[1] || '').length;
if (decimalDigits > this.scale) {
throw new ValidationError(`Value exceeds scale of ${this.scale}`);

Check warning on line 166 in packages/core/src/value-objects/value-object.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/value-objects/value-object.ts#L166

Added line #L166 was not covered by tests
}
}
}

return true;
}

}