Skip to content

Commit

Permalink
feat(core): add custom types for array, blob and json (#559)
Browse files Browse the repository at this point in the history
### ArrayType

In PostgreSQL and MongoDB, it uses native arrays, otherwise it concatenates the 
values into string separated by commas. This means that you can't use values that
contain comma with the `ArrayType` (but you can create custom array type that will
handle this case, e.g. by using different separator).

By default array of strings is returned from the type. You can also have arrays 
of numbers or other data types - to do so, you will need to implement custom 
`hydrate` method that is used for converting the array values to the right type.

```typescript
@Property({ type: new ArrayType(i => +i), nullable: true })
array?: number[];
```

### BlobType

Blob type can be used to store binary data in the database. 

```typescript
@Property({ type: BlobType, nullable: true })
blob?: Buffer;
```

### JsonType

To store objects we can use `JsonType`. As some drivers are handling objects 
automatically and some don't, this type will handle the serialization in a driver
independent way (calling `parse` and `stringify` only when needed).

```typescript
@Property({ type: JsonType, nullable: true })
object?: { foo: string; bar: number };
```

Related: #476
  • Loading branch information
B4nan committed Jun 5, 2020
1 parent 5b34611 commit 07aea2b
Show file tree
Hide file tree
Showing 26 changed files with 551 additions and 53 deletions.
88 changes: 86 additions & 2 deletions docs/docs/custom-types.md
Expand Up @@ -24,8 +24,6 @@ You can define custom types by extending `Type` abstract class. It has 4 optiona
Gets the SQL declaration snippet for a field of this type.
By default returns `columnType` of given property.

> `DateType` and `TimeType` types are already implemented in the ORM.
```typescript
import { Type, Platform, EntityProperty, ValidationError } from 'mikro-orm';

Expand Down Expand Up @@ -81,3 +79,89 @@ export class FooBar {

}
```

## Types provided by MikroORM

There are few types provided by MikroORM. All of them aim to provide similar
experience among all the drivers, even if the particular feature is not supported
out of box by the driver.

### ArrayType

In PostgreSQL and MongoDB, it uses native arrays, otherwise it concatenates the
values into string separated by commas. This means that you can't use values that
contain comma with the `ArrayType` (but you can create custom array type that will
handle this case, e.g. by using different separator).

By default array of strings is returned from the type. You can also have arrays
of numbers or other data types - to do so, you will need to implement custom
`hydrate` method that is used for converting the array values to the right type.

> `ArrayType` will be used automatically if `type` is set to `array` (default behaviour
> of reflect-metadata) or `string[]` or `number[]` (either manually or via ts-morph).
> In case of `number[]` it will automatically handle the conversion to numbers.
> This means that the following examples would both have the `ArrayType` used
> automatically (but with reflect-metadata we would have a string array for both
> unless we specify the type manually as `type: 'number[]')
```typescript
@Property({ type: ArrayType, nullable: true })
stringArray?: string[];

@Property({ type: new ArrayType(i => +i), nullable: true })
numericArray?: number[];
```

### BigIntType

You can use `BigIntType` to support `bigint`s. By default, it will represent the
value as a `string`.

```typescript
@PrimaryKey({ type: BigIntType })
id: string;
```

### BlobType

Blob type can be used to store binary data in the database.

> `BlobType` will be used automatically if you specify the type hint as `Buffer`.
> This means that the following example should work even without the explicit
> `type: BlobType` option (with both reflect-metadata and ts-morph providers).
```typescript
@Property({ type: BlobType, nullable: true })
blob?: Buffer;
```

### JsonType

To store objects we can use `JsonType`. As some drivers are handling objects
automatically and some don't, this type will handle the serialization in a driver
independent way (calling `parse` and `stringify` only when needed).

```typescript
@Property({ type: JsonType, nullable: true })
object?: { foo: string; bar: number };
```

### DateType

To store dates without time information, we can use `DateType`. It does use `date`
column type and maps it to the `Date` object.

```typescript
@Property({ type: DateType, nullable: true })
born?: Date;
```

### TimeType

As opposed to the `DateType`, to store only the time information, we can use
`TimeType`. It will use the `time` column type, the runtime type is string.

```typescript
@Property({ type: TimeType, nullable: true })
bornTime?: string;
```
2 changes: 1 addition & 1 deletion packages/core/src/decorators/Property.ts
Expand Up @@ -38,7 +38,7 @@ export type PropertyOptions = {
fieldNames?: string[];
customType?: Type<any>;
columnType?: string;
type?: 'string' | 'number' | 'boolean' | 'bigint' | 'ObjectId' | string | object | bigint | Date | Constructor<Type>;
type?: 'string' | 'number' | 'boolean' | 'bigint' | 'ObjectId' | string | object | bigint | Date | Constructor<Type<any>> | Type<any>;
length?: any;
onCreate?: () => any;
onUpdate?: () => any;
Expand Down
25 changes: 22 additions & 3 deletions packages/core/src/metadata/MetadataDiscovery.ts
Expand Up @@ -8,7 +8,7 @@ import { MetadataValidator } from './MetadataValidator';
import { MetadataStorage } from './MetadataStorage';
import { Cascade, ReferenceType } from '../entity';
import { Platform } from '../platforms';
import { Type } from '../types';
import { ArrayType, BlobType, Type } from '../types';
import { EntitySchema } from '../metadata';

export class MetadataDiscovery {
Expand Down Expand Up @@ -601,13 +601,32 @@ export class MetadataDiscovery {
}

private initCustomType(prop: EntityProperty): void {
if (Object.getPrototypeOf(prop.type) === Type) {
// `string[]` can be returned via ts-morph, while reflect metadata will give us just `array`
if (!prop.customType && ['string[]', 'array'].includes(prop.type)) {
prop.customType = new ArrayType();
}

// for number arrays we make sure to convert the items to numbers
if (!prop.customType && prop.type === 'number[]') {
prop.customType = new ArrayType(i => +i);
}

if (!prop.customType && prop.type === 'Buffer') {
prop.customType = new BlobType();
}

if (prop.type as unknown instanceof Type) {
prop.customType = prop.type as unknown as Type<any>;
prop.type = prop.customType.constructor.name;
}

if (Object.getPrototypeOf(prop.type) === Type && !prop.customType) {
prop.customType = Type.getType(prop.type as unknown as Constructor<Type>);
}

if (prop.customType) {
prop.type = prop.customType.constructor.name;
prop.columnTypes = [prop.customType.getColumnType(prop, this.platform)];
prop.columnTypes = prop.columnTypes ?? [prop.customType.getColumnType(prop, this.platform)];
}
}

Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/platforms/Platform.ts
Expand Up @@ -91,6 +91,30 @@ export abstract class Platform {
return 'bigint';
}

getArrayDeclarationSQL(): string {
return 'text';
}

marshallArray(values: string[]): string {
return values.join(',');
}

unmarshallArray(value: string): string[] {
return value.split(',') as string[];
}

getBlobDeclarationSQL(): string {
return 'blob';
}

getJsonDeclarationSQL(): string {
return 'json';
}

convertsJsonAutomatically(marshall = false): boolean {
return !marshall;
}

getRepositoryClass<T>(): Constructor<EntityRepository<T>> {
return EntityRepository;
}
Expand Down
44 changes: 44 additions & 0 deletions packages/core/src/types/ArrayType.ts
@@ -0,0 +1,44 @@
import { Type } from './Type';
import { Utils, ValidationError } from '../utils';
import { EntityProperty } from '../typings';
import { Platform } from '../platforms';

export class ArrayType<T extends string | number = string> extends Type<T[] | null, string | null> {

constructor(private readonly hydrate: (i: string) => T = i => i as T) {
super();
}

convertToDatabaseValue(value: T[] | null, platform: Platform): string | null {
if (!value) {
return value as null;
}

if (Array.isArray(value)) {
return platform.marshallArray(value as string[]);
}

throw ValidationError.invalidType(ArrayType, value, 'JS');
}

convertToJSValue(value: T[] | string | null, platform: Platform): T[] | null {
if (!value) {
return value as null;
}

if (Utils.isString(value)) {
value = platform.unmarshallArray(value) as T[];
}

return value.map(i => this.hydrate(i as string));
}

toJSON(value: T[]): T[] {
return value;
}

getColumnType(prop: EntityProperty, platform: Platform): string {
return platform.getArrayDeclarationSQL();
}

}
27 changes: 27 additions & 0 deletions packages/core/src/types/BlobType.ts
@@ -0,0 +1,27 @@
import { Type } from './Type';
import { Platform } from '../platforms';
import { EntityProperty } from '../typings';

export class BlobType extends Type<Buffer | null> {

convertToDatabaseValue(value: Buffer, platform: Platform): Buffer {
return value;
}

convertToJSValue(value: Buffer, platform: Platform): Buffer | null {
if (!value) {
return value;
}

if (value.buffer instanceof Buffer) {
return value.buffer;
}

return Buffer.from(value);
}

getColumnType(prop: EntityProperty, platform: Platform): string {
return platform.getBlobDeclarationSQL();
}

}
28 changes: 28 additions & 0 deletions packages/core/src/types/JsonType.ts
@@ -0,0 +1,28 @@
import { Type } from './Type';
import { Platform } from '../platforms';
import { EntityProperty } from '../typings';
import { Utils } from '../utils';

export class JsonType extends Type<object, string | null> {

convertToDatabaseValue(value: object, platform: Platform): string | null {
if (platform.convertsJsonAutomatically(true) || value === null) {
return value as unknown as string;
}

return JSON.stringify(value);
}

convertToJSValue(value: string | object, platform: Platform): object {
if (!platform.convertsJsonAutomatically() && Utils.isString(value)) {
return JSON.parse(value);
}

return value as object;
}

getColumnType(prop: EntityProperty, platform: Platform): string {
return platform.getJsonDeclarationSQL();
}

}
3 changes: 3 additions & 0 deletions packages/core/src/types/index.ts
Expand Up @@ -2,3 +2,6 @@ export * from './Type';
export * from './DateType';
export * from './TimeType';
export * from './BigIntType';
export * from './BlobType';
export * from './ArrayType';
export * from './JsonType';
8 changes: 7 additions & 1 deletion packages/knex/src/schema/DatabaseTable.ts
Expand Up @@ -140,7 +140,13 @@ export class DatabaseTable {
return namingStrategy.getClassName(column.fk.referencedTableName, '_');
}

return schemaHelper.getTypeFromDefinition(column.type, defaultType);
const type = schemaHelper.getTypeFromDefinition(column.type, defaultType);

if (column.type.endsWith('[]')) {
return type + '[]';
}

return type;
}

private getPropertyDefaultValue(schemaHelper: SchemaHelper, column: Column, propType: string, raw = false): any {
Expand Down
8 changes: 8 additions & 0 deletions packages/mongodb/src/MongoPlatform.ts
Expand Up @@ -30,4 +30,12 @@ export class MongoPlatform extends Platform {
return false;
}

convertsJsonAutomatically(marshall = false): boolean {
return true;
}

marshallArray(values: string[]): string {
return values as unknown as string;
}

}
16 changes: 16 additions & 0 deletions packages/postgresql/src/PostgreSqlPlatform.ts
Expand Up @@ -32,4 +32,20 @@ export class PostgreSqlPlatform extends AbstractSqlPlatform {
return super.isBigIntProperty(prop) || (prop.columnTypes && prop.columnTypes[0] === 'bigserial');
}

getArrayDeclarationSQL(): string {
return 'text[]';
}

marshallArray(values: string[]): string {
return `{${values.join(',')}}`;
}

getBlobDeclarationSQL(): string {
return 'bytea';
}

getJsonDeclarationSQL(): string {
return 'jsonb';
}

}
7 changes: 5 additions & 2 deletions packages/postgresql/src/PostgreSqlSchemaHelper.ts
Expand Up @@ -17,6 +17,8 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
object: ['jsonb', 'json'],
json: ['jsonb', 'json'],
uuid: ['uuid'],
Buffer: ['bytea'],
buffer: ['bytea'],
enum: ['text'], // enums are implemented as text columns with check constraints
};

Expand Down Expand Up @@ -66,13 +68,13 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
}

async getColumns(connection: AbstractSqlConnection, tableName: string, schemaName: string): Promise<any[]> {
const sql = `select column_name, column_default, is_nullable, udt_name, coalesce(datetime_precision, character_maximum_length) length
const sql = `select column_name, column_default, is_nullable, udt_name, coalesce(datetime_precision, character_maximum_length) length, data_type
from information_schema.columns where table_schema = '${schemaName}' and table_name = '${tableName}'`;
const columns = await connection.execute<any[]>(sql);

return columns.map(col => ({
name: col.column_name,
type: col.udt_name,
type: col.data_type.toLowerCase() === 'array' ? col.udt_name.replace(/^_(.*)$/, '$1[]') : col.udt_name,
maxLength: col.length,
nullable: col.is_nullable === 'YES',
defaultValue: this.normalizeDefaultValue(col.column_default, col.length),
Expand Down Expand Up @@ -122,6 +124,7 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
const m1 = item.enum_def.match(/check \(\(\((\w+)\)::/i) || item.enum_def.match(/check \(\((\w+) = any/i);
const m2 = item.enum_def.match(/\(array\[(.*)]\)/i);

/* istanbul ignore else */
if (m1 && m2) {
o[m1[1]] = m2[1].split(',').map((item: string) => item.trim().match(/^'(.*)'/)![1]);
}
Expand Down
4 changes: 4 additions & 0 deletions packages/sqlite/src/SqlitePlatform.ts
Expand Up @@ -19,4 +19,8 @@ export class SqlitePlatform extends AbstractSqlPlatform {
return super.getCurrentTimestampSQL(0);
}

convertsJsonAutomatically(): boolean {
return false;
}

}
3 changes: 3 additions & 0 deletions tests/EntityHelper.mysql.test.ts
Expand Up @@ -116,6 +116,9 @@ describe('EntityHelperMySql', () => {
name: 'fb',
random: 123,
version: a.version,
array: null,
object: null,
blob: null,
});
});

Expand Down

0 comments on commit 07aea2b

Please sign in to comment.