diff --git a/.cz-config.js b/.cz-config.js index c8fe8ca..1e138ab 100644 --- a/.cz-config.js +++ b/.cz-config.js @@ -35,6 +35,7 @@ module.exports = { {name: 'core'}, {name: 'repository'}, {name: 'datasource'}, + {name: 'transaction'}, ], appendBranchNameToCommitMessage: true, diff --git a/README.md b/README.md index 209f24a..b831190 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,79 @@ An example of the filter object might look like this to fetch the books who cont } ``` +## SQL Transactions + +A Sequelize repository can perform operations in a transaction using the `beginTransaction()` method. + +### Isolation levels + +When you call `beginTransaction()`, you can optionally specify a transaction isolation level. It support the following isolation levels: + +- `Transaction.ISOLATION_LEVELS.READ_UNCOMMITTED` (default) +- `Transaction.ISOLATION_LEVELS.READ_COMMITTED` +- `Transaction.ISOLATION_LEVELS.REPEATABLE_READ` +- `Transaction.ISOLATION_LEVELS.SERIALIZABLE` + +### Options + +Following are the supported options: + +```ts +{ + autocommit?: boolean; + isolationLevel?: Transaction.ISOLATION_LEVELS; + type?: Transaction.TYPES; + deferrable?: string | Deferrable; + /** + * Parent transaction. + */ + transaction?: Transaction | null; +} +``` + +### Example + +```ts +// Get repository instances. In a typical application, instances are injected +// via dependency injection using `@repository` decorator. +const userRepo = await app.getRepository(UserRepository); + +// Begin a new transaction. +// It's also possible to call `userRepo.dataSource.beginTransaction` instead. +const tx = await userRepo.beginTransaction({ + isolationLevel: Transaction.ISOLATION_LEVELS.SERIALIZABLE, +}); + +try { + // Then, we do some calls passing this transaction as an option: + const user = await userRepo.create( + { + firstName: 'Jon', + lastName: 'Doe', + }, + {transaction: tx}, + ); + + await userRepo.updateById( + user.id, + { + firstName: 'John', + }, + {transaction: tx}, + ); + + // If the execution reaches this line, no errors were thrown. + // We commit the transaction. + await tx.commit(); +} catch (error) { + // If the execution reaches this line, an error was thrown. + // We rollback the transaction. + await tx.rollback(); +} +``` + +Switching from loopback defaults to sequelize transaction is as simple as [this commit](https://github.com/shubhamp-sf/loopback4-sequelize-transaction-example/commit/321791c93ffd10c3af13e8b891396ae99b632a23) in [loopback4-sequelize-transaction-example](https://github.com/shubhamp-sf/loopback4-sequelize-transaction-example). + ## Debug strings reference @@ -145,9 +218,8 @@ There are three built-in debug strings available in this extension to aid in deb Please note, the current implementation does not support the following: -1. SQL Transactions. -2. Loopback Migrations (via default `migrate.ts`). Though you're good if using external packages like [`db-migrate`](https://www.npmjs.com/package/db-migrate). -3. Connection Pooling is not implemented yet. +1. Loopback Migrations (via default `migrate.ts`). Though you're good if using external packages like [`db-migrate`](https://www.npmjs.com/package/db-migrate). +2. Connection Pooling is not implemented yet. Community contribution is welcome. diff --git a/package-lock.json b/package-lock.json index 279ddd8..ccc36c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "loopback4-sequelize", - "version": "2.0.1", + "version": "2.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "loopback4-sequelize", - "version": "2.0.1", + "version": "2.1.0", "license": "MIT", "dependencies": { "debug": "^4.3.4", @@ -818,7 +818,8 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", - "dev": true + "dev": true, + "optional": true }, "node_modules/@hapi/hoek": { "version": "11.0.2", @@ -1472,6 +1473,7 @@ "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", "dev": true, + "optional": true, "dependencies": { "@gar/promisify": "^1.0.1", "semver": "^7.3.5" @@ -1483,6 +1485,7 @@ "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", "deprecated": "This functionality has been moved to @npmcli/fs", "dev": true, + "optional": true, "dependencies": { "mkdirp": "^1.0.4", "rimraf": "^3.0.2" @@ -2425,6 +2428,7 @@ "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.2.1.tgz", "integrity": "sha512-Zn4cw2NEqd+9fiSVWMscnjyQ1a8Yfoc5oBajLeo5w+YBHgDUcEBY2hS4YpTz6iN5f/2zQiktcuM6tS8x1p9dpA==", "dev": true, + "optional": true, "dependencies": { "debug": "^4.1.0", "depd": "^1.1.2", @@ -2439,6 +2443,7 @@ "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", "dev": true, + "optional": true, "engines": { "node": ">= 0.6" } @@ -2953,6 +2958,7 @@ "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", "dev": true, + "optional": true, "dependencies": { "@npmcli/fs": "^1.0.0", "@npmcli/move-file": "^1.0.1", @@ -2982,6 +2988,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "dev": true, + "optional": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -3002,6 +3009,7 @@ "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", "dev": true, + "optional": true, "dependencies": { "aggregate-error": "^3.0.0" }, @@ -4598,6 +4606,7 @@ "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", "dev": true, + "optional": true, "engines": { "node": ">=6" } @@ -4606,7 +4615,8 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "dev": true + "dev": true, + "optional": true }, "node_modules/error-ex": { "version": "1.3.2", @@ -6024,7 +6034,8 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", - "dev": true + "dev": true, + "optional": true }, "node_modules/http-errors": { "version": "2.0.0", @@ -6098,6 +6109,7 @@ "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", "dev": true, + "optional": true, "dependencies": { "ms": "^2.0.0" } @@ -6236,7 +6248,8 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", - "dev": true + "dev": true, + "optional": true }, "node_modules/inflection": { "version": "1.13.4", @@ -6326,7 +6339,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", - "dev": true + "dev": true, + "optional": true }, "node_modules/ipaddr.js": { "version": "1.9.1", @@ -6416,7 +6430,8 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", - "dev": true + "dev": true, + "optional": true }, "node_modules/is-number": { "version": "7.0.0", @@ -7276,6 +7291,7 @@ "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", "dev": true, + "optional": true, "dependencies": { "agentkeepalive": "^4.1.3", "cacache": "^15.2.0", @@ -7303,6 +7319,7 @@ "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", "dev": true, + "optional": true, "engines": { "node": ">= 6" } @@ -7312,6 +7329,7 @@ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", "dev": true, + "optional": true, "dependencies": { "@tootallnate/once": "1", "agent-base": "6", @@ -7648,6 +7666,7 @@ "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", "dev": true, + "optional": true, "dependencies": { "minipass": "^3.0.0" }, @@ -7660,6 +7679,7 @@ "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", "dev": true, + "optional": true, "dependencies": { "minipass": "^3.1.0", "minipass-sized": "^1.0.3", @@ -7677,6 +7697,7 @@ "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", "dev": true, + "optional": true, "dependencies": { "minipass": "^3.0.0" }, @@ -7689,6 +7710,7 @@ "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", "dev": true, + "optional": true, "dependencies": { "minipass": "^3.0.0" }, @@ -7701,6 +7723,7 @@ "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", "dev": true, + "optional": true, "dependencies": { "minipass": "^3.0.0" }, @@ -8105,6 +8128,7 @@ "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", "dev": true, + "optional": true, "dependencies": { "env-paths": "^2.2.0", "glob": "^7.1.4", @@ -8129,6 +8153,7 @@ "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", "dev": true, + "optional": true, "dependencies": { "delegates": "^1.0.0", "readable-stream": "^3.6.0" @@ -8142,6 +8167,7 @@ "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", "dev": true, + "optional": true, "dependencies": { "aproba": "^1.0.3 || ^2.0.0", "color-support": "^1.1.3", @@ -8161,6 +8187,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "dev": true, + "optional": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -8181,6 +8208,7 @@ "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", "dev": true, + "optional": true, "dependencies": { "are-we-there-yet": "^3.0.0", "console-control-strings": "^1.1.0", @@ -8196,6 +8224,7 @@ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", "dev": true, + "optional": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -12389,13 +12418,15 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", - "dev": true + "dev": true, + "optional": true }, "node_modules/promise-retry": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", "dev": true, + "optional": true, "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" @@ -12409,6 +12440,7 @@ "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", "dev": true, + "optional": true, "engines": { "node": ">= 4" } @@ -13613,6 +13645,7 @@ "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", "dev": true, + "optional": true, "engines": { "node": ">= 6.0.0", "npm": ">= 3.0.0" @@ -13633,6 +13666,7 @@ "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==", "dev": true, + "optional": true, "dependencies": { "ip": "^2.0.0", "smart-buffer": "^4.2.0" @@ -13647,6 +13681,7 @@ "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", "dev": true, + "optional": true, "dependencies": { "agent-base": "^6.0.2", "debug": "^4.3.3", @@ -13799,6 +13834,7 @@ "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", "dev": true, + "optional": true, "dependencies": { "minipass": "^3.1.1" }, @@ -14586,6 +14622,7 @@ "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", "dev": true, + "optional": true, "dependencies": { "unique-slug": "^2.0.0" } @@ -14595,6 +14632,7 @@ "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", "dev": true, + "optional": true, "dependencies": { "imurmurhash": "^0.1.4" } @@ -15788,7 +15826,8 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", - "dev": true + "dev": true, + "optional": true }, "@hapi/hoek": { "version": "11.0.2", @@ -16301,6 +16340,7 @@ "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", "dev": true, + "optional": true, "requires": { "@gar/promisify": "^1.0.1", "semver": "^7.3.5" @@ -16311,6 +16351,7 @@ "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", "dev": true, + "optional": true, "requires": { "mkdirp": "^1.0.4", "rimraf": "^3.0.2" @@ -17061,6 +17102,7 @@ "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.2.1.tgz", "integrity": "sha512-Zn4cw2NEqd+9fiSVWMscnjyQ1a8Yfoc5oBajLeo5w+YBHgDUcEBY2hS4YpTz6iN5f/2zQiktcuM6tS8x1p9dpA==", "dev": true, + "optional": true, "requires": { "debug": "^4.1.0", "depd": "^1.1.2", @@ -17071,7 +17113,8 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", - "dev": true + "dev": true, + "optional": true } } }, @@ -17460,6 +17503,7 @@ "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", "dev": true, + "optional": true, "requires": { "@npmcli/fs": "^1.0.0", "@npmcli/move-file": "^1.0.1", @@ -17486,6 +17530,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "dev": true, + "optional": true, "requires": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -17500,6 +17545,7 @@ "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", "dev": true, + "optional": true, "requires": { "aggregate-error": "^3.0.0" } @@ -18736,13 +18782,15 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true + "dev": true, + "optional": true }, "err-code": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "dev": true + "dev": true, + "optional": true }, "error-ex": { "version": "1.3.2", @@ -19841,7 +19889,8 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", - "dev": true + "dev": true, + "optional": true }, "http-errors": { "version": "2.0.0", @@ -19900,6 +19949,7 @@ "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", "dev": true, + "optional": true, "requires": { "ms": "^2.0.0" } @@ -19989,7 +20039,8 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", - "dev": true + "dev": true, + "optional": true }, "inflection": { "version": "1.13.4", @@ -20061,7 +20112,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", - "dev": true + "dev": true, + "optional": true }, "ipaddr.js": { "version": "1.9.1", @@ -20130,7 +20182,8 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", - "dev": true + "dev": true, + "optional": true }, "is-number": { "version": "7.0.0", @@ -20826,6 +20879,7 @@ "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", "dev": true, + "optional": true, "requires": { "agentkeepalive": "^4.1.3", "cacache": "^15.2.0", @@ -20849,13 +20903,15 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", - "dev": true + "dev": true, + "optional": true }, "http-proxy-agent": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", "dev": true, + "optional": true, "requires": { "@tootallnate/once": "1", "agent-base": "6", @@ -21098,6 +21154,7 @@ "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", "dev": true, + "optional": true, "requires": { "minipass": "^3.0.0" } @@ -21107,6 +21164,7 @@ "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", "dev": true, + "optional": true, "requires": { "encoding": "^0.1.12", "minipass": "^3.1.0", @@ -21119,6 +21177,7 @@ "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", "dev": true, + "optional": true, "requires": { "minipass": "^3.0.0" } @@ -21128,6 +21187,7 @@ "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", "dev": true, + "optional": true, "requires": { "minipass": "^3.0.0" } @@ -21137,6 +21197,7 @@ "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", "dev": true, + "optional": true, "requires": { "minipass": "^3.0.0" } @@ -21466,6 +21527,7 @@ "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", "dev": true, + "optional": true, "requires": { "env-paths": "^2.2.0", "glob": "^7.1.4", @@ -21484,6 +21546,7 @@ "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", "dev": true, + "optional": true, "requires": { "delegates": "^1.0.0", "readable-stream": "^3.6.0" @@ -21494,6 +21557,7 @@ "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", "dev": true, + "optional": true, "requires": { "aproba": "^1.0.3 || ^2.0.0", "color-support": "^1.1.3", @@ -21510,6 +21574,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "dev": true, + "optional": true, "requires": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -21524,6 +21589,7 @@ "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", "dev": true, + "optional": true, "requires": { "are-we-there-yet": "^3.0.0", "console-control-strings": "^1.1.0", @@ -21536,6 +21602,7 @@ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", "dev": true, + "optional": true, "requires": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -24493,13 +24560,15 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", - "dev": true + "dev": true, + "optional": true }, "promise-retry": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", "dev": true, + "optional": true, "requires": { "err-code": "^2.0.2", "retry": "^0.12.0" @@ -24509,7 +24578,8 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "dev": true + "dev": true, + "optional": true } } }, @@ -25431,7 +25501,8 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true + "dev": true, + "optional": true }, "snake-case": { "version": "3.0.4", @@ -25448,6 +25519,7 @@ "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==", "dev": true, + "optional": true, "requires": { "ip": "^2.0.0", "smart-buffer": "^4.2.0" @@ -25458,6 +25530,7 @@ "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", "dev": true, + "optional": true, "requires": { "agent-base": "^6.0.2", "debug": "^4.3.3", @@ -25586,6 +25659,7 @@ "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", "dev": true, + "optional": true, "requires": { "minipass": "^3.1.1" } @@ -26178,6 +26252,7 @@ "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", "dev": true, + "optional": true, "requires": { "unique-slug": "^2.0.0" } @@ -26187,6 +26262,7 @@ "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", "dev": true, + "optional": true, "requires": { "imurmurhash": "^0.1.4" } diff --git a/src/__tests__/fixtures/controllers/index.ts b/src/__tests__/fixtures/controllers/index.ts index d99624d..18b394b 100644 --- a/src/__tests__/fixtures/controllers/index.ts +++ b/src/__tests__/fixtures/controllers/index.ts @@ -5,11 +5,13 @@ export * from './developer.controller'; export * from './doctor-patient.controller'; export * from './doctor.controller'; export * from './patient.controller'; +export * from './product.controller'; export * from './programming-languange.controller'; export * from './test.controller.base'; export * from './todo-list-todo.controller'; export * from './todo-list.controller'; export * from './todo-todo-list.controller'; export * from './todo.controller'; +export * from './transaction.controller'; export * from './user-todo-list.controller'; export * from './user.controller'; diff --git a/src/__tests__/fixtures/controllers/product.controller.ts b/src/__tests__/fixtures/controllers/product.controller.ts new file mode 100644 index 0000000..17ebf31 --- /dev/null +++ b/src/__tests__/fixtures/controllers/product.controller.ts @@ -0,0 +1,158 @@ +import { + Count, + CountSchema, + Filter, + FilterExcludingWhere, + repository, + Where, +} from '@loopback/repository'; +import { + del, + get, + getModelSchemaRef, + param, + patch, + post, + put, + requestBody, + response, +} from '@loopback/rest'; +import {Product} from '../models'; +import {ProductRepository} from '../repositories'; +import {TestControllerBase} from './test.controller.base'; + +export class ProductController extends TestControllerBase { + constructor( + @repository(ProductRepository) + public productRepository: ProductRepository, + ) { + super(productRepository); + } + + @post('/products') + @response(200, { + description: 'Product model instance', + content: {'application/json': {schema: getModelSchemaRef(Product)}}, + }) + async create( + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(Product, { + title: 'NewProduct', + exclude: ['id'], + }), + }, + }, + }) + product: Omit, + ): Promise { + return this.productRepository.create(product); + } + + @get('/products/count') + @response(200, { + description: 'Product model count', + content: {'application/json': {schema: CountSchema}}, + }) + async count(@param.where(Product) where?: Where): Promise { + return this.productRepository.count(where); + } + + @get('/products') + @response(200, { + description: 'Array of Product model instances', + content: { + 'application/json': { + schema: { + type: 'array', + items: getModelSchemaRef(Product, {includeRelations: true}), + }, + }, + }, + }) + async find( + @param.filter(Product) filter?: Filter, + ): Promise { + return this.productRepository.find(filter); + } + + @patch('/products') + @response(200, { + description: 'Product PATCH success count', + content: {'application/json': {schema: CountSchema}}, + }) + async updateAll( + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(Product, {partial: true}), + }, + }, + }) + product: Product, + @param.where(Product) where?: Where, + ): Promise { + return this.productRepository.updateAll(product, where); + } + + @get('/products/{id}') + @response(200, { + description: 'Product model instance', + content: { + 'application/json': { + schema: getModelSchemaRef(Product, {includeRelations: true}), + }, + }, + }) + async findById( + @param.path.number('id') id: number, + @param.filter(Product, {exclude: 'where'}) + filter?: FilterExcludingWhere, + ): Promise { + return this.productRepository.findById(id, filter); + } + + @patch('/products/{id}') + @response(204, { + description: 'Product PATCH success', + }) + async updateById( + @param.path.number('id') id: number, + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(Product, {partial: true}), + }, + }, + }) + product: Product, + ): Promise { + await this.productRepository.updateById(id, product); + } + + @put('/products/{id}') + @response(204, { + description: 'Product PUT success', + }) + async replaceById( + @param.path.number('id') id: number, + @requestBody() product: Product, + ): Promise { + await this.productRepository.replaceById(id, product); + } + + @del('/products/{id}') + @response(204, { + description: 'Product DELETE success', + }) + async deleteById(@param.path.number('id') id: number): Promise { + await this.productRepository.deleteById(id); + } + + @get('/products/sync-sequelize-model') + @response(200) + async syncSequelizeModel(): Promise { + await this.beforeEach(); + } +} diff --git a/src/__tests__/fixtures/controllers/test.controller.base.ts b/src/__tests__/fixtures/controllers/test.controller.base.ts index 588519f..c59f79d 100644 --- a/src/__tests__/fixtures/controllers/test.controller.base.ts +++ b/src/__tests__/fixtures/controllers/test.controller.base.ts @@ -2,7 +2,10 @@ import {AnyObject} from '@loopback/repository'; import {SyncOptions} from 'sequelize'; export abstract class TestControllerBase { - constructor(public repository: AnyObject) {} + repositories: AnyObject[]; + constructor(...repositories: AnyObject[]) { + this.repositories = repositories; + } /** * `beforeEach` is only for testing purposes in the controller, @@ -11,12 +14,14 @@ export abstract class TestControllerBase { * to run migrations instead, to sync model definitions to the target database. */ async beforeEach(options: {syncAll?: boolean} = {}) { - const syncOptions: SyncOptions = {force: false}; + const syncOptions: SyncOptions = {force: true}; - if (options.syncAll) { - await this.repository.syncLoadedSequelizeModels(syncOptions); - return; + for (const repository of this.repositories as AnyObject[]) { + if (options.syncAll) { + await repository.syncLoadedSequelizeModels(syncOptions); + continue; + } + await repository.syncSequelizeModel(syncOptions); } - await this.repository.syncSequelizeModel(syncOptions); } } diff --git a/src/__tests__/fixtures/controllers/transaction.controller.ts b/src/__tests__/fixtures/controllers/transaction.controller.ts new file mode 100644 index 0000000..b508a19 --- /dev/null +++ b/src/__tests__/fixtures/controllers/transaction.controller.ts @@ -0,0 +1,158 @@ +import {AnyObject, repository} from '@loopback/repository'; +import { + get, + getModelSchemaRef, + HttpErrors, + post, + requestBody, +} from '@loopback/rest'; +import {TodoList} from '../models'; +import {ProductRepository, TodoListRepository} from '../repositories'; +import {Transaction} from './../../../types'; +import {TestControllerBase} from './test.controller.base'; + +export class TransactionController extends TestControllerBase { + constructor( + @repository(TodoListRepository) + public todoListRepository: TodoListRepository, + @repository(ProductRepository) + public productRepository: ProductRepository, + ) { + super(todoListRepository, productRepository); + } + + // create todo-list entry using transaction + @post('/transactions/todo-lists/commit') + async ensureTransactionCommit( + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(TodoList, { + title: 'NewTodoList', + exclude: ['id'], + }), + }, + }, + }) + todoList: Omit, + ): Promise { + const tx = await this.todoListRepository.beginTransaction({ + isolationLevel: Transaction.ISOLATION_LEVELS.SERIALIZABLE, + }); + + try { + const created = await this.todoListRepository.create(todoList, { + transaction: tx, + }); + await tx.commit(); + return created; + } catch (err) { + await tx.rollback(); + throw err; + } + } + + // create todo-list entry using transaction but rollback + @post('/transactions/todo-lists/rollback') + async ensureRollback( + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(TodoList, { + title: 'NewTodoList', + exclude: ['id'], + }), + }, + }, + }) + todoList: Omit, + ): Promise { + const tx = await this.todoListRepository.beginTransaction({ + isolationLevel: Transaction.ISOLATION_LEVELS.READ_COMMITTED, + }); + + const created = await this.todoListRepository.create(todoList, { + transaction: tx, + }); + await tx.rollback(); + + // In real applications if you're rolling back. Don't return created entities to user + // For test cases it's required here. (to get the id) + return created; + } + + // create todo-list entry using transaction but don't commit or rollback + @post('/transactions/todo-lists/isolation/read_commited') + async ensureIsolatedTransaction( + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(TodoList, { + title: 'NewTodoList', + exclude: ['id'], + }), + }, + }, + }) + todoList: Omit, + ): Promise { + const tx = await this.todoListRepository.beginTransaction({ + isolationLevel: Transaction.ISOLATION_LEVELS.READ_COMMITTED, + }); + + const created = await this.todoListRepository.create(todoList, { + transaction: tx, + }); + + let err: AnyObject = {}; + + // reading before commit in READ_COMMITED level should not find the entity + const findBeforeCommit = await this.todoListRepository + .findById(created.id) + .catch(e => (err = e)); + + await tx.commit(); + + // throwing it after commit to avoid deadlocks + if (err) { + throw err; + } + return findBeforeCommit as TodoList; + } + + @get('/transactions/ensure-local') + async ensureLocalTransactions(): Promise { + // "Todo List" model is from Primary Datasource + // and "AnyObject" model is from Secondary Datasource + // this test case is to ensure transaction created on + // one datasource can't be used in another + const tx = await this.todoListRepository.beginTransaction({ + isolationLevel: Transaction.ISOLATION_LEVELS.SERIALIZABLE, + }); + + let err: AnyObject | null = null; + + try { + await this.productRepository.create( + { + name: 'phone', + price: 5000, + }, + { + transaction: tx, + }, + ); + } catch (e) { + err = e; + } + + await tx.commit(); + + if (err) { + throw new HttpErrors[406](err.message); + } + + // Won't reach till here if test passes + throw new HttpErrors[406]('Product created with non-local transaction.'); + } +} diff --git a/src/__tests__/fixtures/datasources/config.ts b/src/__tests__/fixtures/datasources/config.ts new file mode 100644 index 0000000..6a41ecd --- /dev/null +++ b/src/__tests__/fixtures/datasources/config.ts @@ -0,0 +1,50 @@ +import {SequelizeDataSourceConfig} from '../../../sequelize'; + +// sqlite3 is to be used while running tests in ci environment +// postgresql can be used for local development (to ensure all transaction test cases passes) +type AvailableConfig = Record< + 'postgresql' | 'sqlite3', + SequelizeDataSourceConfig +>; + +export const datasourceTestConfig: Record< + 'primary' | 'secondary', + AvailableConfig +> = { + primary: { + postgresql: { + name: 'primary', + connector: 'postgresql', + host: 'localhost', + port: 5001, + user: 'postgres', + password: 'super-secret', + database: 'postgres', + }, + sqlite3: { + name: 'primary', + host: '0.0.0.0', + connector: 'sqlite3', + database: 'transaction-primary', + file: ':memory:', + }, + }, + secondary: { + postgresql: { + name: 'secondary', + connector: 'postgresql', + host: 'localhost', + port: 5002, + user: 'postgres', + password: 'super-secret', + database: 'postgres', + }, + sqlite3: { + name: 'secondary', + host: '0.0.0.0', + connector: 'sqlite3', + database: 'transaction-secondary', + file: ':memory:', + }, + }, +}; diff --git a/src/__tests__/fixtures/datasources/db.datasource.ts b/src/__tests__/fixtures/datasources/db.datasource.ts deleted file mode 100644 index 534042b..0000000 --- a/src/__tests__/fixtures/datasources/db.datasource.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {inject, lifeCycleObserver, LifeCycleObserver} from '@loopback/core'; -import { - SequelizeDataSource, - SequelizeDataSourceConfig, -} from '../../../sequelize'; - -const config: SequelizeDataSourceConfig = { - name: 'db', - host: '0.0.0.0', - connector: 'sqlite3', - database: 'database', - file: ':memory:', -}; - -@lifeCycleObserver('datasource') -export class DbDataSource - extends SequelizeDataSource - implements LifeCycleObserver -{ - static dataSourceName = 'db'; - static readonly defaultConfig = config; - - constructor( - @inject('datasources.config.db', {optional: true}) - dsConfig: SequelizeDataSourceConfig = config, - ) { - super(dsConfig); - } -} diff --git a/src/__tests__/fixtures/datasources/primary.datasource.ts b/src/__tests__/fixtures/datasources/primary.datasource.ts new file mode 100644 index 0000000..dc3af8c --- /dev/null +++ b/src/__tests__/fixtures/datasources/primary.datasource.ts @@ -0,0 +1,30 @@ +import {inject, lifeCycleObserver, LifeCycleObserver} from '@loopback/core'; +import { + SequelizeDataSource, + SequelizeDataSourceConfig, +} from '../../../sequelize'; +import {datasourceTestConfig} from './config'; + +// DEVELOPMENT NOTE: +// "Few Test cases for database transaction features won't work for in-memory +// database configuration like sqlite3, change this to postgresql while developing to run +// all test cases of transactional repo including those of isolation levels. +// but ensure it's set to sqlite3 before commiting changes." + +export const config = datasourceTestConfig['primary']['sqlite3']; + +@lifeCycleObserver('datasource') +export class PrimaryDataSource + extends SequelizeDataSource + implements LifeCycleObserver +{ + static dataSourceName = 'primary'; + static readonly defaultConfig = config; + + constructor( + @inject('datasources.config.primary', {optional: true}) + dsConfig: SequelizeDataSourceConfig = config, + ) { + super(dsConfig); + } +} diff --git a/src/__tests__/fixtures/datasources/secondary.datasource.ts b/src/__tests__/fixtures/datasources/secondary.datasource.ts new file mode 100644 index 0000000..1e831fb --- /dev/null +++ b/src/__tests__/fixtures/datasources/secondary.datasource.ts @@ -0,0 +1,29 @@ +import {inject, lifeCycleObserver, LifeCycleObserver} from '@loopback/core'; +import { + SequelizeDataSource, + SequelizeDataSourceConfig, +} from '../../../sequelize'; +import {datasourceTestConfig} from './config'; + +// DEVELOPMENT NOTE: +// "Few Test cases for database transaction features won't work for in-memory +// database configuration like sqlite3, change this to postgresql while developing to run +// all test cases of transactional repo including those of isolation levels. +// but ensure it's set to sqlite3 before commiting changes." +export const config = datasourceTestConfig['secondary']['sqlite3']; + +@lifeCycleObserver('datasource') +export class SecondaryDataSource + extends SequelizeDataSource + implements LifeCycleObserver +{ + static dataSourceName = 'secondary'; + static readonly defaultConfig = config; + + constructor( + @inject('datasources.config.secondary', {optional: true}) + dsConfig: SequelizeDataSourceConfig = config, + ) { + super(dsConfig); + } +} diff --git a/src/__tests__/fixtures/models/index.ts b/src/__tests__/fixtures/models/index.ts index 4c46524..eef3025 100644 --- a/src/__tests__/fixtures/models/index.ts +++ b/src/__tests__/fixtures/models/index.ts @@ -1,10 +1,11 @@ -export * from './todo.model'; -export * from './todo-list.model'; -export * from './user.model'; -export * from './doctor.model'; -export * from './patient.model'; export * from './appointment.model'; -export * from './programming-language.model'; -export * from './developer.model'; export * from './book.model'; export * from './category.model'; +export * from './developer.model'; +export * from './doctor.model'; +export * from './patient.model'; +export * from './product.model'; +export * from './programming-language.model'; +export * from './todo-list.model'; +export * from './todo.model'; +export * from './user.model'; diff --git a/src/__tests__/fixtures/models/product.model.ts b/src/__tests__/fixtures/models/product.model.ts new file mode 100644 index 0000000..f2b8398 --- /dev/null +++ b/src/__tests__/fixtures/models/product.model.ts @@ -0,0 +1,35 @@ +import {Entity, model, property} from '@loopback/repository'; + +export const TableInSecondaryDB = 'products'; +@model({ + name: TableInSecondaryDB, +}) +export class Product extends Entity { + @property({ + type: 'number', + id: true, + generated: true, + }) + id?: number; + + @property({ + type: 'string', + required: true, + }) + name: string; + + @property({ + type: 'number', + }) + price: number; + + constructor(data?: Partial) { + super(data); + } +} + +export interface ProductRelations { + // describe navigational properties here +} + +export type ProductWithRelations = Product & ProductRelations; diff --git a/src/__tests__/fixtures/repositories/appointment.repository.ts b/src/__tests__/fixtures/repositories/appointment.repository.ts index 6b197c8..349614b 100644 --- a/src/__tests__/fixtures/repositories/appointment.repository.ts +++ b/src/__tests__/fixtures/repositories/appointment.repository.ts @@ -1,6 +1,6 @@ import {inject} from '@loopback/core'; import {SequelizeCrudRepository} from '../../../sequelize'; -import {DbDataSource} from '../datasources/db.datasource'; +import {PrimaryDataSource} from '../datasources/primary.datasource'; import {Appointment, AppointmentRelations} from '../models/index'; export class AppointmentRepository extends SequelizeCrudRepository< @@ -8,7 +8,7 @@ export class AppointmentRepository extends SequelizeCrudRepository< typeof Appointment.prototype.id, AppointmentRelations > { - constructor(@inject('datasources.db') dataSource: DbDataSource) { + constructor(@inject('datasources.primary') dataSource: PrimaryDataSource) { super(Appointment, dataSource); } } diff --git a/src/__tests__/fixtures/repositories/book.repository.ts b/src/__tests__/fixtures/repositories/book.repository.ts index 0ee1675..c246e59 100644 --- a/src/__tests__/fixtures/repositories/book.repository.ts +++ b/src/__tests__/fixtures/repositories/book.repository.ts @@ -1,7 +1,7 @@ import {Getter, inject} from '@loopback/core'; import {BelongsToAccessor, repository} from '@loopback/repository'; import {SequelizeCrudRepository} from '../../../sequelize'; -import {DbDataSource} from '../datasources/db.datasource'; +import {PrimaryDataSource} from '../datasources/primary.datasource'; import {Book, BookRelations, Category} from '../models/index'; import {CategoryRepository} from './category.repository'; @@ -16,7 +16,7 @@ export class BookRepository extends SequelizeCrudRepository< >; constructor( - @inject('datasources.db') dataSource: DbDataSource, + @inject('datasources.primary') dataSource: PrimaryDataSource, @repository.getter('CategoryRepository') protected categoryRepositoryGetter: Getter, ) { diff --git a/src/__tests__/fixtures/repositories/category.repository.ts b/src/__tests__/fixtures/repositories/category.repository.ts index d0145c2..bee3d9f 100644 --- a/src/__tests__/fixtures/repositories/category.repository.ts +++ b/src/__tests__/fixtures/repositories/category.repository.ts @@ -1,6 +1,6 @@ import {inject} from '@loopback/core'; import {SequelizeCrudRepository} from '../../../sequelize'; -import {DbDataSource} from '../datasources/db.datasource'; +import {PrimaryDataSource} from '../datasources/primary.datasource'; import {Category, CategoryRelations} from '../models/index'; export class CategoryRepository extends SequelizeCrudRepository< @@ -8,7 +8,7 @@ export class CategoryRepository extends SequelizeCrudRepository< typeof Category.prototype.id, CategoryRelations > { - constructor(@inject('datasources.db') dataSource: DbDataSource) { + constructor(@inject('datasources.primary') dataSource: PrimaryDataSource) { super(Category, dataSource); } } diff --git a/src/__tests__/fixtures/repositories/developer.repository.ts b/src/__tests__/fixtures/repositories/developer.repository.ts index bfd6764..3ac7aa8 100644 --- a/src/__tests__/fixtures/repositories/developer.repository.ts +++ b/src/__tests__/fixtures/repositories/developer.repository.ts @@ -1,7 +1,7 @@ import {Getter, inject} from '@loopback/core'; import {ReferencesManyAccessor, repository} from '@loopback/repository'; import {SequelizeCrudRepository} from '../../../sequelize'; -import {DbDataSource} from '../datasources/db.datasource'; +import {PrimaryDataSource} from '../datasources/primary.datasource'; import { Developer, DeveloperRelations, @@ -20,7 +20,7 @@ export class DeveloperRepository extends SequelizeCrudRepository< >; constructor( - @inject('datasources.db') dataSource: DbDataSource, + @inject('datasources.primary') dataSource: PrimaryDataSource, @repository.getter('ProgrammingLanguageRepository') protected programmingLanguageRepositoryGetter: Getter, ) { diff --git a/src/__tests__/fixtures/repositories/doctor.repository.ts b/src/__tests__/fixtures/repositories/doctor.repository.ts index 9c6e4fc..ea53bc5 100644 --- a/src/__tests__/fixtures/repositories/doctor.repository.ts +++ b/src/__tests__/fixtures/repositories/doctor.repository.ts @@ -4,7 +4,7 @@ import { repository, } from '@loopback/repository'; import {SequelizeCrudRepository} from '../../../sequelize'; -import {DbDataSource} from '../datasources/db.datasource'; +import {PrimaryDataSource} from '../datasources/primary.datasource'; import {Appointment, Doctor, DoctorRelations, Patient} from '../models/index'; import {AppointmentRepository} from './appointment.repository'; import {PatientRepository} from './patient.repository'; @@ -22,7 +22,7 @@ export class DoctorRepository extends SequelizeCrudRepository< >; constructor( - @inject('datasources.db') dataSource: DbDataSource, + @inject('datasources.primary') dataSource: PrimaryDataSource, @repository.getter('AppointmentRepository') protected appointmentRepositoryGetter: Getter, @repository.getter('PatientRepository') diff --git a/src/__tests__/fixtures/repositories/index.ts b/src/__tests__/fixtures/repositories/index.ts index b64ec94..e691523 100644 --- a/src/__tests__/fixtures/repositories/index.ts +++ b/src/__tests__/fixtures/repositories/index.ts @@ -1,10 +1,11 @@ -export * from './todo.repository'; -export * from './todo-list.repository'; -export * from './user.repository'; -export * from './doctor.repository'; -export * from './patient.repository'; export * from './appointment.repository'; -export * from './developer.repository'; -export * from './programming-language.repository'; export * from './book.repository'; export * from './category.repository'; +export * from './developer.repository'; +export * from './doctor.repository'; +export * from './patient.repository'; +export * from './product.repository'; +export * from './programming-language.repository'; +export * from './todo-list.repository'; +export * from './todo.repository'; +export * from './user.repository'; diff --git a/src/__tests__/fixtures/repositories/patient.repository.ts b/src/__tests__/fixtures/repositories/patient.repository.ts index f47110e..2ad5741 100644 --- a/src/__tests__/fixtures/repositories/patient.repository.ts +++ b/src/__tests__/fixtures/repositories/patient.repository.ts @@ -1,6 +1,6 @@ import {inject} from '@loopback/core'; import {SequelizeCrudRepository} from '../../../sequelize'; -import {DbDataSource} from '../datasources/db.datasource'; +import {PrimaryDataSource} from '../datasources/primary.datasource'; import {Patient, PatientRelations} from '../models/index'; export class PatientRepository extends SequelizeCrudRepository< @@ -8,7 +8,7 @@ export class PatientRepository extends SequelizeCrudRepository< typeof Patient.prototype.id, PatientRelations > { - constructor(@inject('datasources.db') dataSource: DbDataSource) { + constructor(@inject('datasources.primary') dataSource: PrimaryDataSource) { super(Patient, dataSource); } } diff --git a/src/__tests__/fixtures/repositories/product.repository.ts b/src/__tests__/fixtures/repositories/product.repository.ts new file mode 100644 index 0000000..2ba70f9 --- /dev/null +++ b/src/__tests__/fixtures/repositories/product.repository.ts @@ -0,0 +1,16 @@ +import {inject} from '@loopback/core'; +import {SequelizeCrudRepository} from '../../../sequelize'; +import {SecondaryDataSource} from '../datasources/secondary.datasource'; +import {Product, ProductRelations} from '../models'; + +export class ProductRepository extends SequelizeCrudRepository< + Product, + typeof Product.prototype.id, + ProductRelations +> { + constructor( + @inject('datasources.secondary') dataSource: SecondaryDataSource, + ) { + super(Product, dataSource); + } +} diff --git a/src/__tests__/fixtures/repositories/programming-language.repository.ts b/src/__tests__/fixtures/repositories/programming-language.repository.ts index a321839..1c9a923 100644 --- a/src/__tests__/fixtures/repositories/programming-language.repository.ts +++ b/src/__tests__/fixtures/repositories/programming-language.repository.ts @@ -1,6 +1,6 @@ import {inject} from '@loopback/core'; import {SequelizeCrudRepository} from '../../../sequelize'; -import {DbDataSource} from '../datasources/db.datasource'; +import {PrimaryDataSource} from '../datasources/primary.datasource'; import { ProgrammingLanguage, ProgrammingLanguageRelations, @@ -11,7 +11,7 @@ export class ProgrammingLanguageRepository extends SequelizeCrudRepository< typeof ProgrammingLanguage.prototype.id, ProgrammingLanguageRelations > { - constructor(@inject('datasources.db') dataSource: DbDataSource) { + constructor(@inject('datasources.primary') dataSource: PrimaryDataSource) { super(ProgrammingLanguage, dataSource); } } diff --git a/src/__tests__/fixtures/repositories/todo-list.repository.ts b/src/__tests__/fixtures/repositories/todo-list.repository.ts index 5d6e096..eb7df4a 100644 --- a/src/__tests__/fixtures/repositories/todo-list.repository.ts +++ b/src/__tests__/fixtures/repositories/todo-list.repository.ts @@ -1,7 +1,7 @@ import {Getter, inject} from '@loopback/core'; import {HasManyRepositoryFactory, repository} from '@loopback/repository'; import {SequelizeCrudRepository} from '../../../sequelize'; -import {DbDataSource} from '../datasources/db.datasource'; +import {PrimaryDataSource} from '../datasources/primary.datasource'; import {Todo, TodoList, TodoListRelations} from '../models/index'; import {TodoRepository} from './todo.repository'; @@ -16,7 +16,7 @@ export class TodoListRepository extends SequelizeCrudRepository< >; constructor( - @inject('datasources.db') dataSource: DbDataSource, + @inject('datasources.primary') dataSource: PrimaryDataSource, @repository.getter('TodoRepository') protected todoRepositoryGetter: Getter, ) { diff --git a/src/__tests__/fixtures/repositories/todo.repository.ts b/src/__tests__/fixtures/repositories/todo.repository.ts index 1cfda0d..cb926ac 100644 --- a/src/__tests__/fixtures/repositories/todo.repository.ts +++ b/src/__tests__/fixtures/repositories/todo.repository.ts @@ -1,7 +1,7 @@ import {Getter, inject} from '@loopback/core'; import {BelongsToAccessor, repository} from '@loopback/repository'; import {SequelizeCrudRepository} from '../../../sequelize'; -import {DbDataSource} from '../datasources/db.datasource'; +import {PrimaryDataSource} from '../datasources/primary.datasource'; import {Todo, TodoList, TodoRelations} from '../models/index'; import {TodoListRepository} from './todo-list.repository'; @@ -16,7 +16,7 @@ export class TodoRepository extends SequelizeCrudRepository< >; constructor( - @inject('datasources.db') dataSource: DbDataSource, + @inject('datasources.primary') dataSource: PrimaryDataSource, @repository.getter('TodoListRepository') protected todoListRepositoryGetter: Getter, ) { diff --git a/src/__tests__/fixtures/repositories/user.repository.ts b/src/__tests__/fixtures/repositories/user.repository.ts index 0216afb..e96d7de 100644 --- a/src/__tests__/fixtures/repositories/user.repository.ts +++ b/src/__tests__/fixtures/repositories/user.repository.ts @@ -1,7 +1,7 @@ import {Getter, inject} from '@loopback/core'; import {HasOneRepositoryFactory, repository} from '@loopback/repository'; import {SequelizeCrudRepository} from '../../../sequelize'; -import {DbDataSource} from '../datasources/db.datasource'; +import {PrimaryDataSource} from '../datasources/primary.datasource'; import {TodoList, User, UserRelations} from '../models/index'; import {TodoListRepository} from './todo-list.repository'; @@ -16,7 +16,7 @@ export class UserRepository extends SequelizeCrudRepository< >; constructor( - @inject('datasources.db') dataSource: DbDataSource, + @inject('datasources.primary') dataSource: PrimaryDataSource, @repository.getter('TodoListRepository') protected todoListRepositoryGetter: Getter, ) { diff --git a/src/__tests__/integration/repository.integration.ts b/src/__tests__/integration/repository.integration.ts index 3603d7d..158b233 100644 --- a/src/__tests__/integration/repository.integration.ts +++ b/src/__tests__/integration/repository.integration.ts @@ -9,8 +9,23 @@ import { } from '@loopback/testlab'; import {resolve} from 'path'; import {SequelizeSandboxApplication} from '../fixtures/application'; - -describe('Sequelize CRUD Repository (integration)', () => { +import {config as primaryDataSourceConfig} from '../fixtures/datasources/primary.datasource'; + +import {config as secondaryDataSourceConfig} from '../fixtures/datasources/secondary.datasource'; +import {TableInSecondaryDB} from '../fixtures/models'; + +type Entities = + | 'users' + | 'todo-lists' + | 'todos' + | 'doctors' + | 'developers' + | 'books' + | 'products'; + +describe('Sequelize CRUD Repository (integration)', function () { + // eslint-disable-next-line @typescript-eslint/no-invalid-this + this.timeout(5000); const sandbox = new TestSandbox(resolve(__dirname, '../../.sandbox')); let app: SequelizeSandboxApplication; @@ -116,11 +131,7 @@ describe('Sequelize CRUD Repository (integration)', () => { }); describe('With Relations', () => { - async function migrateSchema( - entities: Array< - 'users' | 'todo-lists' | 'todos' | 'doctors' | 'developers' | 'books' - >, - ) { + async function migrateSchema(entities: Array) { for (const route of entities) { await client.get(`/${route}/sync-sequelize-model`).send(); } @@ -250,12 +261,14 @@ describe('Sequelize CRUD Repository (integration)', () => { ) .send(); - /** - * sqlite3 doesn't support array data type using it will convert values - * to comma saperated string - */ - createDeveloperResponse.body.programmingLanguageIds = - createDeveloperResponse.body.programmingLanguageIds.join(','); + if (primaryDataSourceConfig.connector === 'sqlite3') { + /** + * sqlite3 doesn't support array data type using it will convert values + * to comma saperated string + */ + createDeveloperResponse.body.programmingLanguageIds = + createDeveloperResponse.body.programmingLanguageIds.join(','); + } expect(relationRes.body).to.be.deepEqual({ ...createDeveloperResponse.body, @@ -322,9 +335,116 @@ describe('Sequelize CRUD Repository (integration)', () => { }); }); + describe('Connections', () => { + async function migrateSchema(entities: Array) { + for (const route of entities) { + await client.get(`/${route}/sync-sequelize-model`).send(); + } + } + it('can work with two datasources together', async () => { + await migrateSchema(['todo-lists', 'products']); + + // products model uses primary datasource + const todoList = getDummyTodoList(); + const todoListCreateRes = await client.post('/todo-lists').send(todoList); + + // products model uses secondary datasource + const product = getDummyProduct(); + const productCreateRes = await client.post('/products').send(product); + + expect(todoListCreateRes.body).to.have.properties('id', 'title'); + expect(productCreateRes.body).to.have.properties('id', 'name', 'price'); + expect(todoListCreateRes.body.title).to.be.equal(todoList.title); + expect(productCreateRes.body.name).to.be.equal(product.name); + }); + }); + + describe('Transactions', () => { + const DB_ERROR_MESSAGES = { + invalidTransaction: [ + `SequelizeDatabaseError: relation "${TableInSecondaryDB}" does not exist`, + `SequelizeDatabaseError: SQLITE_ERROR: no such table: ${TableInSecondaryDB}`, + ], + }; + async function migrateSchema(entities: Array) { + for (const route of entities) { + await client.get(`/${route}/sync-sequelize-model`).send(); + } + } + + it('retrieves model instance once transaction is committed', async () => { + await migrateSchema(['todo-lists']); + + const todoList = getDummyTodoList(); + const todoListCreateRes = await client + .post('/transactions/todo-lists/commit') + .send(todoList); + + const todoListReadRes = await client.get( + `/todo-lists/${todoListCreateRes.body.id}`, + ); + + expect(todoListReadRes.body).to.have.properties('id', 'title'); + expect(todoListReadRes.body.title).to.be.equal(todoList.title); + }); + + it('can rollback transaction', async function () { + await migrateSchema(['todo-lists']); + + const todoList = getDummyTodoList(); + const todoListCreateRes = await client + .post('/transactions/todo-lists/rollback') + .send(todoList); + + const todoListReadRes = await client.get( + `/todo-lists/${todoListCreateRes.body.id}`, + ); + + expect(todoListReadRes.body).to.have.properties('error'); + expect(todoListReadRes.body.error.code).to.be.equal('ENTITY_NOT_FOUND'); + }); + + it('ensures transactions are isolated', async function () { + if (primaryDataSourceConfig.connector === 'sqlite3') { + // Skip "READ_COMMITED" test for sqlite3 as it doesn't support it through isolationLevel options. + // eslint-disable-next-line @typescript-eslint/no-invalid-this + this.skip(); + } else { + await migrateSchema(['todo-lists']); + + const todoList = getDummyTodoList(); + const todoListCreateRes = await client + .post('/transactions/todo-lists/isolation/read_commited') + .send(todoList); + + expect(todoListCreateRes.body).to.have.properties('error'); + expect(todoListCreateRes.body.error.code).to.be.equal( + 'ENTITY_NOT_FOUND', + ); + } + }); + + it('ensures local transactions (should not use transaction with another repository of different datasource)', async function () { + if (secondaryDataSourceConfig.connector === 'sqlite3') { + // Skip local transactions test for sqlite3 as it doesn't support it through isolationLevel options. + // eslint-disable-next-line @typescript-eslint/no-invalid-this + this.skip(); + } else { + await migrateSchema(['todo-lists', 'products']); + + const response = await client.get('/transactions/ensure-local').send(); + + expect(response.body).to.have.properties('error'); + expect(response.body.error.message).to.be.oneOf( + DB_ERROR_MESSAGES.invalidTransaction, + ); + } + }); + }); + async function getAppAndClient() { const artifacts: AnyObject = { - datasources: ['db.datasource'], + datasources: ['config', 'primary.datasource', 'secondary.datasource'], models: [ 'index', 'todo.model', @@ -337,6 +457,7 @@ describe('Sequelize CRUD Repository (integration)', () => { 'developer.model', 'book.model', 'category.model', + 'product.model', ], repositories: [ 'index', @@ -350,6 +471,7 @@ describe('Sequelize CRUD Repository (integration)', () => { 'programming-language.repository', 'book.repository', 'category.repository', + 'product.repository', ], controllers: [ 'index', @@ -367,6 +489,8 @@ describe('Sequelize CRUD Repository (integration)', () => { 'todo.controller', 'user-todo-list.controller', 'user.controller', + 'transaction.controller', + 'product.controller', 'test.controller.base', ], }; @@ -425,6 +549,15 @@ describe('Sequelize CRUD Repository (integration)', () => { }; return todoList; } + function getDummyProduct(overwrite = {}) { + const todoList = { + name: 'Phone', + price: 5000, + ...overwrite, + }; + return todoList; + } + function getDummyTodo(overwrite = {}) { const todo = { title: 'Fix Bugs', diff --git a/src/sequelize/sequelize.datasource.base.ts b/src/sequelize/sequelize.datasource.base.ts index e54c376..ba338fc 100644 --- a/src/sequelize/sequelize.datasource.base.ts +++ b/src/sequelize/sequelize.datasource.base.ts @@ -1,7 +1,12 @@ import {LifeCycleObserver} from '@loopback/core'; import {AnyObject} from '@loopback/repository'; import debugFactory from 'debug'; -import {Options as SequelizeOptions, Sequelize} from 'sequelize'; +import { + Options as SequelizeOptions, + Sequelize, + Transaction, + TransactionOptions, +} from 'sequelize'; import { SupportedConnectorMapping as supportedConnectorMapping, SupportedLoopbackConnectors, @@ -32,46 +37,83 @@ export class SequelizeDataSource implements LifeCycleObserver { sequelize?: Sequelize; sequelizeConfig: SequelizeDataSourceConfig; async init(): Promise { - const connector = this.config.connector; - const storage = this.config.file; - const schema = this.config.schema; + const {config} = this; + const { + connector, + file, + schema, + database, + host, + port, + user, + username, + password, + } = config; this.sequelizeConfig = { - database: this.config.database, - ...(connector ? {dialect: supportedConnectorMapping[connector]} : {}), - ...(storage ? {storage: storage} : {}), - host: this.config.host, - port: this.config.port, - ...(schema ? {schema: schema} : {}), - username: this.config.user ?? this.config.username, - password: this.config.password, + database, + dialect: connector ? supportedConnectorMapping[connector] : undefined, + storage: file, + host, + port, + schema, + username: user ?? username, + password, logging: queryLogging, }; this.sequelize = new Sequelize(this.sequelizeConfig); - try { - await this.sequelize.authenticate(); - debug('Connection has been established successfully.'); - } catch (error) { - console.error('Unable to connect to the database:', error); - } + await this.sequelize.authenticate(); + debug('Connection has been established successfully.'); } async start(..._injectedArgs: unknown[]): Promise {} - stop() { - this.sequelize?.close?.().catch(console.error); + async stop() { + await this.sequelize?.close(); } automigrate() { throw new Error( - 'Migrations are not supported when using SequelizeDatasource, Use `db-migrate` package instead.', + 'SequelizeDataSourceError: Migrations are not supported. Use `db-migrate` package instead.', ); } autoupdate() { throw new Error( - 'Migrations are not supported when using SequelizeDatasource, Use `db-migrate` package instead.', + 'SequelizeDataSourceError: Migrations are not supported. Use `db-migrate` package instead.', ); } + + /** + * Begin a new transaction. + * + * @param [options] Options {isolationLevel: '...'} + * @returns A promise which resolves to a Sequelize Transaction object + */ + async beginTransaction( + options?: TransactionOptions | TransactionOptions['isolationLevel'], + ): Promise { + /** + * Default Isolation level for transactions is `READ_COMMITTED`, to be consistent with loopback default. + * See: https://loopback.io/doc/en/lb4/Using-database-transactions.html#isolation-levels + */ + const DEFAULT_ISOLATION_LEVEL = Transaction.ISOLATION_LEVELS.READ_COMMITTED; + + if (typeof options === 'string') { + // Received `isolationLevel` as the first argument + options = { + isolationLevel: options, + }; + } else if (options === undefined) { + options = { + isolationLevel: DEFAULT_ISOLATION_LEVEL, + }; + } else { + options.isolationLevel = + options.isolationLevel ?? DEFAULT_ISOLATION_LEVEL; + } + + return this.sequelize!.transaction(options); + } } export type SequelizeDataSourceConfig = SequelizeOptions & { diff --git a/src/sequelize/sequelize.repository.base.ts b/src/sequelize/sequelize.repository.base.ts index 4e5795b..4a6a9bb 100644 --- a/src/sequelize/sequelize.repository.base.ts +++ b/src/sequelize/sequelize.repository.base.ts @@ -46,6 +46,8 @@ import { Op, Order, SyncOptions, + Transaction, + TransactionOptions, WhereOptions, } from 'sequelize'; import {MakeNullishOptional} from 'sequelize/types/utils'; @@ -118,7 +120,6 @@ export class SequelizeCrudRepository< const data = await this.sequelizeModel .create(entity as MakeNullishOptional, options) .catch(error => { - console.error(error); err = error; }); @@ -682,16 +683,22 @@ export class SequelizeCrudRepository< * @param options Sequelize Sync Options */ async syncSequelizeModel(options: SyncOptions = {}) { - await this.dataSource.sequelize?.models[this.entityClass.modelName] - .sync(options) - .catch(console.error); + if (!this.dataSource.sequelize) { + throw new Error( + 'Sequelize instance is not attached to the datasource yet.', + ); + } + await this.dataSource.sequelize.authenticate(); + await this.dataSource.sequelize.models[this.entityClass.modelName].sync( + options, + ); } /** * Run CREATE TABLE query for the all sequelize models, Useful for quick testing * @param options Sequelize Sync Options */ async syncLoadedSequelizeModels(options: SyncOptions = {}) { - await this.dataSource.sequelize?.sync(options).catch(console.error); + await this.dataSource.sequelize?.sync(options); } /** @@ -720,7 +727,7 @@ export class SequelizeCrudRepository< definition[propName].type === Number || ['Number', 'number'].includes(definition[propName].type.toString()) ) { - dataType = DataTypes.NUMBER; + dataType = DataTypes.INTEGER; // handle float for (const dbKey of this.DB_SPECIFIC_SETTINGS_KEYS) { @@ -1240,4 +1247,10 @@ export class SequelizeCrudRepository< this, ); } + + async beginTransaction( + options?: TransactionOptions | TransactionOptions['isolationLevel'], + ): Promise { + return this.dataSource.beginTransaction(options); + } } diff --git a/src/types.ts b/src/types.ts index 12f1000..3201d86 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,3 +12,8 @@ export const DEFAULT_LOOPBACK4_SEQUELIZE_OPTIONS: LB4SequelizeComponentOptions = { // Specify the values here }; + +/** + * Sequelize Transaction type + */ +export {Transaction} from 'sequelize';