From 16d224b4e78efcb32a07a7fef977b79789b7e6d6 Mon Sep 17 00:00:00 2001 From: Zachary Golba Date: Mon, 5 Dec 2016 17:40:24 -0500 Subject: [PATCH] feat: use transactions when writing to the database (#527) * feat: use transactions when writing to the database * feat: expose transaction api * wip: improve public transaction api and type declarations * fix: change tracking is broken * fix: get tests passing * test: add unit tests for new utils * test: add unit test for transaction proxies * test: add unit test for attribute setters * test: more database module unit tests * test: unit test for updating relationships * test: account for patching has many relationships in controller test * reactor: use change sets for relationship dirty tracking * fix: remove console.log from relationship unit test --- .eslintrc.json | 5 +- .flowconfig | 2 +- decl/knex.js | 236 ++++++++ .../npm/eslint-plugin-flowtype_vx.x.x.js | 32 +- flow-typed/npm/eslint_vx.x.x.js | 25 +- flow-typed/npm/knex_v0.12.x.js | 164 ++++++ flow-typed/npm/rollup_vx.x.x.js | 4 +- package.json | 1 + src/errors/module-missing-error.js | 19 - src/packages/cli/commands/dbmigrate.js | 40 +- src/packages/cli/commands/dbseed.js | 5 +- src/packages/controller/index.js | 79 ++- .../controller/test/controller.test.js | 38 +- .../controller/test/find-related.test.js | 84 --- src/packages/controller/utils/find-related.js | 64 --- .../controller/utils/resolve-relationships.js | 33 ++ .../database/{model => }/attribute/index.js | 0 .../{model => }/attribute/interfaces.js | 0 .../attribute/utils/create-attribute.js | 23 + .../attribute/utils/create-getter.js | 9 +- .../attribute/utils/create-normalizer.js | 0 .../database/attribute/utils/create-setter.js | 36 ++ src/packages/database/change-set/index.js | 49 ++ src/packages/database/constants.js | 3 - src/packages/database/index.js | 6 +- src/packages/database/migration/index.js | 10 +- src/packages/database/migration/interfaces.js | 6 + .../migration/utils/generate-timestamp.js | 6 +- .../model/attribute/utils/create-setter.js | 46 -- .../model/attribute/utils/refs-for.js | 18 - src/packages/database/model/index.js | 526 +++++++++++------- .../database/model/initialize-class.js | 64 ++- src/packages/database/model/interfaces.js | 20 + .../database/model/utils/persistence.js | 95 ++++ .../database/model/utils/run-hooks.js | 17 + src/packages/database/model/utils/validate.js | 35 +- .../query/runner/utils/build-results.js | 4 +- src/packages/database/relationship/index.js | 7 +- .../relationship/utils/inverse-setters.js | 34 +- .../relationship/utils/related-for.js | 19 - .../relationship/utils/save-relationships.js | 19 - .../database/relationship/utils/setters.js | 27 +- .../relationship/utils/update-relationship.js | 189 +++++++ src/packages/database/test/database.test.js | 59 +- src/packages/database/test/migration.test.js | 30 +- src/packages/database/test/model.test.js | 409 ++++++++++++-- .../database/test/relationship.test.js | 153 ++--- .../database/test/transaction.test.js | 121 ++++ src/packages/database/transaction/index.js | 61 ++ .../database/transaction/interfaces.js | 8 + src/packages/database/utils/connect.js | 19 +- .../database/utils/create-migrations.js | 5 +- .../database/utils/normalize-model-name.js | 5 +- .../database/utils/pending-migrations.js | 8 +- .../database/utils/type-for-column.js | 2 +- .../serializer/test/serializer.test.js | 201 +++---- src/utils/compose.js | 28 + src/utils/diff.js | 16 + src/utils/has-own-property.js | 8 + src/utils/map-to-object.js | 11 + src/utils/proxy.js | 27 + src/utils/test/compose.test.js | 59 ++ src/utils/test/diff.test.js | 30 + src/utils/test/has-own-property.test.js | 35 ++ src/utils/test/map-to-object.test.js | 21 + src/utils/test/omit.test.js | 1 + src/utils/test/pick.test.js | 1 + src/utils/test/proxy.test.js | 72 +++ test/test-app/app/controllers/images.js | 3 +- test/test-app/app/models/action.js | 26 +- test/test-app/app/models/comment.js | 4 +- test/test-app/app/models/post.js | 4 +- test/test-app/app/models/reaction.js | 11 +- test/test-app/app/utils/track.js | 21 +- test/test-app/config/database.js | 3 + test/test-app/db/seed.js | 92 +-- 76 files changed, 2650 insertions(+), 1003 deletions(-) create mode 100644 decl/knex.js create mode 100644 flow-typed/npm/knex_v0.12.x.js delete mode 100644 src/errors/module-missing-error.js delete mode 100644 src/packages/controller/test/find-related.test.js delete mode 100644 src/packages/controller/utils/find-related.js create mode 100644 src/packages/controller/utils/resolve-relationships.js rename src/packages/database/{model => }/attribute/index.js (100%) rename src/packages/database/{model => }/attribute/interfaces.js (100%) create mode 100644 src/packages/database/attribute/utils/create-attribute.js rename src/packages/database/{model => }/attribute/utils/create-getter.js (56%) rename src/packages/database/{model => }/attribute/utils/create-normalizer.js (100%) create mode 100644 src/packages/database/attribute/utils/create-setter.js create mode 100644 src/packages/database/change-set/index.js create mode 100644 src/packages/database/migration/interfaces.js delete mode 100644 src/packages/database/model/attribute/utils/create-setter.js delete mode 100644 src/packages/database/model/attribute/utils/refs-for.js create mode 100644 src/packages/database/model/interfaces.js create mode 100644 src/packages/database/model/utils/persistence.js create mode 100644 src/packages/database/model/utils/run-hooks.js delete mode 100644 src/packages/database/relationship/utils/related-for.js delete mode 100644 src/packages/database/relationship/utils/save-relationships.js create mode 100644 src/packages/database/relationship/utils/update-relationship.js create mode 100644 src/packages/database/test/transaction.test.js create mode 100644 src/packages/database/transaction/index.js create mode 100644 src/packages/database/transaction/interfaces.js create mode 100644 src/utils/compose.js create mode 100644 src/utils/diff.js create mode 100644 src/utils/has-own-property.js create mode 100644 src/utils/map-to-object.js create mode 100644 src/utils/proxy.js create mode 100644 src/utils/test/compose.test.js create mode 100644 src/utils/test/diff.test.js create mode 100644 src/utils/test/has-own-property.test.js create mode 100644 src/utils/test/map-to-object.test.js create mode 100644 src/utils/test/proxy.test.js diff --git a/.eslintrc.json b/.eslintrc.json index 9c946e8c..abd2347b 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -8,7 +8,10 @@ "globals": { "Class": true, "Generator": true, - "$PropertyType": true + "$PropertyType": true, + "Knex$Transaction": true, + "Knex$QueryBuilder": true, + "Knex$SchemaBuilder": true }, "settings": { "flowtype": { diff --git a/.flowconfig b/.flowconfig index cd54653d..601347a2 100644 --- a/.flowconfig +++ b/.flowconfig @@ -1,6 +1,6 @@ [libs] -decl/ flow-typed/ +decl/ [ignore] .*/lib/.* diff --git a/decl/knex.js b/decl/knex.js new file mode 100644 index 00000000..ff314352 --- /dev/null +++ b/decl/knex.js @@ -0,0 +1,236 @@ +// @flow +declare type Knex$QueryBuilderFn = (qb: Knex$QueryBuilder) => Knex$QueryBuilder; + +declare class Knex$QueryBuilder mixins Promise { + select(key?: string[]): this; + select(...key: string[]): this; + timeout(ms: number, options?: { cancel: bool }): this; + column(key: string[]): this; + column(...key: string[]): this; + with(alias: string, w: string|Knex$QueryBuilderFn): this; + withSchema(schema: string): this; + returning(column: string): this; + returning(...columns: string[]): this; + returning(columns: string[]): this; + as(name: string): this; + transacting(trx: ?Knex$Transaction): this; + where(builder: Knex$QueryBuilderFn): this; + where(column: string, value: any): this; + where(column: string, operator: string, value: any): this; + whereNot(builder: Knex$QueryBuilderFn): this; + whereNot(column: string, value: any): this; + whereNot(column: string, operator: string, value: any): this; + whereIn(column: string, values: any[]): this; + whereNotIn(column: string, values: any[]): this; + whereNull(column: string): this; + whereNotNull(column: string): this; + whereExists(column: string): this; + whereNotExists(column: string): this; + whereBetween(column: string, range: number[]): this; + whereNotBetween(column: string, range: number[]): this; + whereRaw(raw: any): this; + orWhere(builder: Knex$QueryBuilderFn): this; + orWhere(column: string, value: any): this; + orWhere(column: string, operator: string, value: any): this; + orWhereNot(builder: Knex$QueryBuilderFn): this; + orWhereNot(column: string, value: any): this; + orWhereNot(column: string, operator: string, value: any): this; + orWhereIn(column: string, values: any[]): this; + orWhereNotIn(column: string, values: any[]): this; + orWhereNull(column: string): this; + orWhereNotNull(column: string): this; + orWhereExists(column: string): this; + orWhereNotExists(column: string): this; + orWhereBetween(column: string, range: number[]): this; + orWhereNotBetween(column: string, range: number[]): this; + orWhereRaw(raw: any): this; + innerJoin(table: string, c1: string, operator: string, c2: string): this; + innerJoin(table: string, c1: string, c2: string): this; + innerJoin(builder: Knex$QueryBuilder, c1?: string, c2?: string): this; + leftJoin(table: string, c1: string, operator: string, c2: string): this; + leftJoin(table: string, c1: string, c2: string): this; + leftJoin(builder: Knex$QueryBuilder): this; + leftOuterJoin(table: string, c1: string, operator: string, c2: string): this; + leftOuterJoin(table: string, c1: string, c2: string): this; + rightJoin(table: string, c1: string, operator: string, c2: string): this; + rightJoin(table: string, c1: string, c2: string): this; + rightOuterJoin(table: string, c1: string, operator: string, c2: string): this; + rightOuterJoin(table: string, c1: string, c2: string): this; + outerJoin(table: string, c1: string, operator: string, c2: string): this; + outerJoin(table: string, c1: string, c2: string): this; + fullOuterJoin(table: string, c1: string, operator: string, c2: string): this; + fullOuterJoin(table: string, c1: string, c2: string): this; + crossJoin(column: string, c1: string, c2: string): this; + crossJoin(column: string, c1: string, operator: string, c2: string): this; + joinRaw(sql: string, bindings?: mixed[]): this; + distinct(): this; + groupBy(column: string): this; + groupByRaw(): this; + orderBy(column: string, direction?: 'desc' | 'asc'): this; + orderByRaw(): this; + offset(offset: number): this; + limit(limit: number): this; + having(column: string, operator: string, value: mixed): this; + union(): this; + unionAll(): this; + count(column?: string): this; + countDistinct(column?: string): this; + min(column?: string): this; + max(column?: string): this; + sum(column?: string): this; + sumDistinct(column?: string): this; + avg(column?: string): this; + avgDistinct(column?: string): this; + pluck(column: string): this; + first(): this; + from(table: string): this; + from(builder: Knex$QueryBuilderFn|Knex$Knex|Knex$QueryBuilder): this; + + insert(): this; + del(): this; + delete(): this; + update(): this; + returning(columns: string[]): this; + columnInfo(column?: string): this; +} + +declare class Knex$TableBuilder$Chainable { + index(): this; + primary(): this; + unique(): this; + references(): this; + inTable(): this; + onDelete(): this; + onUpdate(): this; + defaultTo(): this; + unsigned(): this; + notNullable(): this; + nullable(): this; + first(): this; + after(): this; + comment(): this; + collate(): this; +} + +declare class Knex$TableBuilder { + dropColumn(): Knex$TableBuilder$Chainable; + dropColumns(): Knex$TableBuilder$Chainable; + renameColumn(): Knex$TableBuilder$Chainable; + increments(): Knex$TableBuilder$Chainable; + integer(): Knex$TableBuilder$Chainable; + bigInteger(): Knex$TableBuilder$Chainable; + text(): Knex$TableBuilder$Chainable; + string(): Knex$TableBuilder$Chainable; + float(): Knex$TableBuilder$Chainable; + decimal(): Knex$TableBuilder$Chainable; + boolean(): Knex$TableBuilder$Chainable; + date(): Knex$TableBuilder$Chainable; + dateTime(): Knex$TableBuilder$Chainable; + time(): Knex$TableBuilder$Chainable; + timestamp(): Knex$TableBuilder$Chainable; + timestamps(): Knex$TableBuilder$Chainable; + dropTimestamps(): Knex$TableBuilder$Chainable; + binary(): Knex$TableBuilder$Chainable; + enu(): Knex$TableBuilder$Chainable; + enum(): Knex$TableBuilder$Chainable; + json(): Knex$TableBuilder$Chainable; + jsonb(): Knex$TableBuilder$Chainable; + uuid(): Knex$TableBuilder$Chainable; + comment(): Knex$TableBuilder$Chainable; + engine(): Knex$TableBuilder$Chainable; + charset(): Knex$TableBuilder$Chainable; + collate(): Knex$TableBuilder$Chainable; + inherits(): Knex$TableBuilder$Chainable; + specificType(): Knex$TableBuilder$Chainable; + index(): Knex$TableBuilder$Chainable; + dropIndex(): Knex$TableBuilder$Chainable; + unique(): Knex$TableBuilder$Chainable; + foreign(): Knex$TableBuilder$Chainable; + dropForeign(): Knex$TableBuilder$Chainable; + dropUnique(): Knex$TableBuilder$Chainable; + dropPrimary(): Knex$TableBuilder$Chainable; +} + +type Knex$TableBuilderFn = (table: Knex$TableBuilder) => void; + +declare class Knex$SchemaBuilder mixins Promise { + withSchema(schemaName: string): this; + createTable(tableName: string, fn: Knex$TableBuilderFn): this; + createTableIfNotExists( + tableName: string, + fn: Knex$TableBuilderFn + ): Knex$SchemaBuilder; + renameTable(from: string, to: string): this; + dropTable(tableName: string): this; + hasColumn(tableName: string, columnName: string): this; + hasTable(tableName: string): this; + dropTableIfExists(tableName: string): this; + table(tableName: string, fn: Knex$TableBuilderFn): this; + raw(statement: string): this; +} + +declare class Knex$Knex mixins Knex$QueryBuilder, Promise { + schema: Knex$SchemaBuilder; + + static (config: Knex$Config): Knex$Knex; + static QueryBuilder: typeof Knex$QueryBuilder; + (tableName: string): Knex$QueryBuilder; + raw(sqlString: string): any; + client: any; + destroy(): Promise; + transaction( + fn: (trx: Knex$Transaction) => void | Knex$Transaction + ): Knex$Transaction; +} + +declare type Knex$PostgresConfig = { + client?: 'pg', + connection?: { + host?: string, + user?: string, + password?: string, + database?: string, + charset?: string, + }, + searchPath?: string, +} +declare type Knex$MysqlConfig = { + client?: 'mysql', + connection?: { + host?: string, + user?: string, + password?: string, + database?: string, + }, +} +declare type Knex$SqliteConfig = { + client?: 'sqlite3', + connection?: { + filename?: string, + } +} +declare type Knex$Config = Knex$PostgresConfig | Knex$MysqlConfig | Knex$SqliteConfig; + +declare module 'knex' { + declare type Error = { + name: string, + length: number, + severity: string, + code: string, + detail: string, + hint?: string, + position?: any, + intenralPosition?: any, + internalQuery?: any, + where?: any, + schema: string, + table: string, + column?: any, + dataType?: any, + constraint?: string, + file: string, + line: string, + routine: string, + } + declare var exports: typeof Knex$Knex; +} diff --git a/flow-typed/npm/eslint-plugin-flowtype_vx.x.x.js b/flow-typed/npm/eslint-plugin-flowtype_vx.x.x.js index a3268ab4..a20025af 100644 --- a/flow-typed/npm/eslint-plugin-flowtype_vx.x.x.js +++ b/flow-typed/npm/eslint-plugin-flowtype_vx.x.x.js @@ -1,5 +1,5 @@ -// flow-typed signature: cdd60fb1060868f336a433797673f158 -// flow-typed version: <>/eslint-plugin-flowtype_v2.25.0/flow_v0.36.0 +// flow-typed signature: ede45426c6f88a90cd234ce25d4594d1 +// flow-typed version: <>/eslint-plugin-flowtype_v2.28.2/flow_v0.36.0 /** * This is an autogenerated libdef stub for: @@ -50,6 +50,10 @@ declare module 'eslint-plugin-flowtype/dist/rules/noDupeKeys' { declare module.exports: any; } +declare module 'eslint-plugin-flowtype/dist/rules/noPrimitiveConstructorTypes' { + declare module.exports: any; +} + declare module 'eslint-plugin-flowtype/dist/rules/noWeakTypes' { declare module.exports: any; } @@ -70,6 +74,10 @@ declare module 'eslint-plugin-flowtype/dist/rules/requireValidFileAnnotation' { declare module.exports: any; } +declare module 'eslint-plugin-flowtype/dist/rules/requireVariableType' { + declare module.exports: any; +} + declare module 'eslint-plugin-flowtype/dist/rules/semi' { declare module.exports: any; } @@ -138,6 +146,14 @@ declare module 'eslint-plugin-flowtype/dist/rules/validSyntax' { declare module.exports: any; } +declare module 'eslint-plugin-flowtype/dist/utilities/checkFlowFileAnnotation' { + declare module.exports: any; +} + +declare module 'eslint-plugin-flowtype/dist/utilities/fuzzyStringMatch' { + declare module.exports: any; +} + declare module 'eslint-plugin-flowtype/dist/utilities/getParameterName' { declare module.exports: any; } @@ -196,6 +212,9 @@ declare module 'eslint-plugin-flowtype/dist/rules/genericSpacing.js' { declare module 'eslint-plugin-flowtype/dist/rules/noDupeKeys.js' { declare module.exports: $Exports<'eslint-plugin-flowtype/dist/rules/noDupeKeys'>; } +declare module 'eslint-plugin-flowtype/dist/rules/noPrimitiveConstructorTypes.js' { + declare module.exports: $Exports<'eslint-plugin-flowtype/dist/rules/noPrimitiveConstructorTypes'>; +} declare module 'eslint-plugin-flowtype/dist/rules/noWeakTypes.js' { declare module.exports: $Exports<'eslint-plugin-flowtype/dist/rules/noWeakTypes'>; } @@ -211,6 +230,9 @@ declare module 'eslint-plugin-flowtype/dist/rules/requireReturnType.js' { declare module 'eslint-plugin-flowtype/dist/rules/requireValidFileAnnotation.js' { declare module.exports: $Exports<'eslint-plugin-flowtype/dist/rules/requireValidFileAnnotation'>; } +declare module 'eslint-plugin-flowtype/dist/rules/requireVariableType.js' { + declare module.exports: $Exports<'eslint-plugin-flowtype/dist/rules/requireVariableType'>; +} declare module 'eslint-plugin-flowtype/dist/rules/semi.js' { declare module.exports: $Exports<'eslint-plugin-flowtype/dist/rules/semi'>; } @@ -262,6 +284,12 @@ declare module 'eslint-plugin-flowtype/dist/rules/useFlowType.js' { declare module 'eslint-plugin-flowtype/dist/rules/validSyntax.js' { declare module.exports: $Exports<'eslint-plugin-flowtype/dist/rules/validSyntax'>; } +declare module 'eslint-plugin-flowtype/dist/utilities/checkFlowFileAnnotation.js' { + declare module.exports: $Exports<'eslint-plugin-flowtype/dist/utilities/checkFlowFileAnnotation'>; +} +declare module 'eslint-plugin-flowtype/dist/utilities/fuzzyStringMatch.js' { + declare module.exports: $Exports<'eslint-plugin-flowtype/dist/utilities/fuzzyStringMatch'>; +} declare module 'eslint-plugin-flowtype/dist/utilities/getParameterName.js' { declare module.exports: $Exports<'eslint-plugin-flowtype/dist/utilities/getParameterName'>; } diff --git a/flow-typed/npm/eslint_vx.x.x.js b/flow-typed/npm/eslint_vx.x.x.js index efebf37a..247f853f 100644 --- a/flow-typed/npm/eslint_vx.x.x.js +++ b/flow-typed/npm/eslint_vx.x.x.js @@ -1,5 +1,5 @@ -// flow-typed signature: 4d76b6c035119077eeaeed102f9c639a -// flow-typed version: <>/eslint_v3.10.2/flow_v0.36.0 +// flow-typed signature: 142d5494bbec84572976ab5f77caf191 +// flow-typed version: <>/eslint_v3.11.1/flow_v0.36.0 /** * This is an autogenerated libdef stub for: @@ -250,6 +250,10 @@ declare module 'eslint/lib/rules/camelcase' { declare module.exports: any; } +declare module 'eslint/lib/rules/capitalized-comments' { + declare module.exports: any; +} + declare module 'eslint/lib/rules/class-methods-use-this' { declare module.exports: any; } @@ -1042,6 +1046,10 @@ declare module 'eslint/lib/rules/radix' { declare module.exports: any; } +declare module 'eslint/lib/rules/require-await' { + declare module.exports: any; +} + declare module 'eslint/lib/rules/require-jsdoc' { declare module.exports: any; } @@ -1198,6 +1206,10 @@ declare module 'eslint/lib/util/path-util' { declare module.exports: any; } +declare module 'eslint/lib/util/patterns/letters' { + declare module.exports: any; +} + declare module 'eslint/lib/util/rule-fixer' { declare module.exports: any; } @@ -1394,6 +1406,9 @@ declare module 'eslint/lib/rules/callback-return.js' { declare module 'eslint/lib/rules/camelcase.js' { declare module.exports: $Exports<'eslint/lib/rules/camelcase'>; } +declare module 'eslint/lib/rules/capitalized-comments.js' { + declare module.exports: $Exports<'eslint/lib/rules/capitalized-comments'>; +} declare module 'eslint/lib/rules/class-methods-use-this.js' { declare module.exports: $Exports<'eslint/lib/rules/class-methods-use-this'>; } @@ -1988,6 +2003,9 @@ declare module 'eslint/lib/rules/quotes.js' { declare module 'eslint/lib/rules/radix.js' { declare module.exports: $Exports<'eslint/lib/rules/radix'>; } +declare module 'eslint/lib/rules/require-await.js' { + declare module.exports: $Exports<'eslint/lib/rules/require-await'>; +} declare module 'eslint/lib/rules/require-jsdoc.js' { declare module.exports: $Exports<'eslint/lib/rules/require-jsdoc'>; } @@ -2105,6 +2123,9 @@ declare module 'eslint/lib/util/npm-util.js' { declare module 'eslint/lib/util/path-util.js' { declare module.exports: $Exports<'eslint/lib/util/path-util'>; } +declare module 'eslint/lib/util/patterns/letters.js' { + declare module.exports: $Exports<'eslint/lib/util/patterns/letters'>; +} declare module 'eslint/lib/util/rule-fixer.js' { declare module.exports: $Exports<'eslint/lib/util/rule-fixer'>; } diff --git a/flow-typed/npm/knex_v0.12.x.js b/flow-typed/npm/knex_v0.12.x.js new file mode 100644 index 00000000..37ed60f2 --- /dev/null +++ b/flow-typed/npm/knex_v0.12.x.js @@ -0,0 +1,164 @@ +// flow-typed signature: eba1abf3f431d3d8e8583558e192e7a8 +// flow-typed version: 5ae7b07558/knex_v0.12.x/flow_>=v0.33.x + +declare class Knex$Transaction mixins Knex$QueryBuilder, events$EventEmitter, Promise { + commit(connection?: any, value?: any): Promise; + rollback(): Promise; + savepoint(connection?: any): Promise; +} + +declare type Knex$QueryBuilderFn = (qb: Knex$QueryBuilder) => Knex$QueryBuilder; + +declare class Knex$QueryBuilder mixins Promise { + select(key?: string[]): this; + select(...key: string[]): this; + timeout(ms: number, options?: { cancel: bool }): this; + column(key: string[]): this; + column(...key: string[]): this; + with(alias: string, w: string|Knex$QueryBuilderFn): this; + withSchema(schema: string): this; + returning(column: string): this; + returning(...columns: string[]): this; + returning(columns: string[]): this; + as(name: string): this; + transacting(trx: ?Knex$Transaction): this; + where(builder: Knex$QueryBuilderFn): this; + where(column: string, value: any): this; + where(column: string, operator: string, value: any): this; + whereNot(builder: Knex$QueryBuilderFn): this; + whereNot(column: string, value: any): this; + whereNot(column: string, operator: string, value: any): this; + whereIn(column: string, values: any[]): this; + whereNotIn(column: string, values: any[]): this; + whereNull(column: string): this; + whereNotNull(column: string): this; + whereExists(column: string): this; + whereNotExists(column: string): this; + whereBetween(column: string, range: number[]): this; + whereNotBetween(column: string, range: number[]): this; + whereRaw(raw: any): this; + orWhere(builder: Knex$QueryBuilderFn): this; + orWhere(column: string, value: any): this; + orWhere(column: string, operator: string, value: any): this; + orWhereNot(builder: Knex$QueryBuilderFn): this; + orWhereNot(column: string, value: any): this; + orWhereNot(column: string, operator: string, value: any): this; + orWhereIn(column: string, values: any[]): this; + orWhereNotIn(column: string, values: any[]): this; + orWhereNull(column: string): this; + orWhereNotNull(column: string): this; + orWhereExists(column: string): this; + orWhereNotExists(column: string): this; + orWhereBetween(column: string, range: number[]): this; + orWhereNotBetween(column: string, range: number[]): this; + orWhereRaw(raw: any): this; + innerJoin(table: string, c1: string, operator: string, c2: string): this; + innerJoin(table: string, c1: string, c2: string): this; + innerJoin(builder: Knex$QueryBuilder, c1?: string, c2?: string): this; + leftJoin(table: string, c1: string, operator: string, c2: string): this; + leftJoin(table: string, c1: string, c2: string): this; + leftJoin(builder: Knex$QueryBuilder): this; + leftOuterJoin(table: string, c1: string, operator: string, c2: string): this; + leftOuterJoin(table: string, c1: string, c2: string): this; + rightJoin(table: string, c1: string, operator: string, c2: string): this; + rightJoin(table: string, c1: string, c2: string): this; + rightOuterJoin(table: string, c1: string, operator: string, c2: string): this; + rightOuterJoin(table: string, c1: string, c2: string): this; + outerJoin(table: string, c1: string, operator: string, c2: string): this; + outerJoin(table: string, c1: string, c2: string): this; + fullOuterJoin(table: string, c1: string, operator: string, c2: string): this; + fullOuterJoin(table: string, c1: string, c2: string): this; + crossJoin(column: string, c1: string, c2: string): this; + crossJoin(column: string, c1: string, operator: string, c2: string): this; + joinRaw(sql: string, bindings?: mixed[]): this; + distinct(): this; + groupBy(column: string): this; + groupByRaw(): this; + orderBy(column: string, direction?: 'desc' | 'asc'): this; + orderByRaw(): this; + offset(offset: number): this; + limit(limit: number): this; + having(column: string, operator: string, value: mixed): this; + union(): this; + unionAll(): this; + count(column?: string): this; + countDistinct(column?: string): this; + min(column?: string): this; + max(column?: string): this; + sum(column?: string): this; + sumDistinct(column?: string): this; + avg(column?: string): this; + avgDistinct(column?: string): this; + pluck(column: string): this; + first(): this; + from(table: string): this; + from(builder: Knex$QueryBuilderFn|Knex$Knex|Knex$QueryBuilder): this; + + insert(): this; + del(): this; + delete(): this; + update(): this; + returning(columns: string[]): this; +} + +declare class Knex$Knex mixins Knex$QueryBuilder, Promise { + static (config: Knex$Config): Knex$Knex; + static QueryBuilder: typeof Knex$QueryBuilder; + (tableName: string): Knex$QueryBuilder; + raw(sqlString: string): any; + client: any; + destroy(): Promise; + +} + +declare type Knex$PostgresConfig = { + client?: 'pg', + connection?: { + host?: string, + user?: string, + password?: string, + database?: string, + charset?: string, + }, + searchPath?: string, +} +declare type Knex$MysqlConfig = { + client?: 'mysql', + connection?: { + host?: string, + user?: string, + password?: string, + database?: string, + }, +} +declare type Knex$SqliteConfig = { + client?: 'sqlite3', + connection?: { + filename?: string, + } +} +declare type Knex$Config = Knex$PostgresConfig | Knex$MysqlConfig | Knex$SqliteConfig; + +declare module 'knex' { + declare type Error = { + name: string, + length: number, + severity: string, + code: string, + detail: string, + hint?: string, + position?: any, + intenralPosition?: any, + internalQuery?: any, + where?: any, + schema: string, + table: string, + column?: any, + dataType?: any, + constraint?: string, + file: string, + line: string, + routine: string, + } + declare var exports: typeof Knex$Knex; +} diff --git a/flow-typed/npm/rollup_vx.x.x.js b/flow-typed/npm/rollup_vx.x.x.js index 0ba8eef1..251838d4 100644 --- a/flow-typed/npm/rollup_vx.x.x.js +++ b/flow-typed/npm/rollup_vx.x.x.js @@ -1,5 +1,5 @@ -// flow-typed signature: 3f5a85d5f0a33704d4ceb8a08ed89ff1 -// flow-typed version: <>/rollup_v0.36.3/flow_v0.36.0 +// flow-typed signature: 67d894a985306601a788f4ee1b124a20 +// flow-typed version: <>/rollup_v0.36.4/flow_v0.36.0 /** * This is an autogenerated libdef stub for: diff --git a/package.json b/package.json index 55d1020a..8bf15cfc 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "eslint": "3.11.1", "fb-watchman": "1.9.0", "inflection": "1.10.0", + "knex": "0.12.6", "ora": "0.3.0", "rollup": "0.36.4", "rollup-plugin-alias": "1.2.0", diff --git a/src/errors/module-missing-error.js b/src/errors/module-missing-error.js deleted file mode 100644 index 8d1be899..00000000 --- a/src/errors/module-missing-error.js +++ /dev/null @@ -1,19 +0,0 @@ -// @flow -import { red, green } from 'chalk'; - -import { line } from '../packages/logger'; - -/** - * @private - */ -class ModuleMissingError extends ReferenceError { - constructor(name: string) { - super(line` - ${red(`Could not find required module '${name}'.`)} - Please make sure '${name}' is listed as a dependency in your package.json - file and run ${green('npm install')}. - `); - } -} - -export default ModuleMissingError; diff --git a/src/packages/cli/commands/dbmigrate.js b/src/packages/cli/commands/dbmigrate.js index f6ad6c02..0e49377f 100644 --- a/src/packages/cli/commands/dbmigrate.js +++ b/src/packages/cli/commands/dbmigrate.js @@ -29,27 +29,23 @@ export async function dbmigrate() { const pending = await pendingMigrations(CWD, () => connection('migrations')); if (pending.length) { - await Promise.all( - pending.map(async migration => { - const version = migration.replace(/^(\d{16})-.+$/g, '$1'); - const key = migration.replace(new RegExp(`${version}-(.+)\\.js`), '$1'); - const value = migrations.get(`${key}-up`); - - if (value) { - const query = value.run(schema()); - - await query.on('query', () => { - process.stdout.write(sql`${query.toString()}`); - process.stdout.write(EOL); - }); - - await connection('migrations').insert({ - version - }); - } - - return migration; - }) - ); + for (const migration of pending) { + const version = migration.replace(/^(\d{16})-.+$/g, '$1'); + const key = migration.replace(new RegExp(`${version}-(.+)\\.js`), '$1'); + const value = migrations.get(`${key}-up`); + + if (value) { + const query = value.run(schema()); + + await query.on('query', () => { + process.stdout.write(sql`${query.toString()}`); + process.stdout.write(EOL); + }); + + await connection('migrations').insert({ + version + }); + } + } } } diff --git a/src/packages/cli/commands/dbseed.js b/src/packages/cli/commands/dbseed.js index 8e25002f..8bba0c96 100644 --- a/src/packages/cli/commands/dbseed.js +++ b/src/packages/cli/commands/dbseed.js @@ -13,15 +13,14 @@ export async function dbseed() { const seed = load('seed'); const models = load('models'); - await new Database({ + const store = await new Database({ config, models, path: CWD, - logger: new Logger({ enabled: false }) }); - await seed(); + await store.connection.transaction(seed); } diff --git a/src/packages/controller/index.js b/src/packages/controller/index.js index 22fde4b4..a5b2f2a0 100644 --- a/src/packages/controller/index.js +++ b/src/packages/controller/index.js @@ -8,7 +8,7 @@ import type { Request, Response } from '../server'; // eslint-disable-line max-l import findOne from './utils/find-one'; import findMany from './utils/find-many'; -import findRelated from './utils/find-related'; +import resolveRelationships from './utils/resolve-relationships'; import type { Controller$opts, Controller$beforeAction, @@ -622,8 +622,13 @@ class Controller { * @return {Promise} Resolves with the newly created Model instance. * @public */ - create(req: Request, res: Response): Promise { + async create(req: Request, res: Response): Promise { + const { model } = this; + const { + url: { + pathname + }, params: { data: { attributes, @@ -632,28 +637,19 @@ class Controller { } } = req; - return this.model - .create(attributes) - .then(record => { - if (relationships) { - return findRelated(this.controllers, relationships).then(related => { - Object.assign(record, related); - return record.save(true); - }); - } + const record = await model.create({ + ...attributes, + ...resolveRelationships(model, relationships) + }); - return record; - }) - .then(record => { - const { url: { pathname } } = req; - const id = record.getPrimaryKey(); - const location = `${getDomain(req) + pathname}/${id}`; + res.setHeader( + 'Location', + `${getDomain(req) + pathname}/${record.getPrimaryKey()}` + ); - res.statusCode = 201; // eslint-disable-line no-param-reassign - res.setHeader('Location', location); + Reflect.set(res, 'statusCode', 201); - return record; - }); + return record.unwrap(); } /** @@ -666,40 +662,35 @@ class Controller { * @param {Request} request - The request object. * @param {Response} response - The response object. * @return {Promise} Resolves with the updated Model if changes occur. - * Resolves with the number `204` if no - * changes occur. + * Resolves with the number `204` if no changes occur. * @public */ update(req: Request): Promise { - return findOne(this.model, req).then(record => { - const { - params: { - data: { - attributes, - relationships - } - } - } = req; - - Object.assign(record, attributes); + const { model } = this; - if (relationships) { + return findOne(model, req) + .then(record => { const { - route: { - controller: { - controllers + params: { + data: { + attributes, + relationships } } } = req; - return findRelated(controllers, relationships).then(related => { - Object.assign(record, related); - return record.save(true); + return record.update({ + ...attributes, + ...resolveRelationships(model, relationships) }); - } + }) + .then(record => { + if (record.didPersist) { + return record.unwrap(); + } - return record.isDirty ? record.save() : 204; - }); + return 204; + }); } /** diff --git a/src/packages/controller/test/controller.test.js b/src/packages/controller/test/controller.test.js index bc158400..112c4812 100644 --- a/src/packages/controller/test/controller.test.js +++ b/src/packages/controller/test/controller.test.js @@ -38,12 +38,14 @@ describe('module "controller"', () => { before(async () => { const app = await getTestApp(); - Post = setType(() => app.models.get('post')); + // $FlowIgnore + Post = app.models.get('post'); subject = new Controller({ model: Post, namespace: '', serializer: new Serializer({ + // $FlowIgnore model: Post, parent: null, namespace: '' @@ -303,6 +305,7 @@ describe('module "controller"', () => { assertRecord(result, [ 'id', + 'user', 'title', 'isPublic', 'createdAt', @@ -410,9 +413,11 @@ describe('module "controller"', () => { it('returns a record if relationships(s) change', async () => { let item = record; let user = await Reflect.get(item, 'user'); + let comments = await Reflect.get(item, 'comments'); const id = Reflect.get(item, 'id'); expect(user).to.be.null; + expect(comments).to.deep.equal([]); const request = createRequest({ id, @@ -425,11 +430,28 @@ describe('module "controller"', () => { id: 1, type: 'users' } + }, + comments: { + data: [ + { + id: 1, + type: 'comments' + }, + { + id: 2, + type: 'comments' + }, + { + id: 3, + type: 'comments' + } + ] } } }, fields: { - users: ['id'] + users: ['id'], + comments: ['id'] } }); @@ -437,13 +459,23 @@ describe('module "controller"', () => { assertRecord(result, [ ...attributes, - 'user' + 'user', + 'comments' ]); item = await Post.find(id); user = await Reflect.get(item, 'user'); + comments = await Reflect.get(item, 'comments'); expect(user.id).to.equal(1); + + expect(comments) + .to.be.an('array') + .with.lengthOf(3); + + comments.forEach((comment, index) => { + expect(comment).to.have.property('id', index + 1); + }); }); it('returns the number `204` if no changes occur', async () => { diff --git a/src/packages/controller/test/find-related.test.js b/src/packages/controller/test/find-related.test.js deleted file mode 100644 index 9d3e9fac..00000000 --- a/src/packages/controller/test/find-related.test.js +++ /dev/null @@ -1,84 +0,0 @@ -// @flow -import { expect } from 'chai'; -import { describe, it, before } from 'mocha'; - -import { Model } from '../../database'; -import setType from '../../../utils/set-type'; -import { getTestApp } from '../../../../test/utils/get-test-app'; -import findRelated from '../utils/find-related'; - -import type Controller from '../index'; - -describe('module "controller"', () => { - describe('util findRelated()', () => { - let controllers: Map; - - before(async () => { - const app = await getTestApp(); - - controllers = app.controllers; - }); - - it('resolves with the correct object', async () => { - const result = await findRelated(controllers, { - user: { - data: { - id: 1, - type: 'users' - } - }, - image: { - data: null - }, - comments: { - data: [ - { - id: 1, - type: 'comments' - }, - { - id: 2, - type: 'comments' - }, - { - id: 3, - type: 'comments' - }, - { - id: 4, - type: 'invalid-type' - } - ] - }, - reactions: { - data: 'invalid data...' - }, - invalidType: { - data: { - id: 1, - type: 'invalid-type' - } - } - }); - - expect(result).to.have.all.keys([ - 'user', - 'image', - 'comments' - ]); - - expect(result) - .to.have.property('user') - .and.be.an('object'); - - expect(result) - .to.have.property('image') - .and.be.null; - - expect(result) - .to.have.property('comments') - .and.be.an('array') - .with.lengthOf(3); - }); - }); -}); diff --git a/src/packages/controller/utils/find-related.js b/src/packages/controller/utils/find-related.js deleted file mode 100644 index 752a30ea..00000000 --- a/src/packages/controller/utils/find-related.js +++ /dev/null @@ -1,64 +0,0 @@ -// @flow -import isNull from '../../../utils/is-null'; -import entries from '../../../utils/entries'; -import isObject from '../../../utils/is-object'; -import promiseHash from '../../../utils/promise-hash'; -import type { JSONAPI$IdentifierObject } from '../../jsonapi'; -import type Controller from '../index'; - -/** - * @private - */ -export default function findRelated( - controllers: Map, - relationships: Object -) { - return promiseHash( - entries(relationships).reduce((result, [key, { data: value }]: [string, { - data: JSONAPI$IdentifierObject | Array - }]) => { - if (isNull(value)) { - return { - ...result, - [key]: value - }; - } - - if (Array.isArray(value)) { - return { - ...result, - [key]: Promise.all( - value.reduce((arr, { id, type }) => { - const controller = controllers.get(type); - - if (controller) { - return [ - ...arr, - controller.model.find(id) - ]; - } - - return arr; - }, []) - ) - }; - } - - if (isObject(value)) { - const { id, type } = value; - const controller = controllers.get(type); - - if (!controller) { - return result; - } - - return { - ...result, - [key]: controller.model.find(id) - }; - } - - return result; - }, {}) - ); -} diff --git a/src/packages/controller/utils/resolve-relationships.js b/src/packages/controller/utils/resolve-relationships.js new file mode 100644 index 00000000..255cfe01 --- /dev/null +++ b/src/packages/controller/utils/resolve-relationships.js @@ -0,0 +1,33 @@ +// @flow +import entries from '../../../utils/entries'; +// eslint-disable-next-line no-unused-vars +import type { Model } from '../../database'; + +/** + * @private + */ +export default function resolveRelationships( + model: Class, + relationships: Object = {} +): Object { + return entries(relationships).reduce((obj, [key, value]) => { + let { data = null } = value || {}; + + if (data) { + const opts = model.relationshipFor(key); + + if (opts) { + if (Array.isArray(data)) { + data = data.map(item => Reflect.construct(opts.model, [item])); + } else { + data = Reflect.construct(opts.model, [data]); + } + } + } + + return { + ...obj, + [key]: data + }; + }, {}); +} diff --git a/src/packages/database/model/attribute/index.js b/src/packages/database/attribute/index.js similarity index 100% rename from src/packages/database/model/attribute/index.js rename to src/packages/database/attribute/index.js diff --git a/src/packages/database/model/attribute/interfaces.js b/src/packages/database/attribute/interfaces.js similarity index 100% rename from src/packages/database/model/attribute/interfaces.js rename to src/packages/database/attribute/interfaces.js diff --git a/src/packages/database/attribute/utils/create-attribute.js b/src/packages/database/attribute/utils/create-attribute.js new file mode 100644 index 00000000..e3264345 --- /dev/null +++ b/src/packages/database/attribute/utils/create-attribute.js @@ -0,0 +1,23 @@ +// @flow +import type { Attribute$meta } from '../index'; + +import createGetter from './create-getter'; +import createSetter from './create-setter'; +import createNormalizer from './create-normalizer'; + +/** + * @private + */ +export default function createAttribute(opts: Attribute$meta): Object { + const normalize = createNormalizer(opts.type); + const meta = { + ...opts, + normalize, + defaultValue: normalize(opts.defaultValue) + }; + + return { + get: createGetter(meta), + set: createSetter(meta) + }; +} diff --git a/src/packages/database/model/attribute/utils/create-getter.js b/src/packages/database/attribute/utils/create-getter.js similarity index 56% rename from src/packages/database/model/attribute/utils/create-getter.js rename to src/packages/database/attribute/utils/create-getter.js index 395fdd6b..5850e810 100644 --- a/src/packages/database/model/attribute/utils/create-getter.js +++ b/src/packages/database/attribute/utils/create-getter.js @@ -1,17 +1,14 @@ // @flow import type { Attribute$meta } from '../index'; -import isNull from '../../../../../utils/is-null'; -import isUndefined from '../../../../../utils/is-undefined'; - -import refsFor from './refs-for'; +import isNull from '../../../../utils/is-null'; +import isUndefined from '../../../../utils/is-undefined'; export default function createGetter({ key, defaultValue }: Attribute$meta): () => any { return function getter() { - const refs = refsFor(this); - let value = Reflect.get(refs, key); + let value = this.currentChangeSet.get(key); if (isNull(value) || isUndefined(value)) { value = defaultValue; diff --git a/src/packages/database/model/attribute/utils/create-normalizer.js b/src/packages/database/attribute/utils/create-normalizer.js similarity index 100% rename from src/packages/database/model/attribute/utils/create-normalizer.js rename to src/packages/database/attribute/utils/create-normalizer.js diff --git a/src/packages/database/attribute/utils/create-setter.js b/src/packages/database/attribute/utils/create-setter.js new file mode 100644 index 00000000..3b760170 --- /dev/null +++ b/src/packages/database/attribute/utils/create-setter.js @@ -0,0 +1,36 @@ +// @flow +import isNull from '../../../../utils/is-null'; +import isUndefined from '../../../../utils/is-undefined'; +import type { Attribute$meta } from '../index'; + +/** + * @private + */ +export default function createSetter({ + key, + nullable, + normalize, + defaultValue +}: Attribute$meta & { + normalize: (value: any) => any +}): (value?: any) => void { + return function setter(nextValue) { + if (!nullable) { + if (isNull(nextValue) || isUndefined(nextValue)) { + return; + } + } + + let { currentChangeSet: changeSet } = this; + const valueToSet = normalize(nextValue); + const currentValue = changeSet.get(key) || defaultValue; + + if (!changeSet.has(key) || valueToSet !== currentValue) { + if (changeSet.isPersisted) { + changeSet = changeSet.applyTo(this); + } + + changeSet.set(key, valueToSet); + } + }; +} diff --git a/src/packages/database/change-set/index.js b/src/packages/database/change-set/index.js new file mode 100644 index 00000000..c2513686 --- /dev/null +++ b/src/packages/database/change-set/index.js @@ -0,0 +1,49 @@ +// @flow +import type { Model } from '../index'; +import entries from '../../../utils/entries'; +import mapToObject from '../../../utils/map-to-object'; + +class ChangeSet extends Map { + isPersisted: boolean; + + constructor(data?: Object = {}): this { + super(entries(data)); + + this.isPersisted = false; + + return this; + } + + set(key: string, value: mixed): this { + if (!this.isPersisted) { + super.set(key, value); + } + + return this; + } + + persist(group?: Array): this { + if (group) { + group.forEach(changeSet => changeSet.unpersist()); + } + + this.isPersisted = true; + + return this; + } + + unpersist(): this { + this.isPersisted = false; + return this; + } + + applyTo(target: Model): ChangeSet { + const instance = new ChangeSet(mapToObject(this)); + + target.changeSets.unshift(instance); + + return instance; + } +} + +export default ChangeSet; diff --git a/src/packages/database/constants.js b/src/packages/database/constants.js index 8d729935..1691f87b 100644 --- a/src/packages/database/constants.js +++ b/src/packages/database/constants.js @@ -1,7 +1,4 @@ // @flow -import type { Model } from './index'; - -export const NEW_RECORDS: WeakSet = new WeakSet(); export const UNIQUE_CONSTRAINT = /UNIQUE\sCONSTRAINT/ig; export const VALID_DRIVERS = [ diff --git a/src/packages/database/index.js b/src/packages/database/index.js index 21124603..6654b397 100644 --- a/src/packages/database/index.js +++ b/src/packages/database/index.js @@ -1,4 +1,6 @@ // @flow +import type Knex from 'knex'; + import type Logger from '../logger'; import { ModelMissingError } from './errors'; @@ -19,9 +21,9 @@ class Database { config: Object; - schema: Function; + schema: () => $PropertyType; - connection: any; + connection: Knex; models: Map>; diff --git a/src/packages/database/migration/index.js b/src/packages/database/migration/index.js index a569d853..8d01c025 100644 --- a/src/packages/database/migration/index.js +++ b/src/packages/database/migration/index.js @@ -1,19 +1,21 @@ // @flow +import type { Migration$Fn } from './interfaces'; /** * @private */ -class Migration Promise> { - fn: T; +class Migration { + fn: Migration$Fn; - constructor(fn: T) { + constructor(fn: Migration$Fn) { this.fn = fn; } - run(schema: Object) { + run(schema: T): T { return this.fn(schema); } } export default Migration; export { default as generateTimestamp } from './utils/generate-timestamp'; +export type { Migration$Fn } from './interfaces'; diff --git a/src/packages/database/migration/interfaces.js b/src/packages/database/migration/interfaces.js new file mode 100644 index 00000000..c33eb349 --- /dev/null +++ b/src/packages/database/migration/interfaces.js @@ -0,0 +1,6 @@ +// @flow + +/** + * @private + */ +export type Migration$Fn = (schema: T) => T; diff --git a/src/packages/database/migration/utils/generate-timestamp.js b/src/packages/database/migration/utils/generate-timestamp.js index 8100c274..efc6ffac 100644 --- a/src/packages/database/migration/utils/generate-timestamp.js +++ b/src/packages/database/migration/utils/generate-timestamp.js @@ -1,15 +1,15 @@ // @flow -function formatInt(int: number) { +function formatInt(int: number): string { return (int / 10).toString().replace('.', '').substr(0, 2); } -function* padding(char: string, amount: number) { +function* padding(char: string, amount: number): Generator { for (let i = 0; i < amount; i += 1) { yield char; } } -export default function generateTimestamp() { +export default function generateTimestamp(): string { const now = new Date(); const timestamp = now.toISOString() .substr(0, 10) diff --git a/src/packages/database/model/attribute/utils/create-setter.js b/src/packages/database/model/attribute/utils/create-setter.js deleted file mode 100644 index 6be2cb39..00000000 --- a/src/packages/database/model/attribute/utils/create-setter.js +++ /dev/null @@ -1,46 +0,0 @@ -// @flow -import type { Attribute$meta } from '../index'; -import isNull from '../../../../../utils/is-null'; -import isUndefined from '../../../../../utils/is-undefined'; - -import refsFor from './refs-for'; - -/** - * @private - */ -export default function createSetter({ - key, - nullable, - normalize, - defaultValue -}: Attribute$meta & { - normalize: (value: any) => any -}): (value?: any) => void { - return function setter(nextValue) { - if (!nullable) { - if (isNull(nextValue) || isUndefined(nextValue)) { - return; - } - } - - const refs = refsFor(this); - const valueToSet = normalize(nextValue); - const currentValue = Reflect.get(refs, key) || defaultValue; - - if (valueToSet !== currentValue) { - Reflect.set(refs, key, valueToSet); - - if (this.initialized) { - const initialValue = this.initialValues.get(key) || defaultValue; - - if (valueToSet !== initialValue) { - this.dirtyAttributes.add(key); - } else { - this.dirtyAttributes.delete(key); - } - } else { - this.initialValues.set(key, valueToSet); - } - } - }; -} diff --git a/src/packages/database/model/attribute/utils/refs-for.js b/src/packages/database/model/attribute/utils/refs-for.js deleted file mode 100644 index bae0af11..00000000 --- a/src/packages/database/model/attribute/utils/refs-for.js +++ /dev/null @@ -1,18 +0,0 @@ -// @flow -import type Model from '../../index'; - -export const REFS: WeakMap = new WeakMap(); - -/** - * @private - */ -export default function refsFor(instance: Model) { - let table = REFS.get(instance); - - if (!table) { - table = Object.create(null); - REFS.set(instance, table); - } - - return table; -} diff --git a/src/packages/database/model/index.js b/src/packages/database/model/index.js index 402379d5..838606e8 100644 --- a/src/packages/database/model/index.js +++ b/src/packages/database/model/index.js @@ -1,28 +1,36 @@ // @flow import { pluralize } from 'inflection'; -import { NEW_RECORDS } from '../constants'; import Query from '../query'; -import { sql } from '../../logger'; -import { saveRelationships } from '../relationship'; +import ChangeSet from '../change-set'; +import { updateRelationship } from '../relationship'; +import { + createTransactionResultProxy, + createStaticTransactionProxy, + createInstanceTransactionProxy +} from '../transaction'; import pick from '../../../utils/pick'; -import omit from '../../../utils/omit'; -import chain from '../../../utils/chain'; -import setType from '../../../utils/set-type'; import underscore from '../../../utils/underscore'; -import type Logger from '../../logger'; // eslint-disable-line max-len, no-duplicate-imports +import { compose } from '../../../utils/compose'; +import { map as diffMap } from '../../../utils/diff'; +import mapToObject from '../../../utils/map-to-object'; +import type Logger from '../../logger'; import type Database from '../../database'; import type Serializer from '../../serializer'; -import type { Relationship$opts } from '../relationship'; // eslint-disable-line max-len, no-duplicate-imports +/* eslint-disable no-duplicate-imports */ +import type { Relationship$opts } from '../relationship'; +import type { Transaction$ResultProxy } from '../transaction'; +/* eslint-enable no-duplicate-imports */ +import { create, update, destroy, createRunner } from './utils/persistence'; import initializeClass from './initialize-class'; -import getColumns from './utils/get-columns'; import validate from './utils/validate'; +import runHooks from './utils/run-hooks'; +import type { Model$Hooks } from './interfaces'; /** * ## Overview * - * * @module lux-framework * @namespace Lux * @class Model @@ -58,6 +66,24 @@ class Model { */ resourceName: string; + /** + * A timestamp representing when the Model instance was created. + * + * @property createdAt + * @type {Date} + * @public + */ + createdAt: Date; + + /** + * A timestamp representing the last time the Model instance was updated. + * + * @property updatedAt + * @type {Date} + * @public + */ + updatedAt: Date; + /** * @property initialized * @type {Boolean} @@ -73,30 +99,21 @@ class Model { rawColumnData: Object; /** - * @property initialValues - * @type {Map} - * @private - */ - initialValues: Map; - - /** - * @property dirtyAttributes + * @property prevAssociations * @type {Set} * @private */ - dirtyAttributes: Set; + isModelInstance: boolean; /** - * @property prevAssociations - * @type {Set} * @private */ - isModelInstance: boolean; + prevAssociations: Set; /** * @private */ - prevAssociations: Set; + changeSets: Array; /** * A reference to the instance of the `Logger` used for the `Application` the @@ -155,7 +172,15 @@ class Model { * @static * @private */ - static table; + static table: () => Knex$QueryBuilder; + + /** + * @property hooks + * @type {Object} + * @static + * @private + */ + static hooks: Model$Hooks; /** * @property store @@ -213,30 +238,16 @@ class Model { */ static relationshipNames: Array; - constructor(attrs: Object = {}, initialize: boolean = true) { - const { constructor: { attributeNames, relationshipNames } } = this; - + constructor(attrs: Object = {}, initialize: boolean = true): this { Object.defineProperties(this, { - rawColumnData: { - value: attrs, - writable: false, - enumerable: false, - configurable: false - }, - initialValues: { - value: new Map(), + changeSets: { + value: [new ChangeSet()], writable: false, enumerable: false, configurable: false }, - dirtyAttributes: { - value: new Set(), - writable: false, - enumerable: false, - configurable: false - }, - isModelInstance: { - value: true, + rawColumnData: { + value: attrs, writable: false, enumerable: false, configurable: false @@ -249,6 +260,7 @@ class Model { } }); + const { constructor: { attributeNames, relationshipNames } } = this; const props = pick(attrs, ...attributeNames.concat(relationshipNames)); Object.assign(this, props); @@ -260,13 +272,8 @@ class Model { enumerable: false, configurable: false }); - - Object.freeze(this); - Object.freeze(this.rawColumnData); } - NEW_RECORDS.add(this); - return this; } @@ -300,7 +307,7 @@ class Model { * @public */ get isNew(): boolean { - return NEW_RECORDS.has(this); + return !this.persistedChangeSet; } /** @@ -333,7 +340,7 @@ class Model { * @public */ get isDirty(): boolean { - return Boolean(this.dirtyAttributes.size); + return Boolean(this.dirtyProperties.size); } /** @@ -369,6 +376,80 @@ class Model { return !this.isNew && !this.isDirty; } + /** + * @property dirtyProperties + * @type {Map} + */ + get dirtyProperties(): Map { + const { currentChangeSet, persistedChangeSet } = this; + + if (!persistedChangeSet) { + return new Map(currentChangeSet); + } + + return diffMap(persistedChangeSet, currentChangeSet); + } + + /** + * @property dirtyAttributes + * @type {Map} + */ + get dirtyAttributes(): Map { + const { + dirtyProperties, + constructor: { + relationshipNames + } + } = this; + + Array + .from(dirtyProperties.keys()) + .forEach(key => { + if (relationshipNames.indexOf(key) >= 0) { + dirtyProperties.delete(key); + } + }); + + return dirtyProperties; + } + + /** + * @property dirtyRelationships + * @type {Map} + */ + get dirtyRelationships(): Map { + const { + dirtyProperties, + constructor: { + attributeNames + } + } = this; + + Array + .from(dirtyProperties.keys()) + .forEach(key => { + if (attributeNames.indexOf(key) >= 0) { + dirtyProperties.delete(key); + } + }); + + return dirtyProperties; + } + + /** + * @private + */ + get currentChangeSet(): ChangeSet { + return this.changeSets[0]; + } + + /** + * @private + */ + get persistedChangeSet(): void | ChangeSet { + return this.changeSets.find(({ isPersisted }) => isPersisted); + } + static get hasOne(): Object { return Object.freeze({}); } @@ -414,21 +495,6 @@ class Model { } } - static get hooks(): Object { - return Object.freeze({}); - } - - static set hooks(value: Object): void { - if (value && Object.keys(value).length) { - Reflect.defineProperty(this, 'hooks', { - value, - writable: true, - enumerable: false, - configurable: true - }); - } - } - static get scopes(): Object { return Object.freeze({}); } @@ -459,141 +525,147 @@ class Model { } } - async save(deep?: boolean): Promise { - const { - constructor: { - table, - logger, - primaryKey, - - hooks: { - afterUpdate, - afterSave, - afterValidation, - beforeUpdate, - beforeSave, - beforeValidation - } - } - } = this; + transacting(trx: Knex$Transaction): this { + return createInstanceTransactionProxy(this, trx); + } - if (typeof beforeValidation === 'function') { - await beforeValidation(this); - } + transaction(fn: (...args: Array) => Promise): Promise { + return this.constructor.transaction(fn); + } - validate(this); + save( + transaction?: Knex$Transaction + ): Promise> { + return this.update(mapToObject(this.dirtyProperties), transaction); + } - if (typeof afterValidation === 'function') { - await afterValidation(this); - } + update( + props: Object = {}, + transaction?: Knex$Transaction + ): Promise> { + const run = async (trx: Knex$Transaction) => { + const { constructor: { hooks, logger } } = this; + let statements = []; + let promise = Promise.resolve([]); + let hadDirtyAttrs = false; + let hadDirtyAssoc = false; - if (typeof beforeUpdate === 'function') { - await beforeUpdate(this); - } + const associations = Object + .keys(props) + .filter(key => ( + Boolean(this.constructor.relationshipFor(key)) + )); - if (typeof beforeSave === 'function') { - await beforeSave(this); - } + Object.assign(this, props); - Reflect.set(this, 'updatedAt', new Date()); + if (associations.length) { + hadDirtyAssoc = true; + statements = associations.reduce((arr, key) => [ + ...arr, + ...updateRelationship(this, key, trx) + ], []); + } - const query = table() - .where({ [primaryKey]: Reflect.get(this, primaryKey) }) - .update(getColumns(this, Array.from(this.dirtyAttributes))) - .on('query', () => { - setImmediate(() => logger.debug(sql`${query.toString()}`)); - }); + if (this.isDirty) { + hadDirtyAttrs = true; - if (deep) { - await Promise.all([ - query, - saveRelationships(this) - ]); + await runHooks(this, trx, hooks.beforeValidation); + + validate(this); + + await runHooks(this, trx, + hooks.afterValidation, + hooks.beforeUpdate, + hooks.beforeSave + ); + + promise = update(this, trx); + } + + await createRunner(logger, statements)(await promise); this.prevAssociations.clear(); - } else { - await query; - } + this.currentChangeSet.persist(this.changeSets); - NEW_RECORDS.delete(this); - this.dirtyAttributes.clear(); + if (hadDirtyAttrs) { + await runHooks(this, trx, + hooks.afterUpdate, + hooks.afterSave + ); + } - if (typeof afterUpdate === 'function') { - await afterUpdate(this); - } + return createTransactionResultProxy(this, hadDirtyAttrs || hadDirtyAssoc); + }; - if (typeof afterSave === 'function') { - await afterSave(this); + if (transaction) { + return run(transaction); } - return this; + return this.transaction(run); } - async update(attributes: Object = {}): Promise { - Object.assign(this, attributes); + destroy( + transaction?: Knex$Transaction + ): Promise> { + const run = async (trx: Knex$Transaction) => { + const { constructor: { hooks, logger } } = this; - if (this.isDirty) { - return await this.save(true); - } + await runHooks(this, trx, hooks.beforeDestroy); + await createRunner(logger, [])(await destroy(this, trx)); + await runHooks(this, trx, hooks.afterDestroy); - return this; - } + return createTransactionResultProxy(this, true); + }; - async destroy(): Promise { - const { - constructor: { - primaryKey, - logger, - table, + if (transaction) { + return run(transaction); + } - hooks: { - afterDestroy, - beforeDestroy - } - } - } = this; + return this.transaction(run); + } - if (typeof beforeDestroy === 'function') { - await beforeDestroy(this); + reload(): Promise { + if (this.isNew) { + return Promise.resolve(this); } - const query = table() - .where({ [primaryKey]: this.getPrimaryKey() }) - .del() - .on('query', () => { - setImmediate(() => logger.debug(sql`${query.toString()}`)); - }); + return this.constructor.find(this.getPrimaryKey()); + } - await query; + rollback(): this { + const { persistedChangeSet } = this; - if (typeof afterDestroy === 'function') { - await afterDestroy(this); + if (persistedChangeSet && !this.currentChangeSet.isPersisted) { + persistedChangeSet + .applyTo(this) + .persist(this.changeSets); } return this; } getAttributes(...keys: Array): Object { - return setType(() => pick(this, ...keys)); + return pick(this, ...keys); } /** * @private */ - getPrimaryKey() { + getPrimaryKey(): number { return Reflect.get(this, this.constructor.primaryKey); } + /** + * @private + */ static initialize(store, table): Promise> { if (this.initialized) { return Promise.resolve(this); } if (!this.tableName) { - const tableName = chain(this.name) - .pipe(underscore) - .pipe(pluralize) - .value(); + const getTableName = compose(pluralize, underscore); + const tableName = getTableName(this.name); Reflect.defineProperty(this, 'tableName', { value: tableName, @@ -617,81 +689,93 @@ class Model { }); } - static async create(props = {}): Promise { - const { - primaryKey, - logger, - table, - - hooks: { - afterCreate, - afterSave, - afterValidation, - beforeCreate, - beforeSave, - beforeValidation + static create( + props: Object = {}, + transaction?: Knex$Transaction + ): Promise> { + const run = async (trx: Knex$Transaction) => { + const { hooks, logger, primaryKey } = this; + const instance = Reflect.construct(this, [props, false]); + let statements = []; + + const associations = Object + .keys(props) + .filter(key => ( + Boolean(this.relationshipFor(key)) + )); + + if (associations.length) { + statements = associations.reduce((arr, key) => [ + ...arr, + ...updateRelationship(instance, key, trx) + ], []); } - } = this; - const datetime = new Date(); - const instance = Reflect.construct(this, [{ - ...props, - createdAt: datetime, - updatedAt: datetime - }, false]); + await runHooks(instance, trx, hooks.beforeValidation); - if (typeof beforeValidation === 'function') { - await beforeValidation(instance); - } + validate(instance); - validate(instance); + await runHooks(instance, trx, + hooks.afterValidation, + hooks.beforeCreate, + hooks.beforeSave + ); - if (typeof afterValidation === 'function') { - await afterValidation(instance); - } + const runner = createRunner(logger, statements); + const [[primaryKeyValue]] = await runner(await create(instance, trx)); - if (typeof beforeCreate === 'function') { - await beforeCreate(instance); - } + Reflect.set(instance, primaryKey, primaryKeyValue); + Reflect.set(instance.rawColumnData, primaryKey, primaryKeyValue); - if (typeof beforeSave === 'function') { - await beforeSave(instance); - } - - const query = table() - .returning(primaryKey) - .insert(omit(getColumns(instance), primaryKey)) - .on('query', () => { - setImmediate(() => logger.debug(sql`${query.toString()}`)); + Reflect.defineProperty(instance, 'initialized', { + value: true, + writable: false, + enumerable: false, + configurable: false }); - const [primaryKeyValue] = await query; + instance.currentChangeSet.persist(instance.changeSets); - Object.assign(instance, { - [primaryKey]: primaryKeyValue - }); + await runHooks(instance, trx, + hooks.afterCreate, + hooks.afterSave + ); - Reflect.set(instance.rawColumnData, primaryKey, primaryKeyValue); - - Reflect.defineProperty(instance, 'initialized', { - value: true, - writable: false, - enumerable: false, - configurable: false - }); - - Object.freeze(instance); - NEW_RECORDS.delete(instance); - - if (typeof afterCreate === 'function') { - await afterCreate(instance); - } + return createTransactionResultProxy(instance, true); + }; - if (typeof afterSave === 'function') { - await afterSave(instance); + if (transaction) { + return run(transaction); } - return instance; + return this.transaction(run); + } + + static transacting(trx: Knex$Transaction): Class { + return createStaticTransactionProxy(this, trx); + } + + static transaction(fn: (...args: Array) => Promise): Promise { + return new Promise((resolve, reject) => { + const { store: { connection } } = this; + let result: T; + + connection + .transaction(trx => { + fn(trx) + .then(data => { + result = data; + return trx.commit(); + }) + .catch(trx.rollback); + }) + .then(() => { + resolve(result); + }) + .catch(err => { + reject(err); + }); + }); } static all(): Query> { @@ -761,23 +845,33 @@ class Model { /** * Check if a value is an instance of a Model. */ - static isInstance(obj: mixed): boolean { + static isInstance(obj: any): boolean { return obj instanceof this; } + /** + * @private + */ static columnFor(key: string): void | Object { return Reflect.get(this.attributes, key); } + /** + * @private + */ static columnNameFor(key: string): void | string { const column = this.columnFor(key); return column ? column.columnName : undefined; } + /** + * @private + */ static relationshipFor(key: string): void | Relationship$opts { return Reflect.get(this.relationships, key); } } export default Model; +export type { Model$Hook, Model$Hooks } from './interfaces'; diff --git a/src/packages/database/model/initialize-class.js b/src/packages/database/model/initialize-class.js index 5a93c46a..c83285cf 100644 --- a/src/packages/database/model/initialize-class.js +++ b/src/packages/database/model/initialize-class.js @@ -2,6 +2,7 @@ import { camelize, dasherize, pluralize, singularize } from 'inflection'; import { line } from '../../logger'; +import { createAttribute } from '../attribute'; import { get as getRelationship, set as setRelationship @@ -10,9 +11,7 @@ import entries from '../../../utils/entries'; import underscore from '../../../utils/underscore'; import type Database, { Model } from '../index'; // eslint-disable-line no-unused-vars, max-len -import { createAttribute } from './attribute'; - -const VALID_HOOKS = [ +const VALID_HOOKS = new Set([ 'afterCreate', 'afterDestroy', 'afterSave', @@ -23,7 +22,7 @@ const VALID_HOOKS = [ 'beforeSave', 'beforeUpdate', 'beforeValidation' -]; +]); /** * @private @@ -55,29 +54,28 @@ function initializeProps(prototype, attributes, relationships) { /** * @private */ -function initializeHooks(opts) { - const { model, logger } = opts; - let { hooks } = opts; - - hooks = entries(hooks) - .filter(([key]) => { - const isValid = VALID_HOOKS.indexOf(key) >= 0; - - if (!isValid) { +function initializeHooks({ model, hooks, logger }) { + return Object.freeze( + entries(hooks).reduce((obj, [key, value]) => { + if (!VALID_HOOKS.has(key)) { logger.warn(line` Invalid hook '${key}' will not be added to Model '${model.name}'. - Valid hooks are ${VALID_HOOKS.map(h => `'${h}'`).join(', ')}. + Valid hooks are ${ + Array.from(VALID_HOOKS).map(h => `'${h}'`).join(', ') + }. `); - } - return isValid; - }) - .reduce((hash, [key, hook]) => ({ - ...hash, - [key]: async (...args) => await Reflect.apply(hook, model, args) - }), {}); + return obj; + } - return Object.freeze(hooks); + return { + ...obj, + [key]: async (instance, transaction) => { + await Reflect.apply(value, model, [instance, transaction]); + } + }; + }, {}) + ); } /** @@ -94,8 +92,8 @@ function initializeValidations(opts) { if (!isValid) { logger.warn(line` - Invalid validation '${key}' will not be added to Model - '${model.name}'. '${key}' is not an attribute of Model + Invalid validation '${key}' will not be added to Model + '${model.name}'. '${key}' is not an attribute of Model '${model.name}'. `); } @@ -104,7 +102,7 @@ function initializeValidations(opts) { isValid = false; logger.warn(line` - Invalid validation '${key}' will not be added to Model + Invalid validation '${key}' will not be added to Model '${model.name}'. Validations must be a function. `); } @@ -128,10 +126,11 @@ export default async function initializeClass>({ model }: { store: Database, - table: Function, + table: $PropertyType, model: T }): Promise { - const { hooks, scopes, validates } = model; + let { hooks } = model; + const { scopes, validates } = model; const { logger } = store; const modelName = dasherize(underscore(model.name)); const resourceName = pluralize(modelName); @@ -299,6 +298,10 @@ export default async function initializeClass>({ ...belongsTo }); + if (!hooks) { + hooks = {}; + } + Object.defineProperties(model, { store: { value: store, @@ -447,12 +450,17 @@ export default async function initializeClass>({ enumerable: true, configurable: false }, - resourceName: { value: resourceName, writable: false, enumerable: true, configurable: false + }, + isModelInstance: { + value: true, + writable: false, + enumerable: false, + configurable: false } }); diff --git a/src/packages/database/model/interfaces.js b/src/packages/database/model/interfaces.js new file mode 100644 index 00000000..a5de2c1b --- /dev/null +++ b/src/packages/database/model/interfaces.js @@ -0,0 +1,20 @@ +// @flow +import type Model from './index'; + +export type Model$Hook = ( + instance: Model, + trx: Knex$Transaction +) => Promise; + +export interface Model$Hooks { + +afterCreate?: Model$Hook; + +afterDestroy?: Model$Hook; + +afterSave?: Model$Hook; + +afterUpdate?: Model$Hook; + +afterValidation?: Model$Hook; + +beforeCreate?: Model$Hook; + +beforeDestroy?: Model$Hook; + +beforeSave?: Model$Hook; + +beforeUpdate?: Model$Hook; + +beforeValidation?: Model$Hook; +} diff --git a/src/packages/database/model/utils/persistence.js b/src/packages/database/model/utils/persistence.js new file mode 100644 index 00000000..2239cd62 --- /dev/null +++ b/src/packages/database/model/utils/persistence.js @@ -0,0 +1,95 @@ +// @flow +import { sql } from '../../../logger'; +import omit from '../../../../utils/omit'; +// eslint-disable-next-line no-duplicate-imports +import type Logger from '../../../logger'; +import type Model from '../index'; + +import getColumns from './get-columns'; + +/** + * @private + */ +export function create( + record: Model, + trx: Knex$Transaction +): [Knex$QueryBuilder] { + const timestamp = new Date(); + + Object.assign(record, { + createdAt: timestamp, + updatedAt: timestamp + }); + + Object.assign(record.rawColumnData, { + createdAt: timestamp, + updatedAt: timestamp + }); + + return [ + record.constructor + .table() + .transacting(trx) + .returning(record.constructor.primaryKey) + .insert(omit(getColumns(record), record.constructor.primaryKey)) + ]; +} + +/** + * @private + */ +export function update( + record: Model, + trx: Knex$Transaction +): [Knex$QueryBuilder] { + Reflect.set(record, 'updatedAt', new Date()); + + return [ + record.constructor + .table() + .transacting(trx) + .where(record.constructor.primaryKey, record.getPrimaryKey()) + .update(getColumns( + record, + Array.from(record.dirtyAttributes.keys()) + )) + ]; +} + +/** + * @private + */ +export function destroy( + record: Model, + trx: Knex$Transaction +): [Knex$QueryBuilder] { + return [ + record.constructor + .table() + .transacting(trx) + .where(record.constructor.primaryKey, record.getPrimaryKey()) + .del() + ]; +} + +/** + * @private + */ +export function createRunner( + logger: Logger, + statements: Array +): (query: Array) => Promise> { + return query => { + const promises = query.concat(statements); + + promises.forEach(promise => { + promise.on('query', () => { + setImmediate(() => { + logger.debug(sql`${promise.toString()}`); + }); + }); + }); + + return Promise.all(promises); + }; +} diff --git a/src/packages/database/model/utils/run-hooks.js b/src/packages/database/model/utils/run-hooks.js new file mode 100644 index 00000000..fc5ca4ab --- /dev/null +++ b/src/packages/database/model/utils/run-hooks.js @@ -0,0 +1,17 @@ +// @flow +import type Model, { Model$Hook } from '../index'; + +/** + * @private + */ +export default function runHooks( + record: Model, + trx: Knex$Transaction, + ...hooks: Array +): Promise { + return hooks + .filter(Boolean) + .reduce((prev, next) => ( + prev.then(() => next(record, trx)) + ), Promise.resolve()); +} diff --git a/src/packages/database/model/utils/validate.js b/src/packages/database/model/utils/validate.js index 2bc91c87..38a4ae01 100644 --- a/src/packages/database/model/utils/validate.js +++ b/src/packages/database/model/utils/validate.js @@ -1,32 +1,25 @@ // @flow import Validation, { ValidationError } from '../../validation'; -import pick from '../../../../utils/pick'; -import entries from '../../../../utils/entries'; import type { Model } from '../../index'; /** * @private */ -export default function validate(instance: Model) { - const { initialized, constructor: model } = instance; - let { validates } = model; - - if (initialized) { - validates = pick(validates, ...Array.from(instance.dirtyAttributes)); - } - - for (const [key, validator] of entries(validates)) { - const value = Reflect.get(instance, key); - const validation = new Validation({ +export default function validate(instance: Model): true { + return Array + .from(instance.dirtyAttributes) + .map(([key, value]) => ({ key, value, - validator - }); - - if (!validation.isValid()) { - throw new ValidationError(key, value); - } - } + validator: Reflect.get(instance.constructor.validates, key) + })) + .filter(({ validator }) => validator) + .map(props => new Validation(props)) + .reduce((result, validation) => { + if (!validation.isValid()) { + throw new ValidationError(validation.key, String(validation.value)); + } - return true; + return result; + }, true); } diff --git a/src/packages/database/query/runner/utils/build-results.js b/src/packages/database/query/runner/utils/build-results.js index f87998f3..6db66fd1 100644 --- a/src/packages/database/query/runner/utils/build-results.js +++ b/src/packages/database/query/runner/utils/build-results.js @@ -1,7 +1,6 @@ // @flow import { camelize, singularize } from 'inflection'; -import { NEW_RECORDS } from '../../../constants'; import Model from '../../../model'; import entries from '../../../../../utils/entries'; import underscore from '../../../../../utils/underscore'; @@ -133,7 +132,8 @@ export default async function buildResults({ }, {}) ]); - NEW_RECORDS.delete(instance); + instance.currentChangeSet.persist(); + return instance; }); } diff --git a/src/packages/database/relationship/index.js b/src/packages/database/relationship/index.js index cda02684..7e8d1c0e 100644 --- a/src/packages/database/relationship/index.js +++ b/src/packages/database/relationship/index.js @@ -3,7 +3,6 @@ import { camelize } from 'inflection'; import type { Model } from '../index'; -import relatedFor from './utils/related-for'; import { getHasOne, getHasMany, getBelongsTo } from './utils/getters'; import { setHasOne, setHasMany, setBelongsTo } from './utils/setters'; @@ -51,11 +50,10 @@ export async function get( let value = null; if (opts) { - const related = relatedFor(owner); const { type } = opts; let { foreignKey } = opts; - value = related.get(key); + value = owner.currentChangeSet.get(key); foreignKey = camelize(foreignKey, true); if (!value) { @@ -92,6 +90,5 @@ export async function get( return value; } -export { default as saveRelationships } from './utils/save-relationships'; - +export { default as updateRelationship } from './utils/update-relationship'; export type { Relationship$opts } from './interfaces'; diff --git a/src/packages/database/relationship/utils/inverse-setters.js b/src/packages/database/relationship/utils/inverse-setters.js index e8258b8e..0bd3f953 100644 --- a/src/packages/database/relationship/utils/inverse-setters.js +++ b/src/packages/database/relationship/utils/inverse-setters.js @@ -1,10 +1,7 @@ // @flow -import setType from '../../../../utils/set-type'; import type { Model } from '../../index'; import type { Relationship$opts } from '../index'; -import relatedFor from './related-for'; - /** * @private */ @@ -19,10 +16,14 @@ export function setHasManyInverse(owner: Model, value: Array, { const { type: inverseType } = inverseModel.relationshipFor(inverse); for (const record of value) { - const related = relatedFor(record); + let { currentChangeSet: changeSet } = record; + + if (owner !== changeSet.get(inverse)) { + if (changeSet.isPersisted) { + changeSet = changeSet.applyTo(record); + } - if (owner !== related.get(inverse)) { - relatedFor(record).set(inverse, owner); + changeSet.set(inverse, owner); if (inverseType === 'belongsTo') { Reflect.set(record, foreignKey, primaryKey); @@ -43,31 +44,30 @@ export function setHasOneInverse(owner: Model, value?: ?Model, { }) { if (value) { const { type: inverseType } = inverseModel.relationshipFor(inverse); - const related = relatedFor(value); - let inverseValue = related.get(inverse); + let inverseValue = value.currentChangeSet.get(inverse); if (inverseType === 'hasMany') { if (!Array.isArray(inverseValue)) { - inverseValue = setType(() => []); + inverseValue = []; } if (!inverseValue.includes(owner)) { inverseValue.push(owner); } - } else if (inverseType !== 'hasMany' && owner !== inverseValue) { - const primaryKey = Reflect.get(owner, owner.constructor.primaryKey); - + } else if (owner !== inverseValue) { inverseValue = owner; if (inverseType === 'belongsTo') { - Reflect.set(value, foreignKey, primaryKey); + Reflect.set(value, foreignKey, inverseValue.getPrimaryKey()); } } - if (inverseValue) { - related.set(inverse, inverseValue); - } else { - related.delete(inverse); + let { currentChangeSet: changeSet } = value; + + if (changeSet.isPersisted) { + changeSet = changeSet.applyTo(value); } + + changeSet.set(inverse, inverseValue || null); } } diff --git a/src/packages/database/relationship/utils/related-for.js b/src/packages/database/relationship/utils/related-for.js deleted file mode 100644 index 3e4726b7..00000000 --- a/src/packages/database/relationship/utils/related-for.js +++ /dev/null @@ -1,19 +0,0 @@ -// @flow -import type { Model } from '../../index'; -import type { Relationship$refs } from '../interfaces'; - -const REFS: Relationship$refs = new WeakMap(); - -/** - * @private - */ -export default function relatedFor(owner: Model) { - let related = REFS.get(owner); - - if (!related) { - related = new Map(); - REFS.set(owner, related); - } - - return related; -} diff --git a/src/packages/database/relationship/utils/save-relationships.js b/src/packages/database/relationship/utils/save-relationships.js deleted file mode 100644 index c9f06c61..00000000 --- a/src/packages/database/relationship/utils/save-relationships.js +++ /dev/null @@ -1,19 +0,0 @@ -// @flow -import type { Model } from '../../index'; - -import relatedFor from './related-for'; - -/** - * @private - */ -export default function saveRelationships(record: Model) { - return Promise.all( - Array.from(relatedFor(record).values()) - .reduce((relationships, related) => [ - ...relationships, - ...(Array.isArray(related) ? related : [related]) - ], Array.from(record.prevAssociations)) - .filter(item => item.isDirty) - .map(item => item.save()) - ); -} diff --git a/src/packages/database/relationship/utils/setters.js b/src/packages/database/relationship/utils/setters.js index 5c749d60..1c415940 100644 --- a/src/packages/database/relationship/utils/setters.js +++ b/src/packages/database/relationship/utils/setters.js @@ -2,7 +2,6 @@ import type { Model } from '../../index'; import type { Relationship$opts } from '../index'; -import relatedFor from './related-for'; import unassociate from './unassociate'; import validateType from './validate-type'; import { setHasOneInverse, setHasManyInverse } from './inverse-setters'; @@ -16,10 +15,10 @@ export function setHasMany(owner: Model, key: string, value: Array, { inverse, foreignKey }: Relationship$opts) { - const related = relatedFor(owner); + let { currentChangeSet: changeSet } = owner; if (validateType(model, value)) { - let prevValue = related.get(key); + let prevValue = changeSet.get(key); if (Array.isArray(prevValue)) { prevValue = unassociate(prevValue, foreignKey); @@ -33,7 +32,11 @@ export function setHasMany(owner: Model, key: string, value: Array, { } } - related.set(key, value); + if (changeSet.isPersisted) { + changeSet = changeSet.applyTo(owner); + } + + changeSet.set(key, value); setHasManyInverse(owner, value, { type, @@ -54,20 +57,28 @@ export function setHasOne(owner: Model, key: string, value?: ?Model, { inverse, foreignKey }: Relationship$opts) { - const related = relatedFor(owner); let valueToSet = value; if (value && typeof value === 'object' && !model.isInstance(value)) { valueToSet = Reflect.construct(model, [valueToSet]); } + let { currentChangeSet: changeSet } = owner; + if (valueToSet) { if (validateType(model, valueToSet)) { - related.set(key, valueToSet); + if (changeSet.isPersisted) { + changeSet = changeSet.applyTo(owner); + } + + changeSet.set(key, valueToSet); } } else { - valueToSet = null; - related.delete(key); + if (changeSet.isPersisted) { + changeSet = changeSet.applyTo(owner); + } + + changeSet.set(key, null); } setHasOneInverse(owner, valueToSet, { diff --git a/src/packages/database/relationship/utils/update-relationship.js b/src/packages/database/relationship/utils/update-relationship.js new file mode 100644 index 00000000..69a4ee35 --- /dev/null +++ b/src/packages/database/relationship/utils/update-relationship.js @@ -0,0 +1,189 @@ +// @flow +import type { Model } from '../../index'; +import type { Relationship$opts } from '../interfaces'; + +type Params = { + record: Model; + value: ?Model | Array; + opts: Relationship$opts; + trx: Knex$Transaction; +}; + +function updateHasOne({ + record, + value, + opts, + trx +}: Params): Array { + const recordPrimaryKey = record.getPrimaryKey(); + + if (value) { + if (value instanceof opts.model) { + return [ + opts.model + .table() + .transacting(trx) + .update(opts.foreignKey, null) + .where( + `${opts.model.tableName}.${opts.foreignKey}`, + recordPrimaryKey + ) + .whereNot( + `${opts.model.tableName}.${opts.model.primaryKey}`, + value.getPrimaryKey() + ), + opts.model + .table() + .transacting(trx) + .update(opts.foreignKey, recordPrimaryKey) + .where( + `${opts.model.tableName}.${opts.model.primaryKey}`, + value.getPrimaryKey() + ) + ]; + } + } else { + return [ + opts.model + .table() + .transacting(trx) + .update(opts.foreignKey, null) + .where( + `${opts.model.tableName}.${opts.foreignKey}`, + recordPrimaryKey + ) + ]; + } + + return []; +} + +function updateHasMany({ + record, + value, + opts, + trx +}: Params): Array { + const recordPrimaryKey = record.getPrimaryKey(); + + if (Array.isArray(value) && value.length) { + return [ + opts.model + .table() + .transacting(trx) + .update(opts.foreignKey, null) + .where( + `${opts.model.tableName}.${opts.foreignKey}`, + recordPrimaryKey + ) + .whereNotIn( + `${opts.model.tableName}.${opts.model.primaryKey}`, + value.map(item => item.getPrimaryKey()) + ), + opts.model + .table() + .transacting(trx) + .update(opts.foreignKey, recordPrimaryKey) + .whereIn( + `${opts.model.tableName}.${opts.model.primaryKey}`, + value.map(item => item.getPrimaryKey()) + ) + ]; + } + + return [ + opts.model + .table() + .transacting(trx) + .update(opts.foreignKey, null) + .where( + `${opts.model.tableName}.${opts.foreignKey}`, + recordPrimaryKey + ) + ]; +} + +function updateBelongsTo({ + record, + value, + opts, + trx +}: Params): Array { + if (value instanceof opts.model) { + const inverseOpts = opts.model.relationshipFor(opts.inverse); + const foreignKeyValue = value.getPrimaryKey(); + + Reflect.set(record, opts.foreignKey, foreignKeyValue); + + if (inverseOpts && inverseOpts.type === 'hasOne') { + return [ + record.constructor + .table() + .transacting(trx) + .update(opts.foreignKey, null) + .where(opts.foreignKey, foreignKeyValue) + .whereNot( + `${record.constructor.tableName}.${record.constructor.primaryKey}`, + record.getPrimaryKey() + ) + ]; + } + } + + return []; +} + +/** + * @private + */ +export default function updateRelationship( + record: Model, + name: string, + trx: Knex$Transaction +): Array { + const opts = record.constructor.relationshipFor(name); + + if (!opts) { + const { + constructor: { + name: className + } + } = record; + + throw new Error(`Could not find relationship '${name} on '${className}`); + } + + const { dirtyRelationships } = record; + + if (!dirtyRelationships.has(name)) { + return []; + } + + const value = dirtyRelationships.get(name); + + switch (opts.type) { + case 'hasOne': + return updateHasOne({ + record, + value, + opts, + trx + }); + + case 'hasMany': + return updateHasMany({ + record, + value, + opts, + trx + }); + + default: + return updateBelongsTo({ + record, + value, + opts, + trx + }); + } +} diff --git a/src/packages/database/test/database.test.js b/src/packages/database/test/database.test.js index e750bda3..6a3b8da8 100644 --- a/src/packages/database/test/database.test.js +++ b/src/packages/database/test/database.test.js @@ -9,6 +9,26 @@ const DATABASE_DRIVER: string = Reflect.get(process.env, 'DATABASE_DRIVER'); const DATABASE_USERNAME: string = Reflect.get(process.env, 'DATABASE_USERNAME'); const DATABASE_PASSWORD: string = Reflect.get(process.env, 'DATABASE_PASSWORD'); +const DEFAULT_CONFIG = { + development: { + pool: 5, + driver: 'sqlite3', + database: 'lux_test' + }, + test: { + pool: 5, + driver: DATABASE_DRIVER || 'sqlite3', + database: 'lux_test', + username: DATABASE_USERNAME, + password: DATABASE_PASSWORD + }, + production: { + pool: 5, + driver: 'sqlite3', + database: 'lux_test' + } +}; + describe('module "database"', () => { describe('class Database', () => { let createDatabase; @@ -16,22 +36,7 @@ describe('module "database"', () => { before(async () => { const { path, models, logger } = await getTestApp(); - createDatabase = (config = { - development: { - driver: 'sqlite3', - database: 'lux_test' - }, - test: { - driver: DATABASE_DRIVER || 'sqlite3', - database: 'lux_test', - username: DATABASE_USERNAME, - password: DATABASE_PASSWORD - }, - production: { - driver: 'sqlite3', - database: 'lux_test' - } - }) => new Database({ + createDatabase = async (config = DEFAULT_CONFIG) => await new Database({ path, models, logger, @@ -46,6 +51,28 @@ describe('module "database"', () => { expect(result).to.be.an.instanceof(Database); }); + + it('fails when an invalid database driver is used', async () => { + await createDatabase({ + development: { + ...DEFAULT_CONFIG.development, + driver: 'invalid-driver' + }, + test: { + ...DEFAULT_CONFIG.test, + driver: 'invalid-driver' + }, + production: { + ...DEFAULT_CONFIG.production, + driver: 'invalid-driver' + } + }).catch(err => { + expect(err).to.have.deep.property( + 'constructor.name', + 'InvalidDriverError' + ); + }); + }); }); describe('#modelFor()', () => { diff --git a/src/packages/database/test/migration.test.js b/src/packages/database/test/migration.test.js index 0ae3fe5b..28bba424 100644 --- a/src/packages/database/test/migration.test.js +++ b/src/packages/database/test/migration.test.js @@ -20,7 +20,7 @@ describe('module "database/migration"', () => { const tableName = 'migration_test'; let subject; - before(async () => { + before(() => { subject = new Migration(schema => { return schema.createTable(tableName, table => { table.increments(); @@ -41,28 +41,12 @@ describe('module "database/migration"', () => { await store.schema().dropTable(tableName); }); - it('runs a migration function', async () => { - await subject.run(store.schema()); - - const result = await store.connection(tableName).columnInfo(); - - expect(result).to.have.all.keys([ - 'id', - 'success', - 'created_at', - 'updated_at' - ]); - - Object.keys(result).forEach(key => { - const value = Reflect.get(result, key); - - expect(value).to.have.all.keys([ - 'type', - 'nullable', - 'maxLength', - 'defaultValue' - ]); - }); + it('runs a migration function', () => { + return subject + .run(store.schema()) + .then(result => { + expect(result).to.be.ok; + }); }); }); }); diff --git a/src/packages/database/test/model.test.js b/src/packages/database/test/model.test.js index f2f5b7a3..1a016787 100644 --- a/src/packages/database/test/model.test.js +++ b/src/packages/database/test/model.test.js @@ -14,12 +14,19 @@ describe('module "database/model"', () => { describe('class Model', () => { let store; let User: Class; + let Image: Class; + let Comment: Class; before(async () => { const app = await getTestApp(); store = app.store; - User = setType(() => app.models.get('user')); + // $FlowIgnore + User = app.models.get('user'); + // $FlowIgnore + Image = app.models.get('image'); + // $FlowIgnore + Comment = app.models.get('comment'); }); describe('.initialize()', () => { @@ -38,7 +45,8 @@ describe('module "database/model"', () => { }, reactions: { - inverse: 'post' + inverse: 'post', + model: 'reaction' }, tags: { @@ -48,9 +56,10 @@ describe('module "database/model"', () => { }; static hooks = { - afterCreate: async instance => console.log(instance), - beforeDestroy: async instance => console.log(instance), - duringDestroy: async () => console.log('This hook should be removed.') + async afterCreate() {}, + async beforeDestroy() {}, + async duringDestroy() {} + // ^^^^^^^^^^^^^ This hook should be removed. }; static scopes = { @@ -69,7 +78,10 @@ describe('module "database/model"', () => { static validates = { title: str => Boolean(str), + notAFunction: {}, + //^^^^^^^^^^^^ This validation should be removed. notAnAttribute: () => false + //^^^^^^^^^^^^^^ This validation should be removed. }; } @@ -79,20 +91,38 @@ describe('module "database/model"', () => { }); }); + it('can be called repeatedly without error', async () => { + const table = () => store.connection(Subject.tableName); + + const refs = await Promise.all([ + Subject.initialize(store, table), + Subject.initialize(store, table), + Subject.initialize(store, table) + ]); + + refs.forEach(ref => { + expect(ref).to.equal(Subject); + }); + }); + it('adds a `store` property to the `Model`', () => { - expect(Subject.store).to.equal(store); + expect(Subject).to.have.property('store', store); }); it('adds a `table` property to the `Model`', () => { - expect(Subject.table).to.be.a('function'); + expect(Subject) + .to.have.property('table') + .and.be.a('function'); }); it('adds a `logger` property to the `Model`', () => { - expect(Subject.logger).to.equal(store.logger); + expect(Subject).to.have.property('logger', store.logger); }); it('adds an `attributes` property to the `Model`', () => { - expect(Subject.attributes).to.have.all.keys([ + expect(Subject) + .to.have.property('attributes') + .and.have.all.keys([ 'id', 'body', 'title', @@ -117,15 +147,17 @@ describe('module "database/model"', () => { }); it('adds an `attributeNames` property to the `Model`', () => { - expect(Subject.attributeNames).to.include.all.members([ - 'id', - 'body', - 'title', - 'isPublic', - 'userId', - 'createdAt', - 'updatedAt' - ]); + expect(Subject) + .to.have.property('attributeNames') + .and.include.all.members([ + 'id', + 'body', + 'title', + 'isPublic', + 'userId', + 'createdAt', + 'updatedAt' + ]); }); it('adds attribute accessors on the `prototype`', () => { @@ -231,13 +263,18 @@ describe('module "database/model"', () => { }); it('removes invalid hooks from the `hooks` property', () => { - expect(Subject.hooks).to.have.all.keys([ - 'afterCreate', - 'beforeDestroy' - ]); + expect(Subject) + .to.have.property('hooks') + .and.be.an('object') + .and.not.have.any.keys(['duringDestroy']); + + expect(Subject) + .to.have.deep.property('hooks.afterCreate') + .and.be.a('function'); - expect(Subject.hooks.afterCreate).to.be.a('function'); - expect(Subject.hooks.beforeDestroy).to.be.a('function'); + expect(Subject) + .to.have.deep.property('hooks.beforeDestroy') + .and.be.a('function'); }); it('adds each scope to `Model`', () => { @@ -303,6 +340,12 @@ describe('module "database/model"', () => { class Subject extends Model { static tableName = 'posts'; + + static belongsTo = { + user: { + inverse: 'posts' + } + }; } before(async () => { @@ -316,10 +359,12 @@ describe('module "database/model"', () => { }); it('constructs and persists a `Model` instance', async () => { + const user = new User({ id: 1 }); const body = 'Contents of "Test Post"...'; const title = 'Test Post'; result = await Subject.create({ + user, body, title, isPublic: true @@ -333,6 +378,31 @@ describe('module "database/model"', () => { expect(result).to.have.property('isPublic', true); expect(result).to.have.property('createdAt').and.be.an.instanceof(Date); expect(result).to.have.property('updatedAt').and.be.an.instanceof(Date); + + // $FlowIgnore + expect(await result.user).to.have.property('id', user.getPrimaryKey()); + }); + }); + + describe('.transacting()', async () => { + class Subject extends Model { + static tableName = 'posts'; + } + + before(async () => { + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + }); + + it('returns a static transaction proxy', async () => { + await Subject.transaction(trx => { + const proxy = Subject.transacting(trx); + + expect(proxy.create).to.be.a('function'); + + return Promise.resolve(new Subject()); + }); }); }); @@ -1038,7 +1108,9 @@ describe('module "database/model"', () => { static tableName = 'posts'; static hooks = { - async beforeDestroy() {} + async beforeDestroy(record) { + + } }; } @@ -1121,7 +1193,9 @@ describe('module "database/model"', () => { static tableName = 'posts'; static hooks = { - async beforeUpdate() {} + beforeUpdate(record) { + return Promise.resolve(record); + } }; } @@ -1202,6 +1276,65 @@ describe('module "database/model"', () => { }); }); + describe('.attributes', () => { + class Subject extends Model { + body: void | ?string; + title: string; + + static tableName = 'posts'; + } + + before(async () => { + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + }); + + describe('#set()', () => { + const ogTitle = 'Test Attribute#set()'; + let instance; + + beforeEach(() => { + instance = new Subject({ + title: ogTitle + }); + }); + + it('updates the current value', () => { + const newVaue = 'It worked!'; + + instance.body = newVaue; + expect(instance).to.have.property('body', newVaue); + }); + + describe('- nullable', () => { + it('sets the current value to null when passed null', () => { + instance.body = null; + expect(instance).to.have.property('body', null); + }); + + it('sets the current value to null when passed undefined', () => { + instance.body = undefined; + expect(instance).to.have.property('body', null); + }); + }); + + describe('- not nullable', () => { + it('does not update the current value when passed null', () => { + // $FlowIgnore + instance.title = null; + expect(instance).to.have.property('title', ogTitle); + }); + + it('does not update the current value when passed undefined', () => { + // $FlowIgnore + instance.title = undefined; + expect(instance).to.have.property('title', ogTitle); + }); + }); + }); + }); + describe('#save()', () => { const instances = new Set(); let instance: Subject; @@ -1245,15 +1378,22 @@ describe('module "database/model"', () => { }); }); - afterEach(async () => { - await Promise.all([ - instance.destroy(), - ...Array.from(instances).map(record => { - return record.destroy().then(() => { - instances.delete(record); - }); - }) - ]); + afterEach(() => { + return Subject.transaction(trx => ( + Promise.all([ + instance + .transacting(trx) + .destroy(), + ...Array + .from(instances) + .map(record => ( + record + .transacting(trx) + .destroy() + .then(() => instances.delete(record)) + )) + ]) + )); }); it('can persist dirty attributes', async () => { @@ -1277,7 +1417,7 @@ describe('module "database/model"', () => { instances.add(userInstance); instance.user = userInstance; - await instance.save(true); + await instance.save(); const { rawColumnData: { @@ -1318,6 +1458,24 @@ describe('module "database/model"', () => { static tableName = 'posts'; + static hasOne = { + image: { + inverse: 'post' + } + }; + + static hasMany = { + comments: { + inverse: 'post' + } + }; + + static belongsTo = { + user: { + inverse: 'posts' + } + }; + static validates = { title: str => str.split(' ').length > 1 }; @@ -1340,21 +1498,69 @@ describe('module "database/model"', () => { await instance.destroy(); }); - it('can set and persist attributes', async () => { + it('can set and persist attributes and relationships', async () => { const body = 'Lots of content...'; + const user = new User({ + id: 1 + }); + + const image = new Image({ + id: 1 + }); + + const comments = [ + new Comment({ + id: 1 + }), + new Comment({ + id: 2 + }), + new Comment({ + id: 3 + }) + ]; + await instance.update({ body, + user, + image, + comments, isPublic: true }); expect(instance).to.have.property('body', body); expect(instance).to.have.property('isPublic', true); - const result = await Subject.find(instance.id); + // $FlowIgnore + expect(await instance.user) + .to.have.property('id', user.getPrimaryKey()); + + // $FlowIgnore + expect(await instance.image) + .to.have.property('id', image.getPrimaryKey()); + + // $FlowIgnore + expect(await instance.comments) + .to.be.an('array') + .with.lengthOf(3); + + const result = await Subject + .find(instance.id) + .include('user', 'image', 'comments'); expect(result).to.have.property('body', body); expect(result).to.have.property('isPublic', true); + + expect(await result.user) + .to.have.property('id', user.getPrimaryKey()); + + expect(await result.image) + .to.have.property('id', image.getPrimaryKey()); + + expect(await result.comments) + .to.be.an('array') + .with.lengthOf(3); }); it('fails if a validation is not met', async () => { @@ -1371,7 +1577,9 @@ describe('module "database/model"', () => { expect(instance).to.have.property('isPublic', true); const result = await Subject.find(instance.id); + expect(result).to.have.property('title', 'Test Post'); + expect(result).to.have.property('isPublic', false); }); }); @@ -1406,6 +1614,97 @@ describe('module "database/model"', () => { }); }); + describe('#reload', () => { + let instance: Subject; + + class Subject extends Model { + title: string; + + static tableName = 'posts'; + } + + before(async () => { + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + + instance = await Subject.create({ + body: 'Lots of content...', + title: 'Test Post', + isPublic: true + }); + }); + + after(async () => { + await instance.destroy(); + }); + + it('reverts attributes to the last known persisted changes', async () => { + const { title: prevTitle } = instance; + const nextTitle = 'Testing #reload()'; + + instance.title = nextTitle; + + const ref = await instance.reload(); + + expect(ref).to.not.equal(instance); + expect(ref).to.have.property('title', prevTitle); + expect(instance).to.have.property('title', nextTitle); + }); + + it('resolves with itself if the instance is new', async () => { + const newInstance = new Subject(); + const nextTitle = 'Testing #reload()'; + + newInstance.title = nextTitle; + + const ref = await newInstance.reload(); + + expect(ref).to.equal(newInstance); + expect(ref).to.have.property('title', nextTitle); + }); + }); + + describe('#rollback', () => { + let instance: Subject; + + class Subject extends Model { + title: string; + + static tableName = 'posts'; + } + + before(async () => { + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + + instance = await Subject.create({ + body: 'Lots of content...', + title: 'Test Post', + isPublic: true + }); + }); + + after(async () => { + await instance.destroy(); + }); + + it('reverts attributes to the last known persisted changes', () => { + const { title: prevTitle } = instance; + const nextTitle = 'Testing #rollback()'; + + instance.title = nextTitle; + + expect(instance).to.have.property('title', nextTitle); + + const ref = instance.rollback(); + + expect(ref).to.equal(instance); + expect(instance).to.have.property('title', prevTitle); + }); + }); + describe('#getAttributes()', () => { let instance: Subject; @@ -1466,5 +1765,39 @@ describe('module "database/model"', () => { expect(result).to.be.a('number'); }); }); + + describe('get #persisted()', () => { + let instance; + + class Subject extends Model { + static tableName = 'posts'; + } + + before(async () => { + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + }); + + it('returns true for a record returned from querying', async () => { + const record = await Subject.first(); + + expect(record).to.have.property('persisted', true); + }); + + it('returns false if a record has been modified', async () => { + const record = await Subject.first(); + + record.title = 'Modified Title'; + + expect(record).to.have.property('persisted', false); + }); + + it('returns false if the record is new', () => { + const record = new Subject(); + + expect(record).to.have.property('persisted', false); + }); + }); }); }); diff --git a/src/packages/database/test/relationship.test.js b/src/packages/database/test/relationship.test.js index 49ca3047..d7c08031 100644 --- a/src/packages/database/test/relationship.test.js +++ b/src/packages/database/test/relationship.test.js @@ -5,7 +5,6 @@ import { it, describe, before, beforeEach, afterEach } from 'mocha'; import { get, set } from '../relationship'; import range from '../../../utils/range'; -import setType from '../../../utils/set-type'; import { getTestApp } from '../../../../test/utils/get-test-app'; import type { Model } from '../index'; @@ -21,12 +20,18 @@ describe('module "database/relationship"', () => { before(async () => { const { models } = await getTestApp(); - Tag = setType(() => models.get('tag')); - Post = setType(() => models.get('post')); - User = setType(() => models.get('user')); - Image = setType(() => models.get('image')); - Comment = setType(() => models.get('comment')); - Categorization = setType(() => models.get('categorization')); + // $FlowIgnore + Tag = models.get('tag'); + // $FlowIgnore + Post = models.get('post'); + // $FlowIgnore + User = models.get('user'); + // $FlowIgnore + Image = models.get('image'); + // $FlowIgnore + Comment = models.get('comment'); + // $FlowIgnore + Categorization = models.get('categorization'); }); describe('#get()', () => { @@ -40,63 +45,66 @@ describe('module "database/relationship"', () => { userId: 1 }); + subject = subject.unwrap(); subjectId = subject.getPrimaryKey(); - const [image, tags, comments] = await Promise.all([ - Image.create({ - url: 'http://postlight.com', - postId: subjectId - }), - Promise.all( - Array.from(range(1, 5)).map(num => { - return Tag.create({ - name: `New Tag ${num}` - }); - }) - ), - Promise.all( - Array.from(range(1, 5)).map(num => { - return Comment.create({ - message: `New Comment ${num}`, - userId: 2, - postId: subjectId - }); - }) - ) - ]); - - const categorizations = await Promise.all( - tags.map(tag => { - return Categorization.create({ - tagId: tag.getPrimaryKey(), + await Post.transaction(async trx => { + const [image, tags, comments] = await Promise.all([ + Image.transacting(trx).create({ + url: 'http://postlight.com', postId: subjectId - }); - }) - ); + }), + Promise.all( + Array.from(range(1, 5)).map(num => ( + Tag.transacting(trx).create({ + name: `New Tag ${num}` + }) + )) + ), + Promise.all( + Array.from(range(1, 5)).map(num => ( + Comment.transacting(trx).create({ + message: `New Comment ${num}`, + userId: 2, + postId: subjectId + }) + )) + ) + ]); - instances.add(image); + const categorizations = await Promise.all( + tags.map(tag => ( + Categorization.transacting(trx).create({ + tagId: tag.getPrimaryKey(), + postId: subjectId + }) + )) + ); - tags.forEach(tag => { - instances.add(tag); - }); + instances.add(image); - comments.forEach(comment => { - instances.add(comment); - }); + tags.forEach(tag => { + instances.add(tag); + }); - categorizations.forEach(categorization => { - instances.add(categorization); + comments.forEach(comment => { + instances.add(comment); + }); + + categorizations.forEach(categorization => { + instances.add(categorization); + }); }); }; - const teardown = async () => { + const teardown = () => subject.transaction(async trx => { await Promise.all([ - subject.destroy(), - ...Array.from(instances).map(record => { - return record.destroy(); - }) + subject.transacting(trx).destroy(), + ...Array + .from(instances) + .map(record => record.transacting(trx).destroy()) ]); - }; + }); describe('has-one relationships', () => { beforeEach(setup); @@ -185,17 +193,18 @@ describe('module "database/relationship"', () => { title: '#set() test' }); + subject = subject.unwrap(); subjectId = subject.getPrimaryKey(); }; - const teardown = async () => { + const teardown = () => subject.transaction(async trx => { await Promise.all([ - subject.destroy(), - ...Array.from(instances).map(record => { - return record.destroy(); - }) + subject.transacting(trx).destroy(), + ...Array + .from(instances) + .map(record => record.transacting(trx).destroy()) ]); - }; + }); describe('has-one relationships', () => { let image; @@ -207,6 +216,9 @@ describe('module "database/relationship"', () => { url: 'http://postlight.com' }); + image = image.unwrap(); + + instances.add(image); set(subject, 'image', image); }); @@ -230,6 +242,8 @@ describe('module "database/relationship"', () => { password: 'test12345678' }); + user = user.unwrap(); + instances.add(user); set(subject, 'user', user); }); @@ -255,17 +269,18 @@ describe('module "database/relationship"', () => { beforeEach(async () => { await setup(); - comments = await Promise.all([ - Comment.create({ - message: 'Test Comment 1' - }), - Comment.create({ - message: 'Test Comment 2' - }), - Comment.create({ - message: 'Test Comment 3' - }) - ]); + comments = await Comment.transaction(trx => ( + Promise.all( + [1, 2, 3].map(num => ( + Comment + .transacting(trx) + .create({ + message: `Test Comment ${num}` + }) + .then(record => record.unwrap()) + )) + ) + )); comments.forEach(comment => { instances.add(comment); diff --git a/src/packages/database/test/transaction.test.js b/src/packages/database/test/transaction.test.js new file mode 100644 index 00000000..190957a0 --- /dev/null +++ b/src/packages/database/test/transaction.test.js @@ -0,0 +1,121 @@ +// @flow +import { spy } from 'sinon'; +import { expect } from 'chai'; +import { it, describe, before, after } from 'mocha'; + +import Model from '../model'; +import { + createTransactionResultProxy, + createStaticTransactionProxy, + createInstanceTransactionProxy +} from '../transaction'; + +import { getTestApp } from '../../../../test/utils/get-test-app'; + +describe('module "database/transaction"', () => { + class Subject extends Model { + static tableName = 'posts'; + } + + before(async () => { + const { store } = await getTestApp(); + + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + }); + + describe('.createTransactionResultProxy()', () => { + it('has a #didPersist property', () => { + // $FlowIgnore + const proxy = createTransactionResultProxy({}, true); + + expect(proxy.didPersist).to.be.true; + }); + }); + + describe('.createStaticTransactionProxy()', () => { + describe(`#create()`, () => { + let instance: Subject; + let createSpy; + + before(async () => { + createSpy = spy(Subject, 'create'); + }); + + after(async () => { + createSpy.restore(); + + if (instance) { + await instance.destroy(); + } + }); + + it('calls create on the model with the trx object', async () => { + let args = [{}]; + + await Subject.transaction(trx => { + args.push(trx); + return createStaticTransactionProxy(Subject, trx).create(args[0]); + }); + + expect(createSpy.calledWith(...args)).to.be.true; + }); + }); + }); + + describe('.createInstanceTransactionProxy()', () => { + ['save', 'update', 'destroy'].forEach(method => { + describe(`#${method}()`, () => { + let instance: Subject; + let methodSpy; + + before(async () => { + await Subject.create().then(proxy => { + instance = proxy.unwrap(); + methodSpy = spy(instance, method); + }); + }); + + after(async () => { + methodSpy.restore(); + + if (method !== 'destroy') { + await instance.destroy(); + } + }); + + it(`calls ${method} on the instance with the trx object`, async () => { + const obj = {}; + let args = []; + + await instance.transaction(trx => { + const proxied = createInstanceTransactionProxy(instance, trx); + let promise = Promise.resolve(instance); + + switch (method) { + case 'save': + promise = proxied.save(); + break; + + case 'update': + args.push(obj); + promise = proxied.update(obj); + break; + + case 'destroy': + promise = proxied.destroy(); + break; + } + + args.push(trx); + + return promise; + }); + + expect(methodSpy.calledWith(...args)).to.be.true; + }); + }); + }); + }); +}); diff --git a/src/packages/database/transaction/index.js b/src/packages/database/transaction/index.js new file mode 100644 index 00000000..e97c9226 --- /dev/null +++ b/src/packages/database/transaction/index.js @@ -0,0 +1,61 @@ +// @flow +import { trapGet } from '../../../utils/proxy'; +import type { Model } from '../index'; // eslint-disable-line no-unused-vars + +import type { Transaction$ResultProxy } from './interfaces'; + +/** + * @private + */ +export function createStaticTransactionProxy>( + target: T, + trx: Knex$Transaction +): T { + return new Proxy(target, { + get: trapGet({ + create(model: T, props: Object = {}) { + return model.create(props, trx); + } + }) + }); +} + +/** + * @private + */ +export function createInstanceTransactionProxy( + target: T, + trx: Knex$Transaction +): T { + return new Proxy(target, { + get: trapGet({ + save(model: T) { + return model.save(trx); + }, + + update(model: T, props: Object = {}) { + return model.update(props, trx); + }, + + destroy(model: T) { + return model.destroy(trx); + } + }) + }); +} + +/** + * @private + */ +export function createTransactionResultProxy( + record: T, + didPersist: U +): Transaction$ResultProxy { + return new Proxy(record, { + get: trapGet({ + didPersist + }) + }); +} + +export type { Transaction$ResultProxy } from './interfaces'; diff --git a/src/packages/database/transaction/interfaces.js b/src/packages/database/transaction/interfaces.js new file mode 100644 index 00000000..c7fac172 --- /dev/null +++ b/src/packages/database/transaction/interfaces.js @@ -0,0 +1,8 @@ +// @flow +import type { Model } from '../index'; // eslint-disable-line no-unused-vars + +// $FlowIgnore +export type Transaction$ResultProxy<+T: Model, U: boolean> = T & { + didPersist: U; + unwrap(): T; +}; diff --git a/src/packages/database/utils/connect.js b/src/packages/database/utils/connect.js index 62023371..bf82804a 100644 --- a/src/packages/database/utils/connect.js +++ b/src/packages/database/utils/connect.js @@ -1,16 +1,15 @@ import { join as joinPath } from 'path'; +import type Knex from 'knex'; + import { NODE_ENV, DATABASE_URL } from '../../../constants'; import { VALID_DRIVERS } from '../constants'; -import { tryCatchSync } from '../../../utils/try-catch'; import { InvalidDriverError } from '../errors'; -import ModuleMissingError from '../../../errors/module-missing-error'; /** * @private */ -export default function connect(path, config = {}) { - let knex; +export default function connect(path: string, config: Object = {}): Knex { let { pool } = config; const { @@ -31,18 +30,14 @@ export default function connect(path, config = {}) { if (pool && typeof pool === 'number') { pool = { - min: 0, + min: pool > 1 ? 2 : 1, max: pool }; } - tryCatchSync(() => { - knex = Reflect.apply(require, null, [ - joinPath(path, 'node_modules', 'knex') - ]); - }, () => { - throw new ModuleMissingError('knex'); - }); + const knex: Class = Reflect.apply(require, null, [ + joinPath(path, 'node_modules', 'knex') + ]); const usingSQLite = driver === 'sqlite3'; diff --git a/src/packages/database/utils/create-migrations.js b/src/packages/database/utils/create-migrations.js index 12d7c21b..619630b1 100644 --- a/src/packages/database/utils/create-migrations.js +++ b/src/packages/database/utils/create-migrations.js @@ -1,12 +1,13 @@ // @flow +import type Database from '../index'; /** * @private */ export default async function createMigrations( - schema: Function + schema: $PropertyType ): Promise { - const hasTable = await schema().hasTable('migrations'); + const hasTable: boolean = await schema().hasTable('migrations'); if (!hasTable) { await schema().createTable('migrations', table => { diff --git a/src/packages/database/utils/normalize-model-name.js b/src/packages/database/utils/normalize-model-name.js index d8d8f221..32062eae 100644 --- a/src/packages/database/utils/normalize-model-name.js +++ b/src/packages/database/utils/normalize-model-name.js @@ -1,11 +1,10 @@ // @flow import { dasherize, singularize } from 'inflection'; +import { compose } from '../../../utils/compose'; import underscore from '../../../utils/underscore'; /** * @private */ -export default function normalizeModelName(modelName: string) { - return singularize(dasherize(underscore(modelName))); -} +export default compose(singularize, dasherize, underscore); diff --git a/src/packages/database/utils/pending-migrations.js b/src/packages/database/utils/pending-migrations.js index 9a0dbd83..8feef33e 100644 --- a/src/packages/database/utils/pending-migrations.js +++ b/src/packages/database/utils/pending-migrations.js @@ -6,10 +6,12 @@ import { readdir } from '../../fs'; */ export default async function pendingMigrations( appPath: string, - table: Function + table: () => Knex$QueryBuilder ): Promise> { - const migrations = await readdir(`${appPath}/db/migrate`); - const versions = await table().select().map(({ version }) => version); + const migrations: Array = await readdir(`${appPath}/db/migrate`); + const versions: Array = await table() + .select() + .then(data => data.map(({ version }) => version)); return migrations.filter(migration => versions.indexOf( migration.replace(/^(\d{16})-.+$/g, '$1') diff --git a/src/packages/database/utils/type-for-column.js b/src/packages/database/utils/type-for-column.js index f55e9f8f..7f8876fc 100644 --- a/src/packages/database/utils/type-for-column.js +++ b/src/packages/database/utils/type-for-column.js @@ -5,6 +5,6 @@ import type { Database$column } from '../interfaces'; /** * @private */ -export default function typeForColumn(column: Database$column) { +export default function typeForColumn(column: Database$column): void | string { return TYPE_ALIASES.get(column.type); } diff --git a/src/packages/serializer/test/serializer.test.js b/src/packages/serializer/test/serializer.test.js index 5cc9e673..7465fd1a 100644 --- a/src/packages/serializer/test/serializer.test.js +++ b/src/packages/serializer/test/serializer.test.js @@ -36,13 +36,13 @@ describe('module "serializer"', () => { subject = createSerializer(); }; - const teardown = async () => { - await Promise.all( - Array.from(instances).map(record => { - return record.destroy(); - }) - ); - }; + const teardown = () => subject.model.transaction(async trx => { + const promises = Array + .from(instances) + .map(record => record.transacting(trx).destroy()); + + await Promise.all(promises); + }); before(async () => { const { models } = await getTestApp(); @@ -85,117 +85,122 @@ describe('module "serializer"', () => { includeTags = true, includeImage = true, includeComments = true - } = {}) => { + } = {}, transaction) => { let include = []; - - // $FlowIgnore - const post = await Post.create({ - body: faker.lorem.paragraphs(), - title: faker.lorem.sentence(), - isPublic: faker.random.boolean() - }); - - const postId = post.getPrimaryKey(); - - if (includeUser) { + const run = async trx => { // $FlowIgnore - const user = await User.create({ - name: `${faker.name.firstName()} ${faker.name.lastName()}`, - email: faker.internet.email(), - password: faker.internet.password(8) + const post = await Post.transacting(trx).create({ + body: faker.lorem.paragraphs(), + title: faker.lorem.sentence(), + isPublic: faker.random.boolean() }); - instances.add(user); - include = [...include, 'user']; + const postId = post.getPrimaryKey(); - Reflect.set(post, 'user', user); - } + if (includeUser) { + // $FlowIgnore + const user = await User.transacting(trx).create({ + name: `${faker.name.firstName()} ${faker.name.lastName()}`, + email: faker.internet.email(), + password: faker.internet.password(8) + }); - if (includeImage) { - // $FlowIgnore - const image = await Image.create({ - postId, - url: faker.image.imageUrl() - }); + instances.add(user); + include = [...include, 'user']; - instances.add(image); - include = [...include, 'image']; - } + Reflect.set(post, 'user', user); + } - if (includeTags) { - const tags = await Promise.all([ - // $FlowIgnore - Tag.create({ - name: faker.lorem.word() - }), - // $FlowIgnore - Tag.create({ - name: faker.lorem.word() - }), + if (includeImage) { // $FlowIgnore - Tag.create({ - name: faker.lorem.word() - }) - ]); + const image = await Image.transacting(trx).create({ + postId, + url: faker.image.imageUrl() + }); + + instances.add(image); + include = [...include, 'image']; + } - const categorizations = await Promise.all( - tags.map(tag => ( + if (includeTags) { + const tags = await Promise.all([ // $FlowIgnore - Categorization.create({ - postId, - tagId: tag.getPrimaryKey() + Tag.transacting(trx).create({ + name: faker.lorem.word() + }), + // $FlowIgnore + Tag.transacting(trx).create({ + name: faker.lorem.word() + }), + // $FlowIgnore + Tag.transacting(trx).create({ + name: faker.lorem.word() }) - )) - ); + ]); + + const categorizations = await Promise.all( + tags.map(tag => ( + // $FlowIgnore + Categorization.transacting(trx).create({ + postId, + tagId: tag.getPrimaryKey() + }) + )) + ); + + tags.forEach(tag => { + instances.add(tag); + }); + + categorizations.forEach(categorization => { + instances.add(categorization); + }); + + include = [...include, 'tags']; + } - tags.forEach(tag => { - instances.add(tag); - }); + if (includeComments) { + const comments = await Promise.all([ + // $FlowIgnore + Comment.transacting(trx).create({ + postId, + message: faker.lorem.sentence() + }), + // $FlowIgnore + Comment.transacting(trx).create({ + postId, + message: faker.lorem.sentence() + }), + // $FlowIgnore + Comment.transacting(trx).create({ + postId, + message: faker.lorem.sentence() + }) + ]); - categorizations.forEach(categorization => { - instances.add(categorization); - }); + comments.forEach(comment => { + instances.add(comment); + }); - include = [...include, 'tags']; - } + include = [...include, 'comments']; + } - if (includeComments) { - const comments = await Promise.all([ - // $FlowIgnore - Comment.create({ - postId, - message: faker.lorem.sentence() - }), - // $FlowIgnore - Comment.create({ - postId, - message: faker.lorem.sentence() - }), - // $FlowIgnore - Comment.create({ - postId, - message: faker.lorem.sentence() - }) - ]); + await post.transacting(trx).save(); - comments.forEach(comment => { - instances.add(comment); - }); + return post; + }; - include = [...include, 'comments']; + if (transaction) { + return await run(transaction); } - await post.save(true); - // $FlowIgnore - return await Post - .find(postId) - .include(...include); + return await Post.transaction(run); }; }); describe('#format()', function () { - this.timeout(15000); + this.timeout(20 * 1000); beforeEach(setup); afterEach(teardown); @@ -353,11 +358,11 @@ describe('module "serializer"', () => { this.slow(13 * 1000); this.timeout(25 * 1000); - const posts = await Promise.all( - Array.from(range(1, 25)).map(() => { - return createPost(); - }) - ); + const posts = await subject.model.transaction(trx => ( + Promise.all( + Array.from(range(1, 25)).map(() => createPost({}, trx)) + ) + )); const postIds = posts .map(post => post.getPrimaryKey()) diff --git a/src/utils/compose.js b/src/utils/compose.js new file mode 100644 index 00000000..e368960e --- /dev/null +++ b/src/utils/compose.js @@ -0,0 +1,28 @@ +// @flow + +/** + * @private + */ +export function tap(input: T): T { + console.log(input); // eslint-disable-line no-console + return input; +} + +/** + * @private + */ +export function compose(...funcs: Array<(input: T) => T>): (input: T) => T { + return input => funcs.reduceRight((value, fn) => fn(value), input); +} + +/** + * @private + */ +export function composeAsync( + ...funcs: Array<(input: T) => T | Promise> +): (input: T) => Promise { + return input => funcs.reduceRight( + (value, fn) => Promise.resolve(value).then(fn), + Promise.resolve(input) + ); +} diff --git a/src/utils/diff.js b/src/utils/diff.js new file mode 100644 index 00000000..6117990b --- /dev/null +++ b/src/utils/diff.js @@ -0,0 +1,16 @@ +// @flow + +/** + * @private + */ +export function map(a: Map, b: Map): Map { + return Array + .from(b) + .reduce((result, [key, value]) => { + if (a.get(key) !== value) { + result.set(key, value); + } + + return result; + }, new Map()); +} diff --git a/src/utils/has-own-property.js b/src/utils/has-own-property.js new file mode 100644 index 00000000..6444e678 --- /dev/null +++ b/src/utils/has-own-property.js @@ -0,0 +1,8 @@ +// @flow +export default function hasOwnProperty(target: Object, key: string): boolean { + return Reflect.apply( + Object.prototype.hasOwnProperty, + target, + [key] + ); +} diff --git a/src/utils/map-to-object.js b/src/utils/map-to-object.js new file mode 100644 index 00000000..3e6ef431 --- /dev/null +++ b/src/utils/map-to-object.js @@ -0,0 +1,11 @@ +// @flow +export default function mapToObject( + source: Map +): { [key: string]: T } { + return Array + .from(source) + .reduce((obj, [key, value]) => ({ + ...obj, + [String(key)]: value + }), {}); +} diff --git a/src/utils/proxy.js b/src/utils/proxy.js new file mode 100644 index 00000000..462aa3d3 --- /dev/null +++ b/src/utils/proxy.js @@ -0,0 +1,27 @@ +// @flow +import hasOwnProperty from './has-own-property'; + +type Proxy$get = (target: T, key: string, receiver: Proxy) => any; + +/** + * @private + */ +export function trapGet(traps: Object): Proxy$get { + return (target, key, receiver) => { + if (key === 'unwrap') { + return () => target; + } + + if (hasOwnProperty(traps, key)) { + const value = Reflect.get(traps, key); + + if (typeof value === 'function') { + return value.bind(receiver, target); + } + + return value; + } + + return Reflect.get(target, key); + }; +} diff --git a/src/utils/test/compose.test.js b/src/utils/test/compose.test.js new file mode 100644 index 00000000..bc2500dc --- /dev/null +++ b/src/utils/test/compose.test.js @@ -0,0 +1,59 @@ +// @flow +import { spy } from 'sinon'; +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import { tap, compose, composeAsync } from '../compose'; + +describe('util compose', () => { + describe('.tap()', () => { + let consoleSpy; + + before(() => { + consoleSpy = spy(console, 'log'); + }); + + after(() => { + consoleSpy.restore(); + }); + + it('logs an input and then returns it', () => { + const val = {}; + + expect(tap(val)).to.equal(val); + expect(consoleSpy.calledWithExactly(val)).to.be.true; + }); + }); + + describe('.compose()', () => { + it('returns a composed function', () => { + const shout = compose( + str => `${str}!`, + str => str.toUpperCase() + ); + + expect(shout) + .to.be.a('function') + .with.lengthOf(1); + + expect(shout('hello world')).to.equal('HELLO WORLD!'); + }); + }); + + describe('.composeAsync()', () => { + it('returns a composed asyncfunction', () => { + const shout = composeAsync( + str => Promise.resolve(`${str}!`), + str => Promise.resolve(str.toUpperCase()) + ); + + expect(shout) + .to.be.a('function') + .with.lengthOf(1); + + return shout('hello world').then(str => { + expect(str).to.equal('HELLO WORLD!'); + }); + }); + }); +}); diff --git a/src/utils/test/diff.test.js b/src/utils/test/diff.test.js new file mode 100644 index 00000000..07c6af3d --- /dev/null +++ b/src/utils/test/diff.test.js @@ -0,0 +1,30 @@ +// @flow +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import * as diff from '../diff'; + +describe('util diff', () => { + describe('.map()', () => { + it('returns a map containing the difference between two maps', () => { + const subjectA = new Map([ + ['x', 1] + ]); + + const subjectB = new Map([ + ['x', 1], + ['y', 2] + ]); + + const result = diff.map(subjectA, subjectB); + + expect(result) + .to.be.an.instanceOf(Map) + .and.have.property('size', 1); + + expect(Array.from(result)).to.deep.equal([ + ['y', 2] + ]); + }); + }); +}); diff --git a/src/utils/test/has-own-property.test.js b/src/utils/test/has-own-property.test.js new file mode 100644 index 00000000..8b6439e3 --- /dev/null +++ b/src/utils/test/has-own-property.test.js @@ -0,0 +1,35 @@ +// @flow +import { expect } from 'chai'; +import { it, describe, beforeEach } from 'mocha'; + +import hasOwnProperty from '../has-own-property'; + +describe('util hasOwnProperty()', () => { + let subject; + + beforeEach(() => { + subject = Object.create({ y: 'y' }, { + x: { + value: 'x' + } + }); + }); + + it('returns true when an object has a property', () => { + const result = hasOwnProperty(subject, 'x'); + + expect(result).to.be.true; + }); + + it('returns false when an object\'s prototype has a property', () => { + const result = hasOwnProperty(subject, 'y'); + + expect(result).to.be.false; + }); + + it('returns false when an object does not have a property', () => { + const result = hasOwnProperty(subject, 'z'); + + expect(result).to.be.false; + }); +}); diff --git a/src/utils/test/map-to-object.test.js b/src/utils/test/map-to-object.test.js new file mode 100644 index 00000000..3949c0da --- /dev/null +++ b/src/utils/test/map-to-object.test.js @@ -0,0 +1,21 @@ +// @flow +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import mapToObject from '../map-to-object'; + +describe('util mapToObject()', () => { + it('returns an object containing key, value pairs from a map', () => { + expect(mapToObject( + new Map([ + ['x', 1], + ['y', 2], + ['z', 3] + ]) + )).to.deep.equal({ + x: 1, + y: 2, + z: 3 + }); + }); +}); diff --git a/src/utils/test/omit.test.js b/src/utils/test/omit.test.js index d2890b84..42b14ab1 100644 --- a/src/utils/test/omit.test.js +++ b/src/utils/test/omit.test.js @@ -1,3 +1,4 @@ +// @flow import { expect } from 'chai'; import { it, describe } from 'mocha'; diff --git a/src/utils/test/pick.test.js b/src/utils/test/pick.test.js index d6d72db6..9cbe0763 100644 --- a/src/utils/test/pick.test.js +++ b/src/utils/test/pick.test.js @@ -1,3 +1,4 @@ +// @flow import { expect } from 'chai'; import { it, describe } from 'mocha'; diff --git a/src/utils/test/proxy.test.js b/src/utils/test/proxy.test.js new file mode 100644 index 00000000..b5ffd4ef --- /dev/null +++ b/src/utils/test/proxy.test.js @@ -0,0 +1,72 @@ +// @flow +import { expect } from 'chai'; +import { it, describe, beforeEach } from 'mocha'; + +import * as proxy from '../proxy'; + +describe('util proxy', () => { + describe('.trapGet()', () => { + let base; + let proxied; + + class Circle { + radius: number; + + constructor(radius) { + this.radius = radius; + } + + get diameter() { + return this.radius * 2; + } + + area() { + return Math.PI * (this.radius ** 2); + } + } + + beforeEach(() => { + const traps = { + isProxied: true, + + shortArea(target) { + return Math.round(target.area() * 100) / 100; + } + }; + + base = new Circle(10); + proxied = new Proxy(base, { + get: proxy.trapGet(traps) + }); + }); + + describe('- properties', () => { + it('captures and returns values defined in as traps', () => { + // $FlowIgnore + expect(proxied.isProxied).to.be.true; + }); + + it('forwards unknown properties to the proxy target', () => { + expect(proxied.radius).to.equal(base.radius); + }); + }); + + describe('- methods', () => { + it('captures and returns values defined in as traps', () => { + // $FlowIgnore + expect(proxied.shortArea()).to.equal(314.16); + }); + + it('forwards unknown methods to the proxy target', () => { + expect(proxied.area()).to.equal(base.area()); + }); + }); + + describe('#unwrap', () => { + it('returns the proxy target', () => { + // $FlowIgnore + expect(proxied.unwrap()).to.equal(base); + }); + }); + }); +}); diff --git a/test/test-app/app/controllers/images.js b/test/test-app/app/controllers/images.js index 2dd49762..3c52a40c 100644 --- a/test/test-app/app/controllers/images.js +++ b/test/test-app/app/controllers/images.js @@ -2,7 +2,8 @@ import { Controller } from 'LUX_LOCAL'; class ImagesController extends Controller { params = [ - 'url' + 'url', + 'post' ]; } diff --git a/test/test-app/app/models/action.js b/test/test-app/app/models/action.js index d7250eed..19d976d4 100644 --- a/test/test-app/app/models/action.js +++ b/test/test-app/app/models/action.js @@ -15,12 +15,12 @@ const createMessageTemplate = resourceType => (name, reactionType) => ( */ class Action extends Model { static hooks = { - async afterCreate(action) { - await action.notifyOwner(); + async afterCreate(action, trx) { + await action.notifyOwner(trx); } }; - async notifyOwner() { + async notifyOwner(trx) { const { trackableId, trackableType } = this; if (trackableType === 'Comment') { @@ -42,10 +42,12 @@ class Action extends Model { ]); if (user && post) { - await Notification.create({ - message: `${user.name} commented on your post!`, - recipientId: post.userId - }); + await Notification + .transacting(trx) + .create({ + message: `${user.name} commented on your post!`, + recipientId: post.userId + }); } } } else if (trackableType === 'Reaction') { @@ -80,10 +82,12 @@ class Action extends Model { ]); if (user && reactable) { - await Notification.create({ - message: createMessage(user.name, reaction.type), - recipientId: reactable.userId, - }); + await Notification + .transacting(trx) + .create({ + message: createMessage(user.name, reaction.type), + recipientId: reactable.userId, + }); } } } diff --git a/test/test-app/app/models/comment.js b/test/test-app/app/models/comment.js index b0ed1c06..5dca22f8 100644 --- a/test/test-app/app/models/comment.js +++ b/test/test-app/app/models/comment.js @@ -24,8 +24,8 @@ class Comment extends Model { }; static hooks = { - async afterCreate(comment) { - await track(comment); + async afterCreate(comment, trx) { + await track(comment, trx); } }; } diff --git a/test/test-app/app/models/post.js b/test/test-app/app/models/post.js index c7b34998..4016831b 100644 --- a/test/test-app/app/models/post.js +++ b/test/test-app/app/models/post.js @@ -31,8 +31,8 @@ class Post extends Model { }; static hooks = { - async afterCreate(post) { - await track(post); + async afterCreate(post, trx) { + await track(post, trx); } }; diff --git a/test/test-app/app/models/reaction.js b/test/test-app/app/models/reaction.js index 5c87af78..43b391e1 100644 --- a/test/test-app/app/models/reaction.js +++ b/test/test-app/app/models/reaction.js @@ -27,19 +27,14 @@ class Reaction extends Model { }; static hooks = { - beforeSave(reaction) { - const { - commentId, - postId - } = reaction; - + async beforeSave({ postId, commentId }) { if (!commentId && !postId) { throw new Error('Reactions must have a reactable (Post or Comment).'); } }, - async afterCreate(reaction) { - await track(reaction); + async afterCreate(reaction, trx) { + await track(reaction, trx); } }; } diff --git a/test/test-app/app/utils/track.js b/test/test-app/app/utils/track.js index 16f765c9..a0825f7e 100644 --- a/test/test-app/app/utils/track.js +++ b/test/test-app/app/utils/track.js @@ -1,17 +1,14 @@ import Action from '../models/action'; -export default async function track(trackable) { +export default function track(trackable, trx) { if (trackable) { - const { - id: trackableId, - constructor: { - name: trackableType - } - } = trackable; - - return await Action.create({ - trackableType, - trackableId - }); + return Action + .transacting(trx) + .create({ + trackableId: trackable.id, + trackableType: trackable.constructor.name + }); } + + return Promise.resolve(); } diff --git a/test/test-app/config/database.js b/test/test-app/config/database.js index 830d00fd..00cc8539 100644 --- a/test/test-app/config/database.js +++ b/test/test-app/config/database.js @@ -8,16 +8,19 @@ const { export default { development: { + pool: 5, driver: 'sqlite3', database: 'lux_test' }, test: { + pool: 5, driver: DATABASE_DRIVER, database: 'lux_test', username: DATABASE_USERNAME, password: DATABASE_PASSWORD }, production: { + pool: 5, driver: 'sqlite3', database: 'lux_test' } diff --git a/test/test-app/db/seed.js b/test/test-app/db/seed.js index efdc0847..a1eb6e80 100644 --- a/test/test-app/db/seed.js +++ b/test/test-app/db/seed.js @@ -24,65 +24,81 @@ const { } } = faker; -export default async function seed() { +export default async function seed(trx) { await Promise.all( - Array.from(range(1, 100)).map(() => User.create({ - name: `${name.firstName()} ${name.lastName()}`, - email: internet.email(), - password: internet.password(randomize([...range(8, 127)])) - })) + Array.from(range(1, 100)).map(() => ( + User.transacting(trx).create({ + name: `${name.firstName()} ${name.lastName()}`, + email: internet.email(), + password: internet.password(randomize([...range(8, 127)])) + }) + )) ); await Promise.all( - Array.from(range(1, 100)).map(() => Friendship.create({ - followerId: randomize([...range(1, 100)]), - followeeId: randomize([...range(1, 100)]) - })) + Array.from(range(1, 100)).map(() => ( + Friendship.transacting(trx).create({ + followerId: randomize([...range(1, 100)]), + followeeId: randomize([...range(1, 100)]) + }) + )) ); await Promise.all( - Array.from(range(1, 100)).map(() => Post.create({ - body: lorem.paragraphs(), - title: lorem.sentence(), - userId: randomize([...range(1, 100)]), - isPublic: random.boolean() - })) + Array.from(range(1, 100)).map(() => ( + Post.transacting(trx).create({ + body: lorem.paragraphs(), + title: lorem.sentence(), + userId: randomize([...range(1, 100)]), + isPublic: random.boolean() + }) + )) ); await Promise.all( - Array.from(range(1, 100)).map(() => Image.create({ - url: imageUrl(), - postId: randomize([...range(1, 100)]) - })) + Array.from(range(1, 100)).map(() => ( + Image.transacting(trx).create({ + url: imageUrl(), + postId: randomize([...range(1, 100)]) + }) + )) ); await Promise.all( - Array.from(range(1, 100)).map(() => Tag.create({ - name: lorem.word() - })) + Array.from(range(1, 100)).map(() => ( + Tag.transacting(trx).create({ + name: lorem.word() + }) + )) ); await Promise.all( - Array.from(range(1, 100)).map(() => Categorization.create({ - postId: randomize([...range(1, 100)]), - tagId: randomize([...range(1, 100)]) - })) + Array.from(range(1, 100)).map(() => ( + Categorization.transacting(trx).create({ + postId: randomize([...range(1, 100)]), + tagId: randomize([...range(1, 100)]) + }) + )) ); await Promise.all( - Array.from(range(1, 100)).map(() => Comment.create({ - message: lorem.sentence(), - edited: random.boolean(), - userId: randomize([...range(1, 100)]), - postId: randomize([...range(1, 100)]) - })) + Array.from(range(1, 100)).map(() => ( + Comment.transacting(trx).create({ + message: lorem.sentence(), + edited: random.boolean(), + userId: randomize([...range(1, 100)]), + postId: randomize([...range(1, 100)]) + }) + )) ); await Promise.all( - Array.from(range(1, 100)).map(() => Reaction.create({ - [`${randomize(['comment', 'post'])}Id`]: randomize([...range(1, 100)]), - userId: randomize([...range(1, 100)]), - type: randomize(REACTION_TYPES) - })) + Array.from(range(1, 100)).map(() => ( + Reaction.transacting(trx).create({ + [`${randomize(['comment', 'post'])}Id`]: randomize([...range(1, 100)]), + userId: randomize([...range(1, 100)]), + type: randomize(REACTION_TYPES) + }) + )) ); };