diff --git a/.travis.yml b/.travis.yml index b8bada42b..6f8896ffc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,7 @@ language: node_js node_js: -- '10' -- '11' - '12' +- '13' services: - mongodb sudo: false diff --git a/README.md b/README.md index 91c9a6f0e..4918eeca7 100644 --- a/README.md +++ b/README.md @@ -250,7 +250,7 @@ you might have the need to serialize only one field. * partialPlainToMongo * partialMongoToPlain -## Types / Decorators +## `@f` decorator: define types Class fields are annotated using mainly [@f](https://marshal.marcj.dev/modules/_marcj_marshal.html#f). You can define primitives, class mappings, relations between parents, and indices for the database (currently MongoDB). @@ -277,6 +277,7 @@ class Page { ```` Example *not* valid decorators: + ```typescript import {f} from '@marcj/marshal'; @@ -286,11 +287,11 @@ class Page { } ```` - More examples: ```typescript import {f, uuid} from '@marcj/marshal'; +import * as moment from 'moment'; class MyModel { @f.primary().uuid() @@ -299,38 +300,30 @@ class MyModel { @f.optional().index() name?: string; - @f.array(String).optional() - tags?: string[]; + @f() + created: Date = new Date; + + @f.moment() + modified?: moment.Moment = moment(); } ``` -## TypeORM +### Moment.js -The meta information about your entity can be exported to TypeORM EntitySchema. +Instead of `Date` object you can use `Moment` if `moment` is installed. + +In MongoDB it's stored as `Date`. In JSON its encoded as ISO8601 string. ```typescript -// typeorm.js -import {getTypeOrmEntity} from "@marcj/marshal-mongo"; +import {f} from '@marcj/marshal'; +import * as moment from 'moment'; -const TypeOrmSchema = getTypeOrmEntity(MyEntity); -module.exports = { - type: "mongodb", - host: "localhost", - port: 27017, - database: "test", - useNewUrlParser: true, - synchronize: true, - entities: [TypeOrmSchema] +class MyModel { + @f.moment() + created?: moment.Moment = moment(); } ``` -Marshal.ts uses only TypeORM for connection abstraction and to generate a `EntitySchema` for your typeOrm use-cases. -You need in most cases only to use the @Field decorator with some other Marshal decorators (like @EnumField, @IDField, @UUIDField, @Index) -on your entity. - -You can generate a schema for Typeorm using `getTypeOrmEntity` and then pass this to your `createConnection` call, -which makes it possible to sync the schema defined only with Marshal decorators with your database managed by Typeorm. - ### Exclude `exclude` lets you exclude properties from a class in a certain @@ -530,6 +523,34 @@ expect(plain['childrenCollection.1']).toEqual({label: 'Bar4'}); expect(plain['childrenCollection.2.label']).toEqual('Bar5'); ``` +### TypeORM + +The meta information about your entity can be exported to TypeORM EntitySchema. + +```typescript +// typeorm.js +import {getTypeOrmEntity} from "@marcj/marshal-mongo"; + +const TypeOrmSchema = getTypeOrmEntity(MyEntity); +module.exports = { + type: "mongodb", + host: "localhost", + port: 27017, + database: "test", + useNewUrlParser: true, + synchronize: true, + entities: [TypeOrmSchema] +} +``` + +Marshal.ts uses only TypeORM for connection abstraction and to generate a `EntitySchema` for your typeOrm use-cases. +You need in most cases only to use the @Field decorator with some other Marshal decorators (like @EnumField, @IDField, @UUIDField, @Index) +on your entity. + +You can generate a schema for Typeorm using `getTypeOrmEntity` and then pass this to your `createConnection` call, +which makes it possible to sync the schema defined only with Marshal decorators with your database managed by Typeorm. + + ## Mongo Database Marshal's MongoDB database abstraction makes it super easy to diff --git a/packages/core/package.json b/packages/core/package.json index d344f400c..b8c29b6b4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -15,7 +15,11 @@ "author": "Marc J. Schmidt ", "license": "MIT", "peerDependencies": { - "buffer": "^5.2.1" + "buffer": "^5.2.1", + "moment": "^2.0.0" + }, + "optionalDependencies": { + "moment": "^2.0.0" }, "dependencies": { "@marcj/estdlib": "^0.1.15", @@ -25,6 +29,8 @@ }, "devDependencies": { "@types/clone": "^0.1.30", + "@types/moment": "^2.0.0", + "moment": "^2.0.0", "buffer": "^5.2.1" }, "jest": { diff --git a/packages/core/src/decorators.ts b/packages/core/src/decorators.ts index ceea23305..03584c284 100644 --- a/packages/core/src/decorators.ts +++ b/packages/core/src/decorators.ts @@ -1163,6 +1163,10 @@ fRaw['enum'] = function (this: FieldDecoratorResult, clazz: any, allowLabelsAsVa return EnumField(clazz, allowLabelsAsValue); }; +fRaw['moment'] = function (this: FieldDecoratorResult): FieldDecoratorResult { + return MomentField(); +}; + fRaw['forward'] = function (this: FieldDecoratorResult, f: () => TYPES): FieldDecoratorResult { return Field(forwardRef(f)); }; @@ -1175,13 +1179,7 @@ fRaw['forwardMap'] = function (this: FieldDecoratorResult, f: () => TYPES): Fiel return Field(forwardRef(f)).asMap(); }; -/** - * THis is the main decorator to define a properties on class or arguments on methods. - * - * @see FieldDecoratorResult - * @category Decorator - */ -export const f: FieldDecoratorResult & { +export interface MainDecorator { /** * Defines a type for a certain field. This is only necessary for custom classes * if the Typescript compiler does not include the reflection type in the build output. @@ -1193,7 +1191,7 @@ export const f: FieldDecoratorResult & { * } * ``` */ - type: (type: TYPES) => FieldDecoratorResult, + type(type: TYPES): FieldDecoratorResult; /** * Marks a field as array. @@ -1205,7 +1203,7 @@ export const f: FieldDecoratorResult & { * } * ``` */ - array: (type: TYPES) => FieldDecoratorResult, + array(type: TYPES): FieldDecoratorResult; /** * Marks a field as enum. @@ -1225,12 +1223,20 @@ export const f: FieldDecoratorResult & { * * If allowLabelsAsValue is set, you can use the enum labels as well for setting the property value using plainToClass(). */ - enum: (type: any, allowLabelsAsValue?: boolean) => FieldDecoratorResult, + enum(type: any, allowLabelsAsValue?: boolean): FieldDecoratorResult; + + /** + * Marks a field as Moment.js value. Mongo and JSON transparent uses its toJSON() result. + * In MongoDB its stored as Date. + * + * You have to install moment npm package in order to use it. + */ + moment(): FieldDecoratorResult; /** * Marks a field as type any. It does not transform the value and directly uses JSON.parse/stringify. */ - any: () => FieldDecoratorResult, + any(): FieldDecoratorResult; /** * Marks a field as map. @@ -1245,7 +1251,7 @@ export const f: FieldDecoratorResult & { * } * ``` */ - map: (type: TYPES) => FieldDecoratorResult, + map(type: TYPES): FieldDecoratorResult; /** * Forward references a type, required for circular reference. @@ -1257,7 +1263,7 @@ export const f: FieldDecoratorResult & { * } * ``` */ - forward: (f: () => TYPES) => FieldDecoratorResult, + forward(f: () => TYPES): FieldDecoratorResult; /** * Forward references a type in an array, required for circular reference. @@ -1269,7 +1275,7 @@ export const f: FieldDecoratorResult & { * } * ``` */ - forwardArray: (f: () => TYPES) => FieldDecoratorResult, + forwardArray(f: () => TYPES): FieldDecoratorResult; /** * Forward references a type in a map, required for circular reference. @@ -1281,8 +1287,58 @@ export const f: FieldDecoratorResult & { * } * ``` */ - forwardMap: (f: () => TYPES) => FieldDecoratorResult, -} = fRaw as any; + forwardMap(f: () => TYPES): FieldDecoratorResult; +} + +/** + * This is the main decorator to define a properties on class or arguments on methods. + * + * ```typescript + * class SubModel { + * @f label: string; + * } + * + * export enum Plan { + * DEFAULT, + * PRO, + * ENTERPRISE, + * } + * + * class SimpleModel { + * @f.primary().uuid() + * id: string = uuid(); + * + * @f.array(String) + * tags: string[] = []; + * + * @f.type(Buffer).optional() //binary + * picture?: Buffer; + * + * @f + * type: number = 0; + * + * @f.enum(Plan) + * plan: Plan = Plan.DEFAULT; + * + * @f + * created: Date = new Date; + * + * @f.array(SubModel) + * children: SubModel[] = []; + * + * @f.map(SubModel) + * childrenMap: {[key: string]: SubModel} = {}; + * + * constructor( + * @f.index().asName('name') //asName is required for minimized code + * public name: string + * ) {} + * } + * ``` + * + * @category Decorator + */ +export const f: MainDecorator & FieldDecoratorResult = fRaw as any; /** * @hidden @@ -1336,8 +1392,9 @@ export function MultiIndex(fields: string[], options: IndexOptions, name?: strin /** * Used to define a field as Enum. - * * If allowLabelsAsValue is set, you can use the enum labels as well for setting the property value using plainToClass(). + * + * @internal */ function EnumField(type: any, allowLabelsAsValue = false) { return FieldDecoratorWrapper((target, property, returnType?: any) => { @@ -1348,3 +1405,12 @@ function EnumField(type: any, allowLabelsAsValue = false) { } }); } + +/** + * @internal + */ +function MomentField() { + return FieldDecoratorWrapper((target, property, returnType?: any) => { + property.type = 'moment'; + }); +} diff --git a/packages/core/src/mapper.ts b/packages/core/src/mapper.ts index 8be14c42d..ff7d16608 100644 --- a/packages/core/src/mapper.ts +++ b/packages/core/src/mapper.ts @@ -23,6 +23,7 @@ export type Types = | 'uuid' | 'binary' | 'class' + | 'moment' | 'date' | 'string' | 'boolean' @@ -30,6 +31,16 @@ export type Types = | 'enum' | 'any'; +export let moment: any = () => { + throw new Error('Moment.js not installed') +}; + +declare function require(moduleName: string): any; + +try { + moment = require('moment'); +} catch(e) {} + const cache = new Map>(); /** @@ -75,7 +86,7 @@ export interface ResolvedReflectionFound { */ export type ResolvedReflection = ResolvedReflectionFound | undefined; -type ResolvedReflectionCaches = {[path: string]: ResolvedReflection}; +type ResolvedReflectionCaches = { [path: string]: ResolvedReflection }; const resolvedReflectionCaches = new Map, ResolvedReflectionCaches>(); /** @@ -249,10 +260,6 @@ export function propertyClassToPlain(classType: ClassType, propertyName: s const {type, typeValue, array, map} = reflection; function convert(value: any) { - if ('date' === type && value instanceof Date) { - return value.toJSON(); - } - if ('string' === type) { return String(value); } @@ -280,6 +287,11 @@ export function propertyClassToPlain(classType: ClassType, propertyName: s return classToPlain(typeValue, value); } + //Date/moment automatically is converted since it has toJSON() method. + if (value && 'function' === typeof value.toJSON) { + return value.toJSON(); + } + return value; } @@ -333,6 +345,10 @@ export function propertyPlainToClass( return new Date(value); } + if ('moment' === type && ('string' === typeof value || 'number' === typeof value)) { + return moment(value); + } + if ('string' === type && 'string' !== typeof value) { return String(value); } diff --git a/packages/core/tests/moment.spec.ts b/packages/core/tests/moment.spec.ts new file mode 100644 index 000000000..4a332caca --- /dev/null +++ b/packages/core/tests/moment.spec.ts @@ -0,0 +1,31 @@ +import 'jest-extended' +import 'reflect-metadata'; +import * as moment from 'moment'; +import {getClassSchema, f, classToPlain, plainToClass} from ".."; + +test('test moment', () => { + class Model { + @f.moment() + created: moment.Moment = moment(); + } + + const schema = getClassSchema(Model); + const prop = schema.getProperty('created'); + expect(prop.type).toBe('moment'); + + + const m = new Model; + m.created = moment(new Date('2018-10-13T12:17:35.000Z')); + + const p = classToPlain(Model, m); + expect(p.created).toBeString(); + expect(p.created).toBe('2018-10-13T12:17:35.000Z'); + + { + const m = plainToClass(Model, { + created: '2018-10-13T12:17:35.000Z' + }); + expect(m.created).toBeInstanceOf(moment); + expect(m.created.toJSON()).toBe('2018-10-13T12:17:35.000Z' ); + } +}); diff --git a/packages/mongo/package.json b/packages/mongo/package.json index 5c4b37b6a..97b52383d 100644 --- a/packages/mongo/package.json +++ b/packages/mongo/package.json @@ -26,6 +26,7 @@ "@types/mongodb": "^3.1.12", "buffer": "^5.2.1", "mongodb": "^3.1.13", + "moment": "^2.0.0", "typeorm": "^0.2.14" }, "jest": { diff --git a/packages/mongo/src/mapping.ts b/packages/mongo/src/mapping.ts index 8d5481cd4..0d0986076 100644 --- a/packages/mongo/src/mapping.ts +++ b/packages/mongo/src/mapping.ts @@ -11,7 +11,8 @@ import { isEnumAllowLabelsAsValue, isOptional, toClass, - ToClassState + ToClassState, + moment } from "@marcj/marshal"; import { ClassType, @@ -111,14 +112,15 @@ export function propertyMongoToPlain( return (value).toHexString(); } - if ('date' === type && value instanceof Date) { - return value.toJSON(); - } - if ('binary' === type && value instanceof Binary) { return value.buffer.toString('base64'); } + //Date automatically is converted since it has toJSON() method. + if (value && 'function' === typeof value.toJSON) { + return value.toJSON(); + } + return value; } @@ -165,6 +167,10 @@ export function propertyClassToMongo( } } + if ('moment' === type) { + return value.toDate(); + } + if ('string' === type) { return String(value); } @@ -330,7 +336,6 @@ export function propertyMongoToClass( const {resolvedClassType, resolvedPropertyName, type, typeValue, array, map} = reflection; function convert(value: any) { - if (value && 'uuid' === type && 'string' !== typeof value) { return uuid4Stringify(value); } @@ -343,6 +348,10 @@ export function propertyMongoToClass( return new Date(value); } + if ('moment' === type) { + return moment(value); + } + if ('binary' === type && value instanceof Binary) { return value.buffer; } diff --git a/packages/mongo/tests/moment.spec.ts b/packages/mongo/tests/moment.spec.ts new file mode 100644 index 000000000..cce2f13a5 --- /dev/null +++ b/packages/mongo/tests/moment.spec.ts @@ -0,0 +1,27 @@ +import 'jest-extended' +import 'reflect-metadata'; +import * as moment from 'moment'; +import {f} from "@marcj/marshal"; +import {classToMongo, mongoToClass} from ".."; + +test('test moment', () => { + class Model { + @f.moment() + created: moment.Moment = moment(); + } + + const m = new Model; + m.created = moment(new Date('2018-10-13T12:17:35.000Z')); + + const p = classToMongo(Model, m); + expect(p.created).toBeDate(); + expect(p.created.toJSON()).toBe('2018-10-13T12:17:35.000Z'); + + { + const m = mongoToClass(Model, { + created: new Date('2018-10-13T12:17:35.000Z') + }); + expect(m.created).toBeInstanceOf(moment); + expect(m.created.toJSON()).toBe('2018-10-13T12:17:35.000Z' ); + } +}); diff --git a/packages/mongo/tests/mongo.spec.ts b/packages/mongo/tests/mongo.spec.ts index 922618d1a..d70582c50 100644 --- a/packages/mongo/tests/mongo.spec.ts +++ b/packages/mongo/tests/mongo.spec.ts @@ -14,6 +14,7 @@ import {SimpleModel, SuperSimple} from "@marcj/marshal/tests/entities"; import {plainToMongo, uuid4Stringify} from "../src/mapping"; import {Buffer} from "buffer"; import {createConnection} from "typeorm"; +import * as moment from "moment"; let database: Database; @@ -34,6 +35,25 @@ afterEach(async () => { await database.close(); }); +test('test moment db', async () => { + @Entity('model-moment') + class Model { + @f.moment() + created: moment.Moment = moment(); + } + + const database = await createDatabase('testing'); + + const m = new Model; + m.created = moment(new Date('2018-10-13T12:17:35.000Z')); + + await database.add(Model, m); + const m2 = await database.get(Model, {}); + expect(m2).toBeInstanceOf(Model); + expect(m2!.created).toBeInstanceOf(moment); + expect(m2!.created.toJSON()).toBe('2018-10-13T12:17:35.000Z'); +}); + test('test save undefined values', async () => { const database = await createDatabase('testing'); @@ -41,8 +61,10 @@ test('test save undefined values', async () => { class Model { constructor( @f.optional() - public name?: string){} + public name?: string) { + } } + const collection = database.getCollection(Model); { diff --git a/tsconfig.json b/tsconfig.json index c569dcd51..c94b9efc5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,8 @@ "module": "commonjs", "emitDecoratorMetadata": true, "experimentalDecorators": true, + "esModuleInterop": false, + "moduleResolution": "node", "target": "es2017", "types": [ "reflect-metadata"