diff --git a/cspell.words.txt b/cspell.words.txt index 5659b7d..640e381 100644 --- a/cspell.words.txt +++ b/cspell.words.txt @@ -15,4 +15,7 @@ esnext SEPA hsts nosniff -csrf \ No newline at end of file +csrf +jwtid +ILIKE +alice \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index aa1cca5..3d7285a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zibri", - "version": "2.4.0", + "version": "2.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zibri", - "version": "2.4.0", + "version": "2.4.1", "license": "MIT", "dependencies": { "@fastify/busboy": "^3.2.0", @@ -14,19 +14,19 @@ "express": "^5.2.1", "glob": "^13.0.6", "node-cron": "^4.2.1", - "nodemailer": "^8.0.5", + "nodemailer": "^8.0.7", "pg": "^8.20.0", "prom-client": "^15.1.3", "reflect-metadata": "^0.2.2", "swagger-ui-express": "^5.0.1", "swagger2openapi": "^7.0.8", - "systeminformation": "^5.31.5", - "typeorm": "^0.3.28" + "systeminformation": "^5.31.6", + "typeorm": "^0.3.29" }, "devDependencies": { "@faker-js/faker": "^9.9.0", "@jest/globals": "^30.3.0", - "@swc/core": "^1.15.24", + "@swc/core": "^1.15.33", "@testcontainers/postgresql": "^11.14.0", "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", @@ -51,21 +51,21 @@ "node": ">=20" }, "peerDependencies": { - "axios": "^1.15.0", + "axios": "^1.16.0", "bcryptjs": "^3.0.3", - "bignumber.js": "^10.0.2", + "bignumber.js": "^11.1.1", "cookie-parser": "^1.4.7", "handlebars": "^4.7.9", "hi-base32": "^0.5.1", "jsonwebtoken": "^9.0.3", - "otpauth": "^9.5.0", + "otpauth": "^9.5.1", "pdfmake": "^0.2.2", "preact": "^10.29.1", "preact-render-to-string": "^6.6.7", "rxjs": "^7.8.2", "socket.io": "^4.8.3", "ts-node": "^10.9.2", - "uuid": "^11.1.0", + "uuid": "^11.1.1", "xmlbuilder2": "^4.0.3" } }, @@ -2573,9 +2573,9 @@ } }, "node_modules/@noble/hashes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", - "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -2692,9 +2692,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", "dev": true, "license": "BSD-3-Clause" }, @@ -2706,14 +2706,13 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" + "@protobufjs/aspromise": "^1.1.1" } }, "node_modules/@protobufjs/float": { @@ -2724,9 +2723,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", "dev": true, "license": "BSD-3-Clause" }, @@ -2745,9 +2744,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "dev": true, "license": "BSD-3-Clause" }, @@ -2888,9 +2887,9 @@ } }, "node_modules/@swc/core": { - "version": "1.15.24", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.24.tgz", - "integrity": "sha512-5Hj8aNasue7yusUt8LGCUe/AjM7RMAce8ZoyDyiFwx7Al+GbYKL+yE7g4sJk8vEr1dKIkTRARkNIJENc4CjkBQ==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.33.tgz", + "integrity": "sha512-jOlwnFV2xhuuZeAUILGFULeR6vDPfijEJ57evfocwznQldLU3w2cZ9bSDryY9ip+AsM3r1NJKzf47V2NXebkeQ==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -2907,18 +2906,18 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.15.24", - "@swc/core-darwin-x64": "1.15.24", - "@swc/core-linux-arm-gnueabihf": "1.15.24", - "@swc/core-linux-arm64-gnu": "1.15.24", - "@swc/core-linux-arm64-musl": "1.15.24", - "@swc/core-linux-ppc64-gnu": "1.15.24", - "@swc/core-linux-s390x-gnu": "1.15.24", - "@swc/core-linux-x64-gnu": "1.15.24", - "@swc/core-linux-x64-musl": "1.15.24", - "@swc/core-win32-arm64-msvc": "1.15.24", - "@swc/core-win32-ia32-msvc": "1.15.24", - "@swc/core-win32-x64-msvc": "1.15.24" + "@swc/core-darwin-arm64": "1.15.33", + "@swc/core-darwin-x64": "1.15.33", + "@swc/core-linux-arm-gnueabihf": "1.15.33", + "@swc/core-linux-arm64-gnu": "1.15.33", + "@swc/core-linux-arm64-musl": "1.15.33", + "@swc/core-linux-ppc64-gnu": "1.15.33", + "@swc/core-linux-s390x-gnu": "1.15.33", + "@swc/core-linux-x64-gnu": "1.15.33", + "@swc/core-linux-x64-musl": "1.15.33", + "@swc/core-win32-arm64-msvc": "1.15.33", + "@swc/core-win32-ia32-msvc": "1.15.33", + "@swc/core-win32-x64-msvc": "1.15.33" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" @@ -2930,9 +2929,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.15.24", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.24.tgz", - "integrity": "sha512-uM5ZGfFXjtvtJ+fe448PVBEbn/CSxS3UAyLj3O9xOqKIWy3S6hPTXSPbszxkSsGDYKi+YFhzAsR4r/eXLxEQ0g==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.33.tgz", + "integrity": "sha512-N+L0uXhuO7FIfzqwgxmzv0zIpV0qEp8wPX3QQs2p4atjMoywup2JTeDlXPw+z9pWJGCae3JjM+tZ6myclI+2gA==", "cpu": [ "arm64" ], @@ -2946,9 +2945,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.15.24", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.24.tgz", - "integrity": "sha512-fMIb/Zfn929pw25VMBhV7Ji2Dl+lCWtUPNdYJQYOke+00E5fcQ9ynxtP8+qhUo/HZc+mYQb1gJxwHM9vty+lXg==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.33.tgz", + "integrity": "sha512-/Il4QHSOhV4FekbsDtkrNmKbsX26oSysvgrRswa/RYOHXAkwXDbB4jaeKq6PsJLSPkzJ2KzQ061gtBnk0vNHfA==", "cpu": [ "x64" ], @@ -2962,9 +2961,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.15.24", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.24.tgz", - "integrity": "sha512-vOkjsyjjxnoYx3hMEWcGxQrMgnNrRm6WAegBXrN8foHtDAR+zpdhpGF5a4lj1bNPgXAvmysjui8cM1ov/Clkaw==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.33.tgz", + "integrity": "sha512-C64hBnBxq4viOPQ8hlx+2lJ23bzZBGnjw7ryALmS+0Q3zHmwO8lw1/DArLENw4Q18/0w5wdEO1k3m1wWNtKGqQ==", "cpu": [ "arm" ], @@ -2978,9 +2977,9 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.15.24", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.24.tgz", - "integrity": "sha512-h/oNu+upkXJ6Cicnq7YGVj9PkdfarLCdQa8l/FlHYvfv8CEiMaeeTnpLU7gSBH/rGxosM6Qkfa/J9mThGF9CLA==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.33.tgz", + "integrity": "sha512-TRJfnJbX3jqpxRDRoieMzRiCBS5jOmXNb3iQXmcgjFEHKLnAgK1RZRU8Cq1MsPqO4jAJp/ld1G4O3fXuxv85uw==", "cpu": [ "arm64" ], @@ -2994,9 +2993,9 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.15.24", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.24.tgz", - "integrity": "sha512-ZpF/pRe1guk6sKzQI9D1jAORtjTdNlyeXn9GDz8ophof/w2WhojRblvSDJaGe7rJjcPN8AaOkhwdRUh7q8oYIg==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.33.tgz", + "integrity": "sha512-il7tYM+CpUNzieQbwAjFT1P8zqAhmGWNAGhQZBnxurXZ0aNn+5nqYFTEUKNZl7QibtT0uQXzTZrNGHCIj6Y1Og==", "cpu": [ "arm64" ], @@ -3010,9 +3009,9 @@ } }, "node_modules/@swc/core-linux-ppc64-gnu": { - "version": "1.15.24", - "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.24.tgz", - "integrity": "sha512-QZEsZfisHTSJlmyChgDFNmKPb3W6Lhbfo/O76HhIngfEdnQNmukS38/VSe1feho+xkV5A5hETyCbx3sALBZKAQ==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.33.tgz", + "integrity": "sha512-ZtNBwN0Z7CFj9Il0FcPaKdjgP7URyKu/3RfH46vq+0paOBqLj4NYldD6Qo//Duif/7IOtAraUfDOmp0PLAufog==", "cpu": [ "ppc64" ], @@ -3026,9 +3025,9 @@ } }, "node_modules/@swc/core-linux-s390x-gnu": { - "version": "1.15.24", - "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.24.tgz", - "integrity": "sha512-DLdJKVsJgglqQrJBuoUYNmzm3leI7kUZhLbZGHv42onfKsGf6JDS3+bzCUQfte/XOqDjh/tmmn1DR/CF/tCJFw==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.33.tgz", + "integrity": "sha512-De1IyajoOmhOYYjw/lx66bKlyDpHZTueqwpDrWgf5O7T6d1ODeJJO9/OqMBmrBQc5C+dNnlmIufHsp4QVCWufA==", "cpu": [ "s390x" ], @@ -3042,9 +3041,9 @@ } }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.15.24", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.24.tgz", - "integrity": "sha512-IpLYfposPA/XLxYOKpRfeccl1p5dDa3+okZDHHTchBkXEaVCnq5MADPmIWwIYj1tudt7hORsEHccG5no6IUQRw==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.33.tgz", + "integrity": "sha512-mGTH0YxmUN+x6vRN/I6NOk5X0ogNktkwPnJ94IMvR7QjhRDwL0O8RXEDhyUM0YtwWrryBOqaJQBX4zruxEPRGw==", "cpu": [ "x64" ], @@ -3058,9 +3057,9 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.15.24", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.24.tgz", - "integrity": "sha512-JHy3fMSc0t/EPWgo74+OK5TGr51aElnzqfUPaiRf2qJ/BfX5CUCfMiWVBuhI7qmVMBnk1jTRnL/xZnOSHDPLYg==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.33.tgz", + "integrity": "sha512-hj628ZkSEJf6zMf5VMbYrG2O6QqyTIp2qwY6VlCjvIa9lAEZ5c2lfPblCLVGYubTeLJDxadLB/CxqQYOQABeEQ==", "cpu": [ "x64" ], @@ -3074,9 +3073,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.15.24", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.24.tgz", - "integrity": "sha512-Txj+qUH1z2bUd1P3JvwByfjKFti3cptlAxhWgmunBUUxy/IW3CXLZ6l6Gk4liANadKkU71nIU1X30Z5vpMT3BA==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.33.tgz", + "integrity": "sha512-GV2oohtN2/5+KSccl86VULu3aT+LrISC8uzgSq0FRnikpD+Zwc+sBlXmoKQ+Db6jI57ITUOIB8jRkdGMABC29g==", "cpu": [ "arm64" ], @@ -3090,9 +3089,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.15.24", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.24.tgz", - "integrity": "sha512-15D/nl3XwrhFpMv+MADFOiVwv3FvH9j8c6Rf8EXBT3Q5LoMh8YnDnSgPYqw1JzPnksvsBX6QPXLiPqmcR/Z4qQ==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.33.tgz", + "integrity": "sha512-gtyvzSNR8DHKfFEA2uqb8Ld1myqi6uEg2jyeUq3ikn5ytYs7H8RpZYC8mdy4NXr8hfcdJfCLXPlYaqqfBXpoEQ==", "cpu": [ "ia32" ], @@ -3106,9 +3105,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.15.24", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.24.tgz", - "integrity": "sha512-PR0PlTlPra2JbaDphrOAzm6s0v9rA0F17YzB+XbWD95B4g2cWcZY9LAeTa4xll70VLw9Jr7xBrlohqlQmelMFQ==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.33.tgz", + "integrity": "sha512-d6fRqQSkJI+kmMEBWaDQ7TMl8+YjLYbwRUPZQ9DY0ORBJeTzOrG0twvfvlZ2xgw6jA0ScQKgfBm4vHLSLl5Hqg==", "cpu": [ "x64" ], @@ -4686,13 +4685,13 @@ } }, "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", "license": "MIT", "peer": true, "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } @@ -4970,9 +4969,9 @@ } }, "node_modules/bignumber.js": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-10.0.2.tgz", - "integrity": "sha512-E8Wp9O06QA6lneJ4aRUXKYf/1GIomqUEmUMwtIOMtDxf1U52ffJY+y7JBk/8wRafA8qOIqLnXQGqonYXZdBnFQ==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-11.1.1.tgz", + "integrity": "sha512-LNkCYMieJqAts/Sj5094B92c5q+yRPlXWpABJnHMbUjB/F8AUjELmSmYX5mxbNiY/QnGnJvJIrnRuW5gUqbW5Q==", "license": "MIT", "peer": true }, @@ -5973,9 +5972,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.19", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", - "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", "license": "MIT" }, "node_modules/debug": { @@ -5996,9 +5995,9 @@ } }, "node_modules/dedent": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", - "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", "license": "MIT", "peerDependencies": { "babel-plugin-macros": "^3.1.0" @@ -8158,9 +8157,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "dev": true, "funding": [ { @@ -11408,9 +11407,9 @@ "license": "MIT" }, "node_modules/nodemailer": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz", - "integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==", + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz", + "integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -11989,13 +11988,13 @@ } }, "node_modules/otpauth": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.0.tgz", - "integrity": "sha512-Ldhc6UYl4baR5toGr8nfKC+L/b8/RgHKoIixAebgoNGzUUCET02g04rMEZ2ZsPfeVQhMHcuaOgb28nwMr81zCA==", + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.1.tgz", + "integrity": "sha512-fJmDAHc8wImfqqqOXIlBvT1dEKrZK0Cmb2VEgScpNTolCz0PHh6ExUZGv4sLtOsWNaHCQlD+rRqaPgnoxFoZjQ==", "license": "MIT", "peer": true, "dependencies": { - "@noble/hashes": "2.0.1" + "@noble/hashes": "2.2.0" }, "funding": { "url": "https://github.com/hectorm/otpauth?sponsor=1" @@ -12656,23 +12655,23 @@ } }, "node_modules/protobufjs": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz", - "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==", + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.9.tgz", + "integrity": "sha512-Od4muIm3HW1AouyHF5lONOf1FWo3hY1NbFDoy191X9GzhpgW1clCoaFjfVs2rKJNFYpTNJbje4cbAIDBZJ63ZA==", "dev": true, "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", + "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", + "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", + "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.0.0" }, @@ -14268,9 +14267,9 @@ } }, "node_modules/systeminformation": { - "version": "5.31.5", - "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.5.tgz", - "integrity": "sha512-5SyLdip4/3alxD4Kh+63bUQTJmu7YMfYQTC+koZy7X73HgNqZSD2P4wOZQWtUncvPvcEmnfIjCoygN4MRoEejQ==", + "version": "5.31.6", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.6.tgz", + "integrity": "sha512-Uv2b2uGGM6ns+26czgW2cYRabYdnswM0ddSOOlryHOaelzsmDSet1iM/NT7VOYxW8x/BW+HkY+b1Ve2pLTSGSA==", "license": "MIT", "os": [ "darwin", @@ -14873,25 +14872,25 @@ } }, "node_modules/typeorm": { - "version": "0.3.28", - "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz", - "integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==", + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.29.tgz", + "integrity": "sha512-wwPEX/df4l72gCmOsrs0otJZYLGA9lLQkUZCkukbsymEycV4zXv2KM7wU7v2r8L01TaCgY9ApSSqHQWBOUhEoQ==", "license": "MIT", "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^4.2.0", "app-root-path": "^3.1.0", "buffer": "^6.0.3", - "dayjs": "^1.11.19", + "dayjs": "^1.11.20", "debug": "^4.4.3", - "dedent": "^1.7.0", + "dedent": "^1.7.2", "dotenv": "^16.6.1", "glob": "^10.5.0", "reflect-metadata": "^0.2.2", "sha.js": "^2.4.12", "sql-highlight": "^6.1.0", "tslib": "^2.8.1", - "uuid": "^11.1.0", + "uuid": "^11.1.1", "yargs": "^17.7.2" }, "bin": { @@ -15247,9 +15246,9 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" diff --git a/package.json b/package.json index 8afd254..aeb0bc7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zibri", - "version": "2.4.0", + "version": "2.4.1", "main": "./dist/cjs/index.js", "types": "./dist/cjs/index.d.ts", "module": "./dist/esm/index.mjs", @@ -49,20 +49,20 @@ "license": "MIT", "description": "TS Backend Framework", "peerDependencies": { - "axios": "^1.15.0", + "axios": "^1.16.0", "bcryptjs": "^3.0.3", - "bignumber.js": "^10.0.2", + "bignumber.js": "^11.1.1", "handlebars": "^4.7.9", "hi-base32": "^0.5.1", "jsonwebtoken": "^9.0.3", - "otpauth": "^9.5.0", + "otpauth": "^9.5.1", "pdfmake": "^0.2.2", "preact": "^10.29.1", "preact-render-to-string": "^6.6.7", "rxjs": "^7.8.2", "socket.io": "^4.8.3", "ts-node": "^10.9.2", - "uuid": "^11.1.0", + "uuid": "^11.1.1", "xmlbuilder2": "^4.0.3", "cookie-parser": "^1.4.7" }, @@ -72,19 +72,19 @@ "express": "^5.2.1", "glob": "^13.0.6", "node-cron": "^4.2.1", - "nodemailer": "^8.0.5", + "nodemailer": "^8.0.7", "pg": "^8.20.0", "prom-client": "^15.1.3", "reflect-metadata": "^0.2.2", "swagger-ui-express": "^5.0.1", "swagger2openapi": "^7.0.8", - "systeminformation": "^5.31.5", - "typeorm": "^0.3.28" + "systeminformation": "^5.31.6", + "typeorm": "^0.3.29" }, "devDependencies": { "@faker-js/faker": "^9.9.0", "@jest/globals": "^30.3.0", - "@swc/core": "^1.15.24", + "@swc/core": "^1.15.33", "@testcontainers/postgresql": "^11.14.0", "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", diff --git a/sandbox/src/create-default-data.function.ts b/sandbox/src/create-default-data.function.ts index fdd6194..78a82b5 100644 --- a/sandbox/src/create-default-data.function.ts +++ b/sandbox/src/create-default-data.function.ts @@ -1,4 +1,4 @@ -import { DataSourceInterface, HashServiceInterface, inject, JwtCredentials, JwtCredentialsCreateData, Newable, Repository, repositoryTokenFor, Transaction, ZIBRI_DI_TOKENS } from 'zibri'; +import { DataSourceInterface, inject, JwtCredentials, JwtCredentialsCreateData, Newable, Repository, repositoryTokenFor, Transaction } from 'zibri'; import { logger } from '.'; import { Roles, User } from './models'; @@ -15,7 +15,6 @@ export async function createDefaultData(dataSourceClass: Newable { const userRepository: UserRepository = inject(UserRepository); const credentialsRepository: Repository = inject(repositoryTokenFor(JwtCredentials)); - const hashService: HashServiceInterface = inject(ZIBRI_DI_TOKENS.HASH_SERVICE); const defaultUser: User | undefined = await userRepository.findOne({ where: { email: 'admin@test.com' } }, false); if (defaultUser) { @@ -27,7 +26,7 @@ async function createDefaultAdmin(dataSource: DataSourceInterface): Promise = { host: 'localhost', port: 5432, username: 'postgres', diff --git a/sandbox/src/models/company.model.ts b/sandbox/src/models/company.model.ts index a6e3125..b479573 100644 --- a/sandbox/src/models/company.model.ts +++ b/sandbox/src/models/company.model.ts @@ -2,7 +2,7 @@ import { BaseEntity, Entity, Property } from 'zibri'; import { User } from './user.model'; -@Entity() +@Entity({ defaultOrder: { workers: 'ASC' } }) export class Company extends BaseEntity { @Property.oneToMany({ target: () => User, inverseSide: 'company' }) workers!: User[]; diff --git a/sandbox/src/models/user.model.ts b/sandbox/src/models/user.model.ts index 56b18db..da26f99 100644 --- a/sandbox/src/models/user.model.ts +++ b/sandbox/src/models/user.model.ts @@ -1,16 +1,18 @@ -import { Entity, JwtCredentials, Property, BaseUserEntity, IntersectionClass, OmitClass } from 'zibri'; +import { Entity, JwtCredentials, Property, BaseUserEntity, IntersectionClass, OmitClass, OmitStrict } from 'zibri'; import { Company } from './company.model'; import { Roles } from './roles.enum'; -import { OmitStrict } from '../types'; @Entity() export class User extends BaseUserEntity(Roles) { @Property.string() name!: string; - @Property.manyToOne({ target: () => Company, inverseSide: 'workers', required: false }) + @Property.manyToOne({ target: () => Company, inverseSide: 'workers', joinColumn: 'companyId', required: false }) company?: Company; + + @Property.string({ format: 'uuid', required: false }) + companyId?: string; } export class UserCreateDto extends IntersectionClass( diff --git a/sandbox/src/templates/components/cache-details-section.tsx b/sandbox/src/templates/components/cache-details-section.tsx index 9396280..3b8baa5 100644 --- a/sandbox/src/templates/components/cache-details-section.tsx +++ b/sandbox/src/templates/components/cache-details-section.tsx @@ -1,4 +1,3 @@ -// cache-detail-section.tsx import type { Chart as ChartJsChart } from 'chart.js'; import { MetricsSnapshot, PreactComponent } from 'zibri'; diff --git a/sandbox/src/templates/components/stat-card.tsx b/sandbox/src/templates/components/stat-card.tsx index 249a94c..5cba708 100644 --- a/sandbox/src/templates/components/stat-card.tsx +++ b/sandbox/src/templates/components/stat-card.tsx @@ -1,4 +1,3 @@ -// stat-card.tsx import { ComponentChildren } from 'preact'; import { PreactComponent } from 'zibri'; diff --git a/sandbox/src/types/index.ts b/sandbox/src/types/index.ts deleted file mode 100644 index 8e73ca7..0000000 --- a/sandbox/src/types/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './omit-strict.type'; \ No newline at end of file diff --git a/sandbox/src/types/omit-strict.type.ts b/sandbox/src/types/omit-strict.type.ts deleted file mode 100644 index 393eca8..0000000 --- a/sandbox/src/types/omit-strict.type.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type OmitStrict = Pick< - T, - Exclude ->; \ No newline at end of file diff --git a/src/__testing__/mocks/entities/child.entity.ts b/src/__testing__/mocks/entities/child.entity.ts index 3842651..5876321 100644 --- a/src/__testing__/mocks/entities/child.entity.ts +++ b/src/__testing__/mocks/entities/child.entity.ts @@ -10,6 +10,9 @@ export class Child { @Property.string() name!: string; - @Property.manyToOne({ target: () => Parent, inverseSide: 'children' }) + @Property.manyToOne({ target: () => Parent, joinColumn: 'parentId', inverseSide: 'children' }) parent!: Parent; + + @Property.string({ format: 'uuid' }) + parentId!: string; } \ No newline at end of file diff --git a/src/__testing__/mocks/entities/company.entity.ts b/src/__testing__/mocks/entities/company.entity.ts index 9fe5e1a..e89272b 100644 --- a/src/__testing__/mocks/entities/company.entity.ts +++ b/src/__testing__/mocks/entities/company.entity.ts @@ -3,8 +3,11 @@ import { BaseEntity } from '../../../entity/base-entity.model'; import { Entity } from '../../../entity/decorators/entity.decorator'; import { Property } from '../../../entity/decorators/property.decorator'; -@Entity() +@Entity({ allowOrphan: true }) export class Company extends BaseEntity { - @Property.belongsToOne({ target: () => User, inverseSide: 'company' }) + @Property.belongsToOne({ target: () => User, joinColumn: 'ownerId', inverseSide: 'company' }) owner!: User; + + @Property.string({ format: 'uuid' }) + ownerId!: string; } \ No newline at end of file diff --git a/src/__testing__/mocks/entities/profile.entity.ts b/src/__testing__/mocks/entities/profile.entity.ts index 7a256a0..8aa21e1 100644 --- a/src/__testing__/mocks/entities/profile.entity.ts +++ b/src/__testing__/mocks/entities/profile.entity.ts @@ -2,7 +2,7 @@ import { User } from './user.entity'; import { Entity } from '../../../entity/decorators/entity.decorator'; import { Property } from '../../../entity/decorators/property.decorator'; -@Entity() +@Entity({ allowOrphan: true }) export class Profile { @Property.string({ primary: true }) id!: string; @@ -10,6 +10,9 @@ export class Profile { @Property.string() bio!: string; - @Property.belongsToOne({ target: () => User, inverseSide: 'profile' }) + @Property.belongsToOne({ target: () => User, joinColumn: 'userId', inverseSide: 'profile' }) user!: User; + + @Property.string({ format: 'uuid' }) + userId!: string; } \ No newline at end of file diff --git a/src/__testing__/mocks/entities/role.entity.ts b/src/__testing__/mocks/entities/role.entity.ts index da3402b..b46af67 100644 --- a/src/__testing__/mocks/entities/role.entity.ts +++ b/src/__testing__/mocks/entities/role.entity.ts @@ -2,7 +2,7 @@ import { User } from './user.entity'; import { Entity } from '../../../entity/decorators/entity.decorator'; import { Property } from '../../../entity/decorators/property.decorator'; -@Entity() +@Entity({ allowOrphan: true }) export class Role { @Property.string({ primary: true }) id!: string; diff --git a/src/__testing__/mocks/entities/user.entity.ts b/src/__testing__/mocks/entities/user.entity.ts index 7b257f3..a50a687 100644 --- a/src/__testing__/mocks/entities/user.entity.ts +++ b/src/__testing__/mocks/entities/user.entity.ts @@ -8,7 +8,7 @@ import { Entity } from '../../../entity/decorators/entity.decorator'; import { Property } from '../../../entity/decorators/property.decorator'; import { OmitStrict } from '../../../types/omit-strict.type'; -@Entity() +@Entity({ allowOrphan: true }) export class User { @Property.string({ primary: true }) id!: string; diff --git a/src/__testing__/test-server/create-test-data-source.function.ts b/src/__testing__/test-server/create-test-data-source.function.ts index 6c8aee7..923d217 100644 --- a/src/__testing__/test-server/create-test-data-source.function.ts +++ b/src/__testing__/test-server/create-test-data-source.function.ts @@ -8,7 +8,7 @@ import { JwtRefreshToken } from '../../auth/strategies/jwt/jwt-refresh-token.mod import { ChangeSet } from '../../change-sets/models/change-set.model'; import { Change } from '../../change-sets/models/change.model'; import { CronJobEntity } from '../../cron/cron-job-entity.model'; -import { PostgresDataSource, PostgresOptions } from '../../data-source/data-sources/postgres-data-source.model'; +import { PostgresDataSource, PostgresOptions } from '../../data-source/data-sources/postgres-typeorm-data-source.model'; import { DataSource } from '../../data-source/decorators/data-source.decorator'; import { MigrationEntity } from '../../data-source/migration/migration-entity.model'; import { Email } from '../../email/models/email.model'; @@ -18,6 +18,7 @@ import { Event } from '../../event/event.model'; import { Log } from '../../logging/log.model'; import { ThreadJobEntity } from '../../multithreading/models/thread-job-entity.model'; import { Newable } from '../../types/newable.type'; +import { OmitStrict } from '../../types/omit-strict.type'; import { WebsocketChannel } from '../../websocket/models/websocket-channel.model'; import { WebsocketMessage } from '../../websocket/models/websocket-message.model'; import { JwtUser } from '../mocks/entities/jwt-user.entity'; @@ -59,16 +60,15 @@ export function createTestDataSource({ username = 'postgres', password = 'password', database = 'db' -}: CreateTestDataSourceOptions = {}): Newable { +}: CreateTestDataSourceOptions = {}): Newable[] }> { @DataSource() class DbDataSource extends PostgresDataSource { - options: PostgresOptions = { + options: OmitStrict = { host, username, password, - database, - synchronize: true + database }; entities: Newable[] = entities; } diff --git a/src/__testing__/test-server/start-test-server.function.ts b/src/__testing__/test-server/start-test-server.function.ts index 1ce2940..788ef00 100644 --- a/src/__testing__/test-server/start-test-server.function.ts +++ b/src/__testing__/test-server/start-test-server.function.ts @@ -11,7 +11,7 @@ import { defaultTestServerProviders } from './providers'; import './user-repository'; // this import is needed so that the DI system can pick up the user repository. import { ZibriApplication } from '../../application'; import { ZibriApplicationOptions } from '../../application-options.model'; -import { PostgresDataSource, PostgresOptions } from '../../data-source/data-sources/postgres-data-source.model'; +import { PostgresDataSource, PostgresOptions } from '../../data-source/data-sources/postgres-typeorm-data-source.model'; import { ZIBRI_DI_TOKENS } from '../../di/default/zibri-di-tokens.default'; import { DiContainer } from '../../di/di-container'; import { inject } from '../../di/inject.function'; @@ -20,8 +20,9 @@ import { Newable } from '../../types/newable.type'; import { noOp, POSTGRES_TEST_IMAGE, testAssetsFolder } from '../constants'; import { createTestDataSource } from './create-test-data-source.function'; import { AssetServiceInterface } from '../../assets/asset-service.interface'; +import { OmitStrict } from '../../types/omit-strict.type'; -type StartTestServerOptions = Partial> & { +type StartTestServerOptions = Partial> & { dataSources?: Newable[] }; @@ -56,7 +57,8 @@ export async function startTestServer( dataSources = [createTestDataSource()], providers = defaultTestServerProviders, plugins = defaultTestServerPlugins, - controllers = [] + controllers = [], + cronJobs = [] }: StartTestServerOptions = {} ): Promise { // Reset singleton — every test file gets a clean container with no stale instances. @@ -69,8 +71,7 @@ export async function startTestServer( .withUsername(dataSource.options.username ?? 'postgres') .withPassword(dataSource.options.password?.toString() ?? 'password') .start(); - // eslint-disable-next-line typescript/no-unnecessary-type-assertion - (dataSource.options as PostgresOptions) = { + (dataSource.options as OmitStrict) = { ...dataSource.options, port: container.getMappedPort(5432) }; @@ -93,7 +94,8 @@ export async function startTestServer( websocketControllers: [], dataSources, providers, - plugins + plugins, + cronJobs }); await app.init(H); diff --git a/src/__testing__/test-server/user-repository.ts b/src/__testing__/test-server/user-repository.ts index 98851b5..8490fc6 100644 --- a/src/__testing__/test-server/user-repository.ts +++ b/src/__testing__/test-server/user-repository.ts @@ -1,6 +1,7 @@ import { UserRepo } from '../../auth/decorators/user-repo.decorator'; import { JwtCredentials } from '../../auth/strategies/jwt/jwt-credentials.model'; import { UserRepositoryInterface } from '../../auth/user/user-repository.interface'; +import { getDefaultBeforeReturnHook, getDefaultBeforeSaveHook } from '../../data-source/hooks/hooks.default'; import { Repository } from '../../data-source/repository'; import { InjectRepository } from '../../di/decorators/inject-repository.decorator'; import { Inject } from '../../di/decorators/inject.decorator'; @@ -18,10 +19,10 @@ export class DefaultTestServerUserRepository extends Repository repo: Repository, @Inject(ZIBRI_DI_TOKENS.LOGGER) logger: LoggerInterface, - @InjectRepository(JwtUser) + @InjectRepository(JwtCredentials) private readonly credentialsRepository: Repository ) { - super(JwtUser, repo, logger, repo.dataSource); + super(JwtUser, repo, logger, repo.dataSource, getDefaultBeforeSaveHook(), getDefaultBeforeReturnHook()); } async findByEmail(email: string): Promise { @@ -29,6 +30,6 @@ export class DefaultTestServerUserRepository extends Repository } async resolveCredentialsFor(user: JwtUser): Promise { - return this.credentialsRepository.findOne({ where: { userId: user.id } }); + return await this.credentialsRepository.findOne({ where: { userId: user.id } }); } } \ No newline at end of file diff --git a/src/application.ts b/src/application.ts index 30efad4..0bcffd9 100644 --- a/src/application.ts +++ b/src/application.ts @@ -240,7 +240,10 @@ export class ZibriApplication { for (const [signal, handler] of this.signalHandlers) { process.off(signal, handler); } - process.exit(0); + if (signal != undefined) { + process.exit(0); + } + return; } case AppState.INITIALIZED: case AppState.STARTED: { @@ -256,7 +259,10 @@ export class ZibriApplication { await this.onAppShutdown(injectables, signal); await this.afterAppShutdown(injectables, signal); - process.exit(0); + if (signal != undefined) { + process.exit(0); + } + return; } } } diff --git a/src/auth/2fa/two-factor.service.test.ts b/src/auth/2fa/two-factor.service.test.ts new file mode 100644 index 0000000..59c68dd --- /dev/null +++ b/src/auth/2fa/two-factor.service.test.ts @@ -0,0 +1,151 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'; +import { TOTP } from 'otpauth'; + +import { OtpCredentials } from './methods/otp/otp-credentials.model'; +import { TwoFactorServiceInterface } from './two-factor-service.interface'; +import { createTestDataSource, defaultTestServerEntities } from '../../__testing__/test-server/create-test-data-source.function'; +import { startTestServer, StartedTestServer } from '../../__testing__/test-server/start-test-server.function'; +import { HttpRequestContext } from '../../context/request/http-request.context'; +import { RequestContextToken } from '../../context/request/request-context-token.model'; +import { PostgresDataSource } from '../../data-source/data-sources/postgres-typeorm-data-source.model'; +import { Repository } from '../../data-source/repository'; +import { repositoryTokenFor } from '../../di/decorators/inject-repository.decorator'; +import { ZIBRI_DI_TOKENS } from '../../di/default/zibri-di-tokens.default'; +import { inject } from '../../di/inject.function'; +import { BaseUserEntity } from '../models/base-user.model'; +import { OtpConfirmRegisterData, OtpTwoFactorMethod } from './methods/otp/otp.two-factor-method'; +import { Entity } from '../../entity/decorators/entity.decorator'; +import { Newable } from '../../types/newable.type'; + +enum TestRole { + USER = 'user' +} + +@Entity() +class User extends BaseUserEntity(TestRole) {} + +let server: StartedTestServer; +let twoFactorService: TwoFactorServiceInterface; +let otpMethod: OtpTwoFactorMethod; +let userRepo: Repository; +let otpCredentialsRepo: Repository; +let otpHeader: string; // injected value of ZIBRI_DI_TOKENS.OTP_HEADER +let otpLength: number; + +describe('TwoFactorService (contract via OTP method)', () => { + beforeAll(async () => { + const dataSourceClass: Newable = createTestDataSource({ + entities: [...defaultTestServerEntities, User] + }); + server = await startTestServer({ dataSources: [dataSourceClass] }); + + twoFactorService = inject(ZIBRI_DI_TOKENS.TWO_FACTOR_SERVICE); + otpMethod = inject(OtpTwoFactorMethod); + userRepo = inject(repositoryTokenFor(User)); + otpCredentialsRepo = inject(repositoryTokenFor(OtpCredentials)); + otpHeader = inject(ZIBRI_DI_TOKENS.OTP_HEADER); + otpLength = inject(ZIBRI_DI_TOKENS.OTP_LENGTH); + }, 15000); + + afterAll(async () => { + await server.shutdown(); + }); + + let user: User; + + beforeEach(async () => { + // start fresh every test + await otpCredentialsRepo.deleteAll({}); + await userRepo.deleteAll({}); + user = await userRepo.create({ email: '2fa-test@example.com', roles: [TestRole.USER] }); + }); + + it('requestRegister should create an unconfirmed credential', async () => { + await twoFactorService.requestRegisterTwoFactorMethodForUser(user, otpMethod, undefined as never); + const credentials: OtpCredentials[] = await otpCredentialsRepo.findAll({ where: { userId: user.id } }); + expect(credentials).toHaveLength(1); + expect(credentials[0].secret).toBeTruthy(); + expect(credentials[0].confirmed).toBe(false); + }); + + it('confirmRegister with a valid token should mark the credential as confirmed', async () => { + await twoFactorService.requestRegisterTwoFactorMethodForUser(user, otpMethod, undefined as never); + const credentials: OtpCredentials[] = await otpCredentialsRepo.findAll({ where: { userId: user.id } }); + const secret: string = credentials[0].secret; + + const validToken: string = new TOTP({ secret }).generate(); + await twoFactorService.confirmRegisterTwoFactorMethodForUser(user, otpMethod, { + token: validToken + } as OtpConfirmRegisterData); + + const updated: OtpCredentials[] = await otpCredentialsRepo.findAll({ where: { userId: user.id } }); + expect(updated[0].confirmed).toBe(true); + }); + + it('confirmRegister with an invalid token should throw', async () => { + await twoFactorService.requestRegisterTwoFactorMethodForUser(user, otpMethod, undefined as never); + await expect( + twoFactorService.confirmRegisterTwoFactorMethodForUser(user, otpMethod, { + token: '000000'.slice(0, otpLength) + } as OtpConfirmRegisterData) + ).rejects.toThrow('The provided two factor code is invalid.'); + }); + + it('has2fa should return true when a valid OTP header is present', async () => { + // register & confirm + await twoFactorService.requestRegisterTwoFactorMethodForUser(user, otpMethod, undefined as never); + const credentials: OtpCredentials[] = await otpCredentialsRepo.findAll({ where: { userId: user.id } }); + const validToken: string = new TOTP({ secret: credentials[0].secret }).generate(); + await twoFactorService.confirmRegisterTwoFactorMethodForUser(user, otpMethod, { + token: validToken + } as OtpConfirmRegisterData); + + const context: HttpRequestContext = { + request: { headers: { [otpHeader]: validToken } }, + // eslint-disable-next-line unusedImports/no-unused-vars + has: (token: RequestContextToken) => false + } as HttpRequestContext; + + const result: boolean = await twoFactorService.has2fa(user, context); + expect(result).toBe(true); + }); + + it('has2fa should return false when the token is invalid', async () => { + await twoFactorService.requestRegisterTwoFactorMethodForUser(user, otpMethod, undefined as never); + const credentials: OtpCredentials[] = await otpCredentialsRepo.findAll({ where: { userId: user.id } }); + const validToken: string = new TOTP({ secret: credentials[0].secret }).generate(); + await twoFactorService.confirmRegisterTwoFactorMethodForUser(user, otpMethod, { + token: validToken + } as OtpConfirmRegisterData); + + const context: HttpRequestContext = { + request: { headers: { [otpHeader]: '000000'.slice(0, otpLength) } }, + // eslint-disable-next-line unusedImports/no-unused-vars + has: (token: RequestContextToken) => false + } as HttpRequestContext; + + const result: boolean = await twoFactorService.has2fa(user, context); + expect(result).toBe(false); + }); + + it('unregister should delete all credentials and make has2fa false', async () => { + await twoFactorService.requestRegisterTwoFactorMethodForUser(user, otpMethod, undefined as never); + const credentials: OtpCredentials[] = await otpCredentialsRepo.findAll({ where: { userId: user.id } }); + await twoFactorService.confirmRegisterTwoFactorMethodForUser(user, otpMethod, { + token: new TOTP({ secret: credentials[0].secret }).generate() + } as OtpConfirmRegisterData); + + await twoFactorService.unregisterTwoFactorMethodForUser(user, otpMethod); + + const remaining: OtpCredentials[] = await otpCredentialsRepo.findAll({ where: { userId: user.id } }); + expect(remaining).toHaveLength(0); + + const context: HttpRequestContext = { + request: { headers: { [otpHeader]: 'does-not-matter' } }, + // eslint-disable-next-line unusedImports/no-unused-vars + has: (token: RequestContextToken) => false + } as HttpRequestContext; + const result: boolean = await twoFactorService.has2fa(user, context); + expect(result).toBe(false); + }); +}); \ No newline at end of file diff --git a/src/auth/auth.service.test.ts b/src/auth/auth.service.test.ts new file mode 100644 index 0000000..4d31372 --- /dev/null +++ b/src/auth/auth.service.test.ts @@ -0,0 +1,381 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'; + +import { AuthServiceInterface } from './auth-service.interface'; +import { Auth } from './decorators/auth.decorator'; +import { BelongsToMetadata } from './models/belongs-to-metadata.model'; +import { HasRoleMetadata } from './models/has-role-metadata.model'; +import { IsLoggedInMetadata } from './models/is-logged-in-metadata.model'; +import { IsNotLoggedInMetadata } from './models/is-not-logged-in-metadata.model'; +import { Require2faMetadata } from './models/require-2fa-metadata.model'; +import { AuthStrategies } from './strategies/auth-strategies.model'; +import { JwtUser } from '../__testing__/mocks/entities/jwt-user.entity'; +import { Roles } from '../__testing__/mocks/entities/roles.enum'; +import { createTestDataSource, defaultTestServerEntities } from '../__testing__/test-server/create-test-data-source.function'; +import { StartedTestServer, startTestServer } from '../__testing__/test-server/start-test-server.function'; +import { HttpRequestContext } from '../context/request/http-request.context'; +import { Repository } from '../data-source/repository'; +import { repositoryTokenFor } from '../di/decorators/inject-repository.decorator'; +import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; +import { inject } from '../di/inject.function'; +import { BaseEntity } from '../entity/base-entity.model'; +import { PasswordResetToken, PasswordResetTokenCreateData } from './models/password-reset-token.model'; +import { Entity } from '../entity/decorators/entity.decorator'; +import { Property } from '../entity/decorators/property.decorator'; +import { Controller } from '../routing/decorators/controller.decorator'; +import { Get } from '../routing/decorators/get.decorator'; +import { Newable } from '../types/newable.type'; +import { JwtAuthData } from './strategies/jwt/jwt-auth-data.model'; +import { JwtCredentials, JwtCredentialsCreateData } from './strategies/jwt/jwt-credentials.model'; +import { JwtAuthStrategy } from './strategies/jwt/jwt.auth-strategy'; +import { DefaultTestServerUserRepository } from '../__testing__/test-server/user-repository'; +import { Transaction } from '../data-source/transaction/transaction.model'; + +@Entity() +class Note extends BaseEntity { + @Property.string() + title!: string; + + @Property.string({ format: 'uuid' }) + userId!: string; +} + +// ----- dummy controller with auth decorators ----- +@Controller('/dummy') +class DummyController { + @Get('/open') + open(): void {} + + @Get('/login-required') + @Auth.isLoggedIn([JwtAuthStrategy]) + loginRequired(): void {} + + @Get('/admin-only') + @Auth.hasRole([Roles.ADMIN], [JwtAuthStrategy]) + adminOnly(): void {} + + @Get('/note/:id') + @Auth.belongsTo(Note, 'id', 'userId', [JwtAuthStrategy]) + belongsToNote(): void {} + + @Get('/2fa-required') + @Auth.require2fa([]) + require2fa(): void {} + + @Get('/logged-out-only') + @Auth.isNotLoggedIn([JwtAuthStrategy]) + loggedOutOnly(): void {} + + @Get('/skip-auth') + @Auth.skip() + skipAuth(): void {} +} + +// ---- helper to build contexts ---- +function buildContext(accessToken?: string, params?: Record): HttpRequestContext { + return { + request: { + headers: accessToken ? { authorization: `Bearer ${accessToken}` } : {}, + params: params ?? {} + }, + has: () => false + // minimal other properties as needed by your actual HttpRequestContext interface + } as unknown as HttpRequestContext; +} + +let server: StartedTestServer; +let authService: AuthServiceInterface; +let userRepo: DefaultTestServerUserRepository; +let credentialsRepository: Repository; +let noteRepo: Repository; +const strategies: AuthStrategies = [JwtAuthStrategy]; + +const adminEmail: string = 'auth-test@example.com'; +const adminPassword: string = 'secure123'; +const userEmail: string = 'auth-test2@example.com'; +const userPassword: string = 'secure123'; + +describe('AuthService contract', () => { + let testAdmin: JwtUser; + let testUser: JwtUser; + let adminAuthData: JwtAuthData; + let userAuthData: JwtAuthData; + + beforeAll(async () => { + server = await startTestServer({ + dataSources: [createTestDataSource({ entities: [...defaultTestServerEntities, Note] })], + controllers: [DummyController] + }); + authService = inject(ZIBRI_DI_TOKENS.AUTH_SERVICE); + userRepo = inject(DefaultTestServerUserRepository); + credentialsRepository = inject(repositoryTokenFor(JwtCredentials)); + noteRepo = inject(repositoryTokenFor(Note)); + }, 15000); + + afterAll(async () => { + await server.shutdown(); + }); + + beforeEach(async () => { + await noteRepo.deleteAll({}); + await userRepo.deleteAll({}); + await credentialsRepository.deleteAll({}); + + testAdmin = await userRepo.create({ + email: adminEmail, + roles: [Roles.USER, Roles.ADMIN] + }); + await credentialsRepository.create({ email: adminEmail, password: adminPassword, userId: testAdmin.id }); + testUser = await userRepo.create({ + email: userEmail, + roles: [Roles.USER] + }); + await credentialsRepository.create({ email: userEmail, password: userPassword, userId: testUser.id }); + + adminAuthData = await authService.login( + JwtAuthStrategy, + { email: adminEmail, password: adminPassword } + ); + userAuthData = await authService.login( + JwtAuthStrategy, + { email: userEmail, password: userPassword } + ); + }); + + // --------- login & logout ---------- + describe('login / logout / refresh', () => { + it('login returns valid auth data', () => { + expect(adminAuthData.accessToken).toBeTruthy(); + expect(adminAuthData.refreshToken).toBeTruthy(); + expect(adminAuthData.roles).toBeTruthy(); + expect(adminAuthData.userId).toBeTruthy(); + }); + + it('refreshLogin returns fresh auth data', async () => { + const transaction: Transaction = await userRepo.dataSource.startTransaction(); + const refreshed: JwtAuthData = await authService.refreshLogin( + JwtAuthStrategy, + { refreshToken: adminAuthData.refreshToken.value, transaction } + ); + await transaction.commit(); + + expect(refreshed).toHaveProperty('accessToken'); + expect(refreshed.accessToken).not.toBe(adminAuthData.accessToken); + }); + + it('logout invalidates the refresh token (refreshLogin fails)', async () => { + await authService.logout( + JwtAuthStrategy, + { refreshToken: adminAuthData.refreshToken.value } + ); + // Attempt to refresh after logout – must throw. + await expect( + authService.refreshLogin( + JwtAuthStrategy, + { refreshToken: adminAuthData.refreshToken.value, transaction: await userRepo.dataSource.startTransaction() } + ) + ).rejects.toThrow(); + }); + }); + + // --------- getCurrentUser ---------- + describe('getCurrentUser', () => { + it('returns the logged in user', async () => { + const ctx: HttpRequestContext = buildContext(adminAuthData.accessToken.value); + const current: JwtUser = await authService.getCurrentUser(ctx, strategies, true); + expect(current.id).toBe(testAdmin.id); + expect(current.email).toBe(adminEmail); + }); + + it('required=false returns undefined without token', async () => { + const ctx: HttpRequestContext = buildContext(); + const current: JwtUser | undefined = await authService.getCurrentUser(ctx, strategies, false); + expect(current).toBeUndefined(); + }); + + it('required=true throws without token', async () => { + const ctx: HttpRequestContext = buildContext(); + await expect( + authService.getCurrentUser(ctx, strategies, true) + ).rejects.toThrow(); + }); + }); + + // --------- isLoggedIn / hasRole ---------- + describe('isLoggedIn / hasRole', () => { + it('isLoggedIn returns true with valid token', async () => { + const ctx: HttpRequestContext = buildContext(adminAuthData.accessToken.value); + expect(await authService.isLoggedIn(ctx, strategies)).toBe(true); + }); + + it('isLoggedIn returns false without token', async () => { + const ctx: HttpRequestContext = buildContext(); + expect(await authService.isLoggedIn(ctx, strategies)).toBe(false); + }); + + it('hasRole returns true if role present', async () => { + const ctx: HttpRequestContext = buildContext(adminAuthData.accessToken.value); + expect(await authService.hasRole(ctx, strategies, [Roles.ADMIN])).toBe(true); + }); + + it('hasRole returns false if role missing', async () => { + const ctx: HttpRequestContext = buildContext(adminAuthData.accessToken.value); + expect(await authService.hasRole(ctx, strategies, ['nonexistent'])).toBe(false); + }); + + it('hasRole returns false without token', async () => { + const ctx: HttpRequestContext = buildContext(); + expect(await authService.hasRole(ctx, strategies, [Roles.USER])).toBe(false); + }); + }); + + // --------- belongsTo ---------- + describe('belongsTo', () => { + let note: Note; + + beforeEach(async () => { + note = await noteRepo.create({ title: 'my note', userId: testAdmin.id }); + }); + + it('returns true for owned resource', async () => { + const ctx: HttpRequestContext = buildContext(adminAuthData.accessToken.value, { id: note.id }); + const result: boolean = await authService.belongsTo(ctx, strategies, Note, 'userId', 'id'); + expect(result).toBe(true); + }); + + it('returns false for resource owned by someone else', async () => { + const ctx: HttpRequestContext = buildContext(userAuthData.accessToken.value, { id: note.id }); + const result: boolean = await authService.belongsTo(ctx, strategies, Note, 'userId', 'id'); + expect(result).toBe(false); + }); + }); + + // --------- metadata resolvers ---------- + describe('resolve*Metadata', () => { + it('resolveIsLoggedInMetadata reads @Auth.isLoggedIn', async () => { + const meta: IsLoggedInMetadata | undefined = await authService.resolveIsLoggedInMetadata(DummyController, 'loginRequired'); + expect(meta).toBeDefined(); + expect((meta as IsLoggedInMetadata).allowedStrategies).toEqual([JwtAuthStrategy]); + }); + + it('resolveIsNotLoggedInMetadata reads @Auth.isNotLoggedIn', async () => { + const meta: IsNotLoggedInMetadata | undefined = await authService.resolveIsNotLoggedInMetadata(DummyController, 'loggedOutOnly'); + expect(meta).toBeDefined(); + }); + + it('resolveHasRoleMetadata reads @Auth.hasRole', async () => { + const meta: HasRoleMetadata | undefined = await authService.resolveHasRoleMetadata(DummyController, 'adminOnly'); + expect(meta).toBeDefined(); + const hasRoleMeta: HasRoleMetadata = meta as HasRoleMetadata; + expect(hasRoleMeta.allowedRoles).toEqual([Roles.ADMIN]); + }); + + it('resolveBelongsToMetadata reads @Auth.belongsTo', async () => { + const meta: BelongsToMetadata> | undefined = await authService.resolveBelongsToMetadata(DummyController, 'belongsToNote'); + expect(meta).toBeDefined(); + const belongsMeta: BelongsToMetadata> = meta as BelongsToMetadata>; + expect(belongsMeta.targetEntity).toBe(Note); + expect(belongsMeta.targetUserIdKey).toBe('userId'); + }); + + it('resolveRequire2faMetadata reads @Auth.require2fa', async () => { + const meta: Require2faMetadata | undefined = await authService.resolveRequire2faMetadata(DummyController, 'require2fa'); + expect(meta).toBeDefined(); + }); + }); + + // --------- checkAccess ---------- + describe('checkAccess', () => { + it('throws when not logged in for loginRequired', async () => { + const ctx: HttpRequestContext = buildContext(); + await expect( + authService.checkAccess(DummyController, 'loginRequired', ctx) + ).rejects.toThrow(); + }); + + it('succeeds when logged in for loginRequired', async () => { + const ctx: HttpRequestContext = buildContext(adminAuthData.accessToken.value); + await expect( + authService.checkAccess(DummyController, 'loginRequired', ctx) + ).resolves.toBeUndefined(); + }); + + it('throws when missing role for adminOnly', async () => { + const ctx: HttpRequestContext = buildContext(userAuthData.accessToken.value); + await expect( + authService.checkAccess(DummyController, 'adminOnly', ctx) + ).rejects.toThrow(); + }); + + it('succeeds when role present for adminOnly', async () => { + const ctx: HttpRequestContext = buildContext(adminAuthData.accessToken.value); + await expect( + authService.checkAccess(DummyController, 'adminOnly', ctx) + ).resolves.toBeUndefined(); + }); + + it('skip auth always succeeds', async () => { + const ctx: HttpRequestContext = buildContext(); + await expect( + authService.checkAccess(DummyController, 'skipAuth', ctx) + ).resolves.toBeUndefined(); + }); + }); + + // --------- password reset ---------- + describe('requestPasswordReset / confirmPasswordReset', () => { + let resetTokenRepo: Repository; + + beforeAll(() => { + resetTokenRepo = inject(repositoryTokenFor(PasswordResetToken)); + }); + + beforeEach(async () => { + await resetTokenRepo.deleteAll({}); + }); + + it('requestPasswordReset creates a reset token', async () => { + const transaction: Transaction = await userRepo.dataSource.startTransaction(); + await authService.requestPasswordReset( + JwtAuthStrategy, + { user: testAdmin, transaction } + ); + await transaction.commit(); + + const tokens: PasswordResetToken[] = await resetTokenRepo.findAll({ where: { userId: testAdmin.id } }); + expect(tokens).toHaveLength(1); + expect(tokens[0].value).toBeTruthy(); + expect(tokens[0].expirationDate).toBeInstanceOf(Date); + }); + + it('confirmPasswordReset with valid token updates password', async () => { + // 1. Request reset → token created + const transaction: Transaction = await userRepo.dataSource.startTransaction(); + await authService.requestPasswordReset( + JwtAuthStrategy, + { user: testAdmin, transaction } + ); + await transaction.commit(); + const tokens: PasswordResetToken[] = await resetTokenRepo.findAll({ where: { userId: testAdmin.id } }); + const resetValue: string = tokens[0].value; + + // 2. Confirm reset with new password + const transaction2: Transaction = await userRepo.dataSource.startTransaction(); + await authService.confirmPasswordReset( + JwtAuthStrategy, + { resetToken: resetValue, newPassword: 'newPass456', transaction: transaction2 } + ); + await transaction2.commit(); + + // 3. Verify the token is consumed (deleted / no longer valid) + const afterTokens: PasswordResetToken[] = await resetTokenRepo.findAll({ where: { userId: testAdmin.id } }); + expect(afterTokens).toHaveLength(0); + + // 4. Verify login with new password works + const authData: JwtAuthData = await authService.login( + JwtAuthStrategy, + { email: adminEmail, password: 'newPass456' } + ); + expect(authData.accessToken).toBeTruthy(); + }); + }); +}); \ No newline at end of file diff --git a/src/auth/encryption/encryption-key.model.ts b/src/auth/encryption/encryption-key.model.ts index ecf7bae..9aed1fb 100644 --- a/src/auth/encryption/encryption-key.model.ts +++ b/src/auth/encryption/encryption-key.model.ts @@ -29,8 +29,13 @@ export class EncryptionKey extends BaseEntity { /** * The encryption strategy that this key belongs to. */ - @Property.manyToOne({ target: () => EncryptionStrategyEntity, inverseSide: 'keys' }) + @Property.manyToOne({ target: () => EncryptionStrategyEntity, joinColumn: 'strategyId', inverseSide: 'keys' }) strategy!: EncryptionStrategyEntity; + /** + * The id of the strategy that this key belongs to. + */ + @Property.string({ format: 'uuid' }) + strategyId!: string; /** * The status of the key. */ @@ -41,7 +46,7 @@ export class EncryptionKey extends BaseEntity { /** * The data for creating a new key. */ -export type EncryptionKeyCreateData = OmitStrict +export type EncryptionKeyCreateData = OmitStrict & DeepPartial> & { /** diff --git a/src/auth/strategies/cookie/cookie-auth.auth-strategy.ts b/src/auth/strategies/cookie/cookie-auth.auth-strategy.ts index 8bbfb48..4a31855 100644 --- a/src/auth/strategies/cookie/cookie-auth.auth-strategy.ts +++ b/src/auth/strategies/cookie/cookie-auth.auth-strategy.ts @@ -9,6 +9,7 @@ import { CookieAuthSession, CookieAuthSessionCreateData } from './cookie-auth-se import { ZibriApplication } from '../../../application'; import { HttpRequestContext } from '../../../context/request/http-request.context'; import { WebsocketRequestContext } from '../../../context/request/websocket-request.context'; +import { RepositoryTypeForEntity } from '../../../data-source/data-sources/data-source.interface'; import { Repository } from '../../../data-source/repository'; import { Transaction } from '../../../data-source/transaction/transaction.model'; import { InjectRepository, repositoryTokenFor } from '../../../di/decorators/inject-repository.decorator'; @@ -326,12 +327,12 @@ export class CookieAuthStrategy< } try { - const repo: Repository> = inject(repositoryTokenFor(targetEntity)); + const repo: RepositoryTypeForEntity> = inject(repositoryTokenFor(targetEntity)); const targetId: string | undefined = context.request.params?.[targetIdParamKey]; if (targetId == undefined) { throw new Error(`Could not find the target id specified as path param "${targetId}"`); } - const foundTarget: InstanceType = await repo.findById(targetId); + const foundTarget: InstanceType = await repo.findById(targetId) as InstanceType; const userIdProperty: unknown = foundTarget[targetUserIdKey]; if (Array.isArray(userIdProperty)) { return userIdProperty.includes(session.userId); diff --git a/src/auth/strategies/jwt/jwt-credentials.model.ts b/src/auth/strategies/jwt/jwt-credentials.model.ts index 186f7c9..1e319a6 100644 --- a/src/auth/strategies/jwt/jwt-credentials.model.ts +++ b/src/auth/strategies/jwt/jwt-credentials.model.ts @@ -43,4 +43,10 @@ export class JwtCredentialsDto extends OmitClass(JwtCredentials, ['id', 'userId' /** * The data for creating new jwt credentials. */ -export class JwtCredentialsCreateData extends OmitClass(JwtCredentials, ['id']) {} \ No newline at end of file +export class JwtCredentialsCreateData extends OmitClass(JwtCredentials, ['id', 'password']) { + /** + * The password. + */ + @Property.string({ hash: true }) + password!: string; +} \ No newline at end of file diff --git a/src/auth/strategies/jwt/jwt.auth-strategy.ts b/src/auth/strategies/jwt/jwt.auth-strategy.ts index eda76a7..796c7f6 100644 --- a/src/auth/strategies/jwt/jwt.auth-strategy.ts +++ b/src/auth/strategies/jwt/jwt.auth-strategy.ts @@ -14,6 +14,7 @@ import { JwtUtilities } from './jwt.utilities'; import { ZibriApplication } from '../../../application'; import { HttpRequestContext } from '../../../context/request/http-request.context'; import { WebsocketRequestContext } from '../../../context/request/websocket-request.context'; +import { RepositoryTypeForEntity } from '../../../data-source/data-sources/data-source.interface'; import { Repository } from '../../../data-source/repository'; import { Transaction } from '../../../data-source/transaction/transaction.model'; import { InjectRepository, repositoryTokenFor } from '../../../di/decorators/inject-repository.decorator'; @@ -362,12 +363,12 @@ implements AuthStrategyInterface< return false; } try { - const repo: Repository> = inject(repositoryTokenFor(targetEntity)); + const repo: RepositoryTypeForEntity> = inject(repositoryTokenFor(targetEntity)); const targetId: string | undefined = context.request.params?.[targetIdParamKey]; if (targetId == undefined) { throw new Error(`Could not find the target id specified as path param "${targetId}"`); } - const foundTarget: InstanceType = await repo.findById(targetId); + const foundTarget: InstanceType = await repo.findById(targetId) as InstanceType; const userIdProperty: unknown = foundTarget[targetUserIdKey]; if (Array.isArray(userIdProperty)) { return userIdProperty.includes(jwtData.payload.id); @@ -419,7 +420,8 @@ implements AuthStrategyInterface< return await JwtUtilities.sign(payload, this.refreshTokenSecret, { expiresIn: this.refreshTokenExpiresInMs / Ms.SECOND, - issuer: GlobalRegistry.getAppData('name') + issuer: GlobalRegistry.getAppData('name'), + jwtid: UUIDUtilities.generate() }); } } \ No newline at end of file diff --git a/src/backup/backup-resource-entity.model.ts b/src/backup/backup-resource-entity.model.ts index 4c3de3f..1623f9a 100644 --- a/src/backup/backup-resource-entity.model.ts +++ b/src/backup/backup-resource-entity.model.ts @@ -32,11 +32,16 @@ export class BackupResourceEntity extends BaseEntity { /** * The backup that this resource belongs to. */ - @Property.belongsToOne({ target: () => BackupEntity, inverseSide: 'resources' }) + @Property.belongsToOne({ target: () => BackupEntity, joinColumn: 'backupId', inverseSide: 'resources' }) backup!: BackupEntity; + /** + * The id of the backup that this resource belongs to. + */ + @Property.string({ format: 'uuid' }) + backupId!: string; } /** * The data required to create a new backup resource. */ -export type BackupResourceEntityCreateData = OmitStrict; \ No newline at end of file +export type BackupResourceEntityCreateData = OmitStrict; \ No newline at end of file diff --git a/src/backup/backup-service.test.ts b/src/backup/backup-service.test.ts index 279bc61..7c3ce93 100644 --- a/src/backup/backup-service.test.ts +++ b/src/backup/backup-service.test.ts @@ -8,7 +8,7 @@ import { Backup } from './decorators/backup-resource.decorator'; import { FsBackupTransport } from './transports/fs.backup-transport'; import { defaultTestServerEntities } from '../__testing__/test-server/create-test-data-source.function'; import { StartedTestServer, startTestServer } from '../__testing__/test-server/start-test-server.function'; -import { PostgresDataSource, PostgresOptions } from '../data-source/data-sources/postgres-data-source.model'; +import { PostgresDataSource, PostgresOptions } from '../data-source/data-sources/postgres-typeorm-data-source.model'; import { DataSource } from '../data-source/decorators/data-source.decorator'; import { Repository } from '../data-source/repository'; import { repositoryTokenFor } from '../di/decorators/inject-repository.decorator'; diff --git a/src/backup/backup.service.ts b/src/backup/backup.service.ts index 817e39e..52c979a 100644 --- a/src/backup/backup.service.ts +++ b/src/backup/backup.service.ts @@ -7,7 +7,7 @@ import { BackupResourceEntity, BackupResourceEntityCreateData } from './backup-r import { BackupResourceInterface } from './backup-resource.interface'; import { BackupCreateData, BackupServiceInterface } from './backup-service.interface'; import { ZibriApplication } from '../application'; -import { PostgresDataSource } from '../data-source/data-sources/postgres-data-source.model'; +import { PostgresDataSource } from '../data-source/data-sources/postgres-typeorm-data-source.model'; import { repositoryTokenFor } from '../di/decorators/inject-repository.decorator'; import { Inject } from '../di/decorators/inject.decorator'; import { Injectable } from '../di/decorators/injectable.decorator'; diff --git a/src/caching/cache/base-cache.model.ts b/src/caching/cache/base-cache.model.ts index bbd8c8a..b791f3e 100644 --- a/src/caching/cache/base-cache.model.ts +++ b/src/caching/cache/base-cache.model.ts @@ -1,5 +1,5 @@ import { AlsUtilities } from '../../context/als.utilities'; -import { LogCacheContext } from '../../logging/log-context.model'; +import { CacheContext } from '../../context/cache/cache.context'; import { LoggerInterface } from '../../logging/logger.interface'; import { MetricsServiceInterface } from '../../metrics/metrics-service.interface'; import { OmitStrict } from '../../types/omit-strict.type'; @@ -54,7 +54,7 @@ implements OmitStrict, ' options?: CacheWrapDeleteOptions ): (...args: TArgs) => Promise { return async (...args) => { - const cacheCtx: LogCacheContext = { cache: this.name, operation: CacheOperation.DELETE }; + const cacheCtx: CacheContext = { cache: this.name, operation: CacheOperation.DELETE }; return AlsUtilities.runWithCacheContext(cacheCtx, async () => { const sourceStart: number = performance.now(); @@ -95,7 +95,7 @@ implements OmitStrict, ' options: CacheWrapInvalidateOptions ): (...args: TArgs) => Promise { return async (...args) => { - const cacheCtx: LogCacheContext = { cache: this.name, operation: CacheOperation.INVALIDATE }; + const cacheCtx: CacheContext = { cache: this.name, operation: CacheOperation.INVALIDATE }; return AlsUtilities.runWithCacheContext(cacheCtx, async () => { const sourceStart: number = performance.now(); diff --git a/src/caching/cache/multi-tier.cache.ts b/src/caching/cache/multi-tier.cache.ts index 8efe345..766226c 100644 --- a/src/caching/cache/multi-tier.cache.ts +++ b/src/caching/cache/multi-tier.cache.ts @@ -3,7 +3,7 @@ import { CacheOperation } from './cache-operation.enum'; import { CacheWrapOptions, CacheWrapWriteOptionsWithResult, CacheWrapWriteOptionsArgsOnly, CacheWrapDeleteOptions, CacheWrapInvalidateOptions, CacheKeyProvider, ResultCacheKeyProvider, OnInvalidationFailure, CacheTagsProvider, CacheSetDirectOptions } from './cache-options.model'; import { CacheInterface } from './cache.interface'; import { AlsUtilities } from '../../context/als.utilities'; -import { LogCacheContext } from '../../logging/log-context.model'; +import { CacheContext } from '../../context/cache/cache.context'; import { LoggerInterface } from '../../logging/logger.interface'; import { MetricsServiceInterface } from '../../metrics/metrics-service.interface'; import { CacheMetrics } from '../cache-metrics.model'; @@ -243,7 +243,7 @@ export abstract class MultiTierCache< options?: CacheWrapDeleteOptions ): (...args: TArgs) => Promise { return async (...args) => { - const cacheCtx: LogCacheContext = { cache: this.name, operation: CacheOperation.DELETE }; + const cacheCtx: CacheContext = { cache: this.name, operation: CacheOperation.DELETE }; return AlsUtilities.runWithCacheContext(cacheCtx, async () => { const res: TReturn = await fn(...args); const key: K = await keyFn(...args); @@ -267,7 +267,7 @@ export abstract class MultiTierCache< options: CacheWrapInvalidateOptions ): (...args: TArgs) => Promise { return async (...args) => { - const cacheCtx: LogCacheContext = { cache: this.name, operation: CacheOperation.INVALIDATE }; + const cacheCtx: CacheContext = { cache: this.name, operation: CacheOperation.INVALIDATE }; return AlsUtilities.runWithCacheContext(cacheCtx, async () => { const result: TReturn = await fn(...args); const tags: CacheTag[] = typeof options.invalidatesTags === 'function' diff --git a/src/caching/cache/read-aside/read-aside.cache.ts b/src/caching/cache/read-aside/read-aside.cache.ts index 68f4bba..d3a44f5 100644 --- a/src/caching/cache/read-aside/read-aside.cache.ts +++ b/src/caching/cache/read-aside/read-aside.cache.ts @@ -1,5 +1,5 @@ import { AlsUtilities } from '../../../context/als.utilities'; -import { LogCacheContext } from '../../../logging/log-context.model'; +import { CacheContext } from '../../../context/cache/cache.context'; import { CachedValue } from '../../store/cached-value.model'; import { BaseCache } from '../base-cache.model'; import { CacheOperation } from '../cache-operation.enum'; @@ -30,7 +30,7 @@ export abstract class ReadAsideCache< options?: CacheWrapOptions ): (...args: TArgs) => Promise { return async (...args) => { - const cacheCtx: LogCacheContext = { cache: this.name, operation: CacheOperation.WRAP }; + const cacheCtx: CacheContext = { cache: this.name, operation: CacheOperation.WRAP }; const label: Record = { cache: this.name }; return AlsUtilities.runWithCacheContext(cacheCtx, async () => { diff --git a/src/caching/cache/read-aside/write-around-read-aside.cache.ts b/src/caching/cache/read-aside/write-around-read-aside.cache.ts index caca068..1a30971 100644 --- a/src/caching/cache/read-aside/write-around-read-aside.cache.ts +++ b/src/caching/cache/read-aside/write-around-read-aside.cache.ts @@ -1,5 +1,5 @@ import { AlsUtilities } from '../../../context/als.utilities'; -import { LogCacheContext } from '../../../logging/log-context.model'; +import { CacheContext } from '../../../context/cache/cache.context'; import { CacheOperation } from '../cache-operation.enum'; import { CacheKeyProvider, CacheWrapWriteOptionsArgsOnly } from '../cache-options.model'; import { CacheInterface } from '../cache.interface'; @@ -25,7 +25,7 @@ export abstract class WriteAroundReadAsideCache ): (...args: TArgs) => Promise { return async (...args) => { - const cacheCtx: LogCacheContext = { cache: this.name, operation: CacheOperation.WRITE }; + const cacheCtx: CacheContext = { cache: this.name, operation: CacheOperation.WRITE }; return AlsUtilities.runWithCacheContext(cacheCtx, async () => { const sourceStart: number = performance.now(); diff --git a/src/caching/cache/read-aside/write-behind-read-aside.cache.ts b/src/caching/cache/read-aside/write-behind-read-aside.cache.ts index fb56bd9..3e803a1 100644 --- a/src/caching/cache/read-aside/write-behind-read-aside.cache.ts +++ b/src/caching/cache/read-aside/write-behind-read-aside.cache.ts @@ -1,5 +1,5 @@ import { AlsUtilities } from '../../../context/als.utilities'; -import { LogCacheContext } from '../../../logging/log-context.model'; +import { CacheContext } from '../../../context/cache/cache.context'; import { CacheOperation } from '../cache-operation.enum'; import { ResultCacheKeyProvider, CacheWrapWriteOptionsWithResult, CacheSetDirectOptions } from '../cache-options.model'; import { CacheInterface } from '../cache.interface'; @@ -25,7 +25,7 @@ export abstract class WriteBehindReadAsideCache ): (...args: TArgs) => Promise { return async (...args) => { - const cacheCtx: LogCacheContext = { cache: this.name, operation: CacheOperation.WRITE }; + const cacheCtx: CacheContext = { cache: this.name, operation: CacheOperation.WRITE }; return AlsUtilities.runWithCacheContext(cacheCtx, async () => { const sourceStart: number = performance.now(); diff --git a/src/caching/cache/read-aside/write-invalidate-read-aside-args-only.cache.ts b/src/caching/cache/read-aside/write-invalidate-read-aside-args-only.cache.ts index 4035fb4..6ded3a8 100644 --- a/src/caching/cache/read-aside/write-invalidate-read-aside-args-only.cache.ts +++ b/src/caching/cache/read-aside/write-invalidate-read-aside-args-only.cache.ts @@ -1,5 +1,5 @@ import { AlsUtilities } from '../../../context/als.utilities'; -import { LogCacheContext } from '../../../logging/log-context.model'; +import { CacheContext } from '../../../context/cache/cache.context'; import { CacheOperation } from '../cache-operation.enum'; import { CacheKeyProvider, CacheWrapWriteOptionsArgsOnly } from '../cache-options.model'; import { CacheInterface } from '../cache.interface'; @@ -25,7 +25,7 @@ export abstract class WriteInvalidateReadAsideArgsOnlyCache ): (...args: TArgs) => Promise { return async (...args) => { - const cacheCtx: LogCacheContext = { cache: this.name, operation: CacheOperation.WRITE }; + const cacheCtx: CacheContext = { cache: this.name, operation: CacheOperation.WRITE }; return AlsUtilities.runWithCacheContext(cacheCtx, async () => { const sourceStart: number = performance.now(); diff --git a/src/caching/cache/read-aside/write-invalidate-read-aside-with-result.cache.ts b/src/caching/cache/read-aside/write-invalidate-read-aside-with-result.cache.ts index 075c31b..1254e11 100644 --- a/src/caching/cache/read-aside/write-invalidate-read-aside-with-result.cache.ts +++ b/src/caching/cache/read-aside/write-invalidate-read-aside-with-result.cache.ts @@ -1,5 +1,5 @@ import { AlsUtilities } from '../../../context/als.utilities'; -import { LogCacheContext } from '../../../logging/log-context.model'; +import { CacheContext } from '../../../context/cache/cache.context'; import { CacheOperation } from '../cache-operation.enum'; import { ResultCacheKeyProvider, CacheWrapWriteOptionsWithResult } from '../cache-options.model'; import { CacheInterface } from '../cache.interface'; @@ -25,7 +25,7 @@ export abstract class WriteInvalidateReadAsideWithResultCache ): (...args: TArgs) => Promise { return async (...args) => { - const cacheCtx: LogCacheContext = { cache: this.name, operation: CacheOperation.WRITE }; + const cacheCtx: CacheContext = { cache: this.name, operation: CacheOperation.WRITE }; return AlsUtilities.runWithCacheContext(cacheCtx, async () => { const sourceStart: number = performance.now(); diff --git a/src/caching/cache/read-aside/write-through-read-aside.cache.ts b/src/caching/cache/read-aside/write-through-read-aside.cache.ts index e74994d..ad56705 100644 --- a/src/caching/cache/read-aside/write-through-read-aside.cache.ts +++ b/src/caching/cache/read-aside/write-through-read-aside.cache.ts @@ -1,5 +1,5 @@ import { AlsUtilities } from '../../../context/als.utilities'; -import { LogCacheContext } from '../../../logging/log-context.model'; +import { CacheContext } from '../../../context/cache/cache.context'; import { CacheOperation } from '../cache-operation.enum'; import { ResultCacheKeyProvider, CacheWrapWriteOptionsWithResult, CacheSetDirectOptions } from '../cache-options.model'; import { CacheInterface } from '../cache.interface'; @@ -25,7 +25,7 @@ export abstract class WriteThroughReadAsideCache ): (...args: TArgs) => Promise { return async (...args) => { - const cacheCtx: LogCacheContext = { cache: this.name, operation: CacheOperation.WRITE }; + const cacheCtx: CacheContext = { cache: this.name, operation: CacheOperation.WRITE }; return AlsUtilities.runWithCacheContext(cacheCtx, async () => { const sourceStart: number = performance.now(); diff --git a/src/caching/cache/read-through/read-through.cache.ts b/src/caching/cache/read-through/read-through.cache.ts index c3b5353..ad90a06 100644 --- a/src/caching/cache/read-through/read-through.cache.ts +++ b/src/caching/cache/read-through/read-through.cache.ts @@ -1,5 +1,5 @@ import { AlsUtilities } from '../../../context/als.utilities'; -import { LogCacheContext } from '../../../logging/log-context.model'; +import { CacheContext } from '../../../context/cache/cache.context'; import { CachedValue } from '../../store/cached-value.model'; import { BaseCache } from '../base-cache.model'; import { CacheOperation } from '../cache-operation.enum'; @@ -22,7 +22,7 @@ export abstract class ReadThroughCache< options?: CacheWrapOptions ): (...args: TArgs) => Promise { return async (...args) => { - const cacheCtx: LogCacheContext = { cache: this.name, operation: CacheOperation.WRAP }; + const cacheCtx: CacheContext = { cache: this.name, operation: CacheOperation.WRAP }; const label: Record = { cache: this.name }; return AlsUtilities.runWithCacheContext(cacheCtx, async () => { diff --git a/src/caching/cache/read-through/write-around-read-through.cache.ts b/src/caching/cache/read-through/write-around-read-through.cache.ts index 48be821..745a94c 100644 --- a/src/caching/cache/read-through/write-around-read-through.cache.ts +++ b/src/caching/cache/read-through/write-around-read-through.cache.ts @@ -1,5 +1,5 @@ import { AlsUtilities } from '../../../context/als.utilities'; -import { LogCacheContext } from '../../../logging/log-context.model'; +import { CacheContext } from '../../../context/cache/cache.context'; import { CacheOperation } from '../cache-operation.enum'; import { CacheKeyProvider, CacheWrapWriteOptionsArgsOnly } from '../cache-options.model'; import { CacheInterface } from '../cache.interface'; @@ -23,7 +23,7 @@ export abstract class WriteAroundReadThroughCache ): (...args: TArgs) => Promise { return async (...args) => { - const cacheCtx: LogCacheContext = { cache: this.name, operation: CacheOperation.WRITE }; + const cacheCtx: CacheContext = { cache: this.name, operation: CacheOperation.WRITE }; return AlsUtilities.runWithCacheContext(cacheCtx, async () => { const sourceStart: number = performance.now(); diff --git a/src/caching/cache/read-through/write-behind-read-through.cache.ts b/src/caching/cache/read-through/write-behind-read-through.cache.ts index 11c6ff7..8d1edc5 100644 --- a/src/caching/cache/read-through/write-behind-read-through.cache.ts +++ b/src/caching/cache/read-through/write-behind-read-through.cache.ts @@ -1,5 +1,5 @@ import { AlsUtilities } from '../../../context/als.utilities'; -import { LogCacheContext } from '../../../logging/log-context.model'; +import { CacheContext } from '../../../context/cache/cache.context'; import { CacheOperation } from '../cache-operation.enum'; import { ResultCacheKeyProvider, CacheWrapWriteOptionsWithResult, CacheSetDirectOptions } from '../cache-options.model'; import { CacheInterface } from '../cache.interface'; @@ -26,7 +26,7 @@ export abstract class WriteBehindReadThroughCache ): (...args: TArgs) => Promise { return async (...args) => { - const cacheCtx: LogCacheContext = { cache: this.name, operation: CacheOperation.WRITE }; + const cacheCtx: CacheContext = { cache: this.name, operation: CacheOperation.WRITE }; return AlsUtilities.runWithCacheContext(cacheCtx, async () => { const sourceStart: number = performance.now(); diff --git a/src/caching/cache/read-through/write-invalidate-read-through-args-only.cache.ts b/src/caching/cache/read-through/write-invalidate-read-through-args-only.cache.ts index 10b5a20..e4ad4e4 100644 --- a/src/caching/cache/read-through/write-invalidate-read-through-args-only.cache.ts +++ b/src/caching/cache/read-through/write-invalidate-read-through-args-only.cache.ts @@ -1,5 +1,5 @@ import { AlsUtilities } from '../../../context/als.utilities'; -import { LogCacheContext } from '../../../logging/log-context.model'; +import { CacheContext } from '../../../context/cache/cache.context'; import { CacheOperation } from '../cache-operation.enum'; import { CacheKeyProvider, CacheWrapWriteOptionsArgsOnly } from '../cache-options.model'; import { CacheInterface } from '../cache.interface'; @@ -26,7 +26,7 @@ export abstract class WriteInvalidateReadThroughArgsOnlyCache ): (...args: TArgs) => Promise { return async (...args) => { - const cacheCtx: LogCacheContext = { cache: this.name, operation: CacheOperation.WRITE }; + const cacheCtx: CacheContext = { cache: this.name, operation: CacheOperation.WRITE }; return AlsUtilities.runWithCacheContext(cacheCtx, async () => { const sourceStart: number = performance.now(); diff --git a/src/caching/cache/read-through/write-invalidate-read-through-with-result.cache.ts b/src/caching/cache/read-through/write-invalidate-read-through-with-result.cache.ts index 6fa8e5d..8248210 100644 --- a/src/caching/cache/read-through/write-invalidate-read-through-with-result.cache.ts +++ b/src/caching/cache/read-through/write-invalidate-read-through-with-result.cache.ts @@ -1,5 +1,5 @@ import { AlsUtilities } from '../../../context/als.utilities'; -import { LogCacheContext } from '../../../logging/log-context.model'; +import { CacheContext } from '../../../context/cache/cache.context'; import { CacheOperation } from '../cache-operation.enum'; import { ResultCacheKeyProvider, CacheWrapWriteOptionsWithResult } from '../cache-options.model'; import { CacheInterface } from '../cache.interface'; @@ -25,7 +25,7 @@ export abstract class WriteInvalidateReadThroughWithResultCache ): (...args: TArgs) => Promise { return async (...args) => { - const cacheCtx: LogCacheContext = { cache: this.name, operation: CacheOperation.WRITE }; + const cacheCtx: CacheContext = { cache: this.name, operation: CacheOperation.WRITE }; return AlsUtilities.runWithCacheContext(cacheCtx, async () => { const sourceStart: number = performance.now(); diff --git a/src/caching/cache/read-through/write-through-read-through.cache.ts b/src/caching/cache/read-through/write-through-read-through.cache.ts index 4737a99..c47b910 100644 --- a/src/caching/cache/read-through/write-through-read-through.cache.ts +++ b/src/caching/cache/read-through/write-through-read-through.cache.ts @@ -1,5 +1,5 @@ import { AlsUtilities } from '../../../context/als.utilities'; -import { LogCacheContext } from '../../../logging/log-context.model'; +import { CacheContext } from '../../../context/cache/cache.context'; import { CacheOperation } from '../cache-operation.enum'; import { ResultCacheKeyProvider, CacheWrapWriteOptionsWithResult, CacheSetDirectOptions } from '../cache-options.model'; import { CacheInterface } from '../cache.interface'; @@ -22,7 +22,7 @@ export abstract class WriteThroughReadThroughCache ): (...args: TArgs) => Promise { return async (...args) => { - const cacheCtx: LogCacheContext = { cache: this.name, operation: CacheOperation.WRITE }; + const cacheCtx: CacheContext = { cache: this.name, operation: CacheOperation.WRITE }; return AlsUtilities.runWithCacheContext(cacheCtx, async () => { const sourceStart: number = performance.now(); diff --git a/src/change-sets/change-set-repository.test.ts b/src/change-sets/change-set-repository.test.ts new file mode 100644 index 0000000..cda56d6 --- /dev/null +++ b/src/change-sets/change-set-repository.test.ts @@ -0,0 +1,317 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'; + +import { ChangeSetRepository, ResetChangeSetResult } from './change-set-repository'; +import { ChangeSet, CreateChangeSetData } from './models/change-set.model'; +import { Change } from './models/change.model'; +import { Roles } from '../__testing__/mocks/entities/roles.enum'; +import { createTestDataSource, defaultTestServerEntities } from '../__testing__/test-server/create-test-data-source.function'; +import { startTestServer, StartedTestServer } from '../__testing__/test-server/start-test-server.function'; +import { DefaultTestServerUserRepository } from '../__testing__/test-server/user-repository'; // adjust if needed +import { AuthServiceInterface } from '../auth/auth-service.interface'; +import { Auth } from '../auth/decorators/auth.decorator'; +import { BaseUserEntity } from '../auth/models/base-user.model'; +import { JwtAuthData } from '../auth/strategies/jwt/jwt-auth-data.model'; +import { JwtCredentials } from '../auth/strategies/jwt/jwt-credentials.model'; +import { JwtAuthStrategy } from '../auth/strategies/jwt/jwt.auth-strategy'; +import { Repository } from '../data-source/repository'; +import { InjectRepository, repositoryTokenFor } from '../di/decorators/inject-repository.decorator'; +import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; +import { inject } from '../di/inject.function'; +import { BaseEntity } from '../entity/base-entity.model'; +import { ChangeSetEntity } from './models/change-set-entity.model'; +import { ChangeSetType } from './models/change-set-type.enum'; +import { Entity } from '../entity/decorators/entity.decorator'; +import { Property } from '../entity/decorators/property.decorator'; +import { OmitClass } from '../entity/omit-class.model'; +import { PartialClass } from '../entity/partial-class.model'; +import { Body } from '../routing/decorators/body.decorator'; +import { Controller } from '../routing/decorators/controller.decorator'; +import { Delete } from '../routing/decorators/delete.decorator'; +import { Param } from '../routing/decorators/param.decorator'; +import { Patch } from '../routing/decorators/patch.decorator'; +import { Post } from '../routing/decorators/post.decorator'; +import { JsonUtilities } from '../utilities/json.utilities'; + +// ---- Test entity that extends ChangeSetEntity ---- +@Entity() +class Widget extends BaseEntity implements ChangeSetEntity { + @Property.string() + name!: string; + + @Property.number() + value!: number; + + @Property.string({ excludeFromChangeSets: true }) + internalNote!: string; + + // required by ChangeSetEntity + @Property.oneToMany({ target: () => ChangeSet, inverseSide: 'changeSetEntityId' }) // dummy, real implementation may differ + changeSets!: ChangeSet[]; +} + +class CreateWidgetDto extends OmitClass(Widget, ['changeSets', 'id']) {} + +class UpdateWidgetDto extends PartialClass(OmitClass(Widget, ['changeSets', 'id'])) {} + +@Entity() +class TestUser extends BaseUserEntity(Roles) { + @Property.string({ hash: true }) + password!: string; +} + +// ---- Controller that uses the repository ---- +@Controller('/widgets') +class WidgetController { + constructor( + @InjectRepository(Widget) + private readonly repo: ChangeSetRepository + ) {} + + @Post('/') + @Auth.isLoggedIn([JwtAuthStrategy]) + async create(@Body(CreateWidgetDto) body: CreateWidgetDto): Promise { + return await this.repo.create(body); + } + + @Patch('/:id') + @Auth.isLoggedIn([JwtAuthStrategy]) + async update(@Param.path('id') id: string, @Body(UpdateWidgetDto) body: UpdateWidgetDto): Promise { + return this.repo.updateById(id, body); + } + + @Delete('/:id') + @Auth.isLoggedIn([JwtAuthStrategy]) + async delete(@Param.path('id') id: string): Promise { + return this.repo.deleteById(id); + } + + @Post('/:id/reset/:changeSetId') + @Auth.isLoggedIn([JwtAuthStrategy]) + async resetSingle( + @Param.path('id') id: string, + @Param.path('changeSetId') changeSetId: string + ): Promise> { + const entity: Widget = await this.repo.findById(id); + return this.repo.resetSingleChangeSet(entity, changeSetId); + } + + @Post('/:id/rollback/:changeSetId') + @Auth.isLoggedIn([JwtAuthStrategy]) + async rollbackTo( + @Param.path('id') id: string, + @Param.path('changeSetId') changeSetId: string + ): Promise { + const entity: Widget = await this.repo.findById(id); + return this.repo.rollbackToChangeSet(entity, changeSetId); + } +} + +let server: StartedTestServer; +let baseUrl: string; +let accessToken: string; +let userId: string; + +let changeSetRepo: Repository; +let changeRepo: Repository; +let widgetRepo: ChangeSetRepository; + +beforeAll(async () => { + server = await startTestServer({ + dataSources: [ + createTestDataSource({ + entities: [...defaultTestServerEntities, Widget, TestUser] + }) + ], + controllers: [WidgetController] + }); + baseUrl = await server.start(); + + // Inject repository instances + changeSetRepo = inject(repositoryTokenFor(ChangeSet)); + changeRepo = inject(repositoryTokenFor(Change)); + widgetRepo = inject(repositoryTokenFor(Widget)) as ChangeSetRepository; + + // Create a user and login to get a token + const userRepo: DefaultTestServerUserRepository = inject(DefaultTestServerUserRepository); + const credentialsRepo: Repository = inject(repositoryTokenFor(JwtCredentials)); + + const testEmail: string = 'widget-test@example.com'; + const testPassword: string = 'test123'; + await userRepo.create({ email: testEmail, roles: [Roles.USER] }); + await credentialsRepo.create({ email: testEmail, password: testPassword, userId: (await userRepo.findOne({ where: { email: testEmail } }, true)).id }); + + userId = (await userRepo.findOne({ where: { email: testEmail } }, true)).id; + + const authService: AuthServiceInterface = inject(ZIBRI_DI_TOKENS.AUTH_SERVICE); + const authData: JwtAuthData = await authService.login(JwtAuthStrategy, { email: testEmail, password: testPassword }); + accessToken = authData.accessToken.value; +}, 15000); + +afterAll(async () => { + await server.shutdown(); +}); + +beforeEach(async () => { + await changeSetRepo.deleteAll({}); + await changeRepo.deleteAll({}); + await widgetRepo.deleteAll({}); +}); + +// Helper for authorized requests +async function authFetch(path: string, options: RequestInit = {}): Promise { + const res: Response = await fetch(`${baseUrl}${path}`, { + ...options, + headers: { + ...options.headers, + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + } + }); + + if (!res.ok) { + const body: unknown = await res.json(); + throw new Error(`Request "${options.method ?? 'GET'} ${path}" failed:\n${JsonUtilities.stringify(body, undefined, 4)}`); + } + + return res; +} + +describe('ChangeSetRepository behavior', () => { + describe('on create', () => { + it('creates a CREATE change set with correct changes (excluding internalNote)', async () => { + const res: Response = await authFetch('/widgets', { + method: 'POST', + body: JSON.stringify({ name: 'TestWidget', value: 42, internalNote: 'secret' }) + }); + expect(res.status).toBe(200); + const widget: Widget = await res.json() as Widget; + + const changeSets: ChangeSet[] = await changeSetRepo.findAll({ where: { changeSetEntityId: widget.id } }); + expect(changeSets).toHaveLength(1); + const cs: ChangeSet = changeSets[0]; + expect(cs.type).toBe(ChangeSetType.CREATE); + expect(cs.createdBy).toBe(userId); + + const changes: Change[] = await changeRepo.findAll({ where: { changeSetId: cs.id } }); + expect(changes).toHaveLength(3); // id, name and value. not internalNote + expect(changes.map(c => c.key).sort()).toEqual(['id', 'name', 'value']); + expect(changes.find(c => c.key === 'name')?.previousValue).toBeNull(); + expect(changes.find(c => c.key === 'name')?.newValue).toBe('TestWidget'); + }); + }); + + describe('on update', () => { + let widgetId: string; + + beforeEach(async () => { + const res: Response = await authFetch('/widgets', { + method: 'POST', + body: JSON.stringify({ name: 'Original', value: 100, internalNote: 'old-secret' }) + }); + const widget: Widget = await res.json() as Widget; + widgetId = widget.id; + // clear change sets from creation to isolate update test + await changeSetRepo.deleteAll({}); + }); + + it('creates an UPDATE change set only with changed fields', async () => { + await authFetch(`/widgets/${widgetId}`, { + method: 'PATCH', + body: JSON.stringify({ name: 'Updated', value: 100 }) // value unchanged + }); + + const changeSets: ChangeSet[] = await changeSetRepo.findAll({ where: { changeSetEntityId: widgetId } }); + expect(changeSets).toHaveLength(1); + expect(changeSets[0].type).toBe(ChangeSetType.UPDATE); + + const changes: Change[] = await changeRepo.findAll({ where: { changeSetId: changeSets[0].id } }); + expect(changes).toHaveLength(1); // only name changed + expect(changes[0].key).toBe('name'); + expect(changes[0].previousValue).toBe('Original'); + expect(changes[0].newValue).toBe('Updated'); + }); + + it('does not create a change set when no values actually change', async () => { + await authFetch(`/widgets/${widgetId}`, { + method: 'PATCH', + body: JSON.stringify({ name: 'Original', value: 100 }) + }); + + const changeSets: ChangeSet[] = await changeSetRepo.findAll({ where: { changeSetEntityId: widgetId } }); + expect(changeSets).toHaveLength(0); + }); + }); + + describe('resetSingleChangeSet', () => { + let widgetId: string; + let nameUpdateChangeSetId: string; + + beforeEach(async () => { + // Create widget, this causes + const res1: Response = await authFetch('/widgets', { + method: 'POST', + body: JSON.stringify({ name: 'Initial', value: 10, internalNote: 'test' }) + }); + const w: Widget = await res1.json() as Widget; + widgetId = w.id; + + // First update: change name + await authFetch(`/widgets/${widgetId}`, { method: 'PATCH', body: JSON.stringify({ name: 'FirstEdit' }) }); + + // Second update: change value + await authFetch(`/widgets/${widgetId}`, { method: 'PATCH', body: JSON.stringify({ value: 20 }) }); + + const sets: ChangeSet[] = await changeSetRepo.findAll({ + where: { changeSetEntityId: widgetId }, + order: { createdAt: 'ASC' }, + relations: ['changes'] + }); + // sets[0] = CREATE, sets[1] = UPDATE name, sets[2] = UPDATE value + nameUpdateChangeSetId = sets[1].id; // target the name update + }); + + it('reverts only the targeted change, preserving later changes', async () => { + await authFetch(`/widgets/${widgetId}/reset/${nameUpdateChangeSetId}`, { method: 'POST' }); + + const widget: Widget = await widgetRepo.findById(widgetId); + expect(widget.name).toBe('Initial'); // reverted + expect(widget.value).toBe(20); // preserved (later change untouched) + + // A new RESET change set was created + const allSets: ChangeSet[] = await changeSetRepo.findAll({ where: { changeSetEntityId: widgetId } }); + expect(allSets.some(s => s.type === ChangeSetType.RESET)).toBe(true); + }); + }); + + describe('rollbackToChangeSet', () => { + let widgetId: string; + let createSetId: string; + + beforeEach(async () => { + const res: Response = await authFetch('/widgets', { + method: 'POST', + body: JSON.stringify({ name: 'Base', value: 1, internalNote: 'base' }) + }); + const w: Widget = await res.json() as Widget; + widgetId = w.id; + createSetId = (await changeSetRepo.findOne({ where: { changeSetEntityId: widgetId, type: ChangeSetType.CREATE } }, true)).id; + + // Two subsequent updates + await authFetch(`/widgets/${widgetId}`, { method: 'PATCH', body: JSON.stringify({ name: 'Version2' }) }); + await authFetch(`/widgets/${widgetId}`, { method: 'PATCH', body: JSON.stringify({ value: 99 }) }); + }); + + it('rolls back all changes after the given change set', async () => { + await authFetch(`/widgets/${widgetId}/rollback/${createSetId}`, { method: 'POST' }); + + const widget: Widget = await widgetRepo.findById(widgetId); + expect(widget.name).toBe('Base'); + expect(widget.value).toBe(1); + + // Only the CREATE and the new RESET change set remain (intermediate ones deleted) + const remaining: ChangeSet[] = await changeSetRepo.findAll({ where: { changeSetEntityId: widgetId } }); + expect(remaining).toHaveLength(2); + expect(remaining.map(s => s.type)).toEqual(expect.arrayContaining([ChangeSetType.CREATE, ChangeSetType.RESET])); + }); + }); +}); \ No newline at end of file diff --git a/src/change-sets/change-set-repository.ts b/src/change-sets/change-set-repository.ts index 9a63951..e925da0 100644 --- a/src/change-sets/change-set-repository.ts +++ b/src/change-sets/change-set-repository.ts @@ -8,6 +8,7 @@ import { ChangeSetType } from './models/change-set-type.enum'; import { ChangeSet, CreateChangeSetData } from './models/change-set.model'; import { NewChange } from './models/change.model'; import { BaseUser } from '../auth/models/base-user.model'; +import { AlsUtilities } from '../context/als.utilities'; import { HttpRequestContext } from '../context/request/http-request.context'; import { WebsocketRequestContext } from '../context/request/websocket-request.context'; import { DataSourceInterface } from '../data-source/data-sources/data-source.interface'; @@ -20,9 +21,6 @@ import { UpdateAllOptions } from '../data-source/models/options/update-all-optio import { UpdateByIdOptions } from '../data-source/models/options/update-by-id-options.model'; import { Where } from '../data-source/models/where/where-filter.model'; import { Repository } from '../data-source/repository'; -import { repositoryTokenFor } from '../di/decorators/inject-repository.decorator'; -import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; -import { inject } from '../di/inject.function'; import { PropertyMetadata } from '../entity/decorators/property.decorator'; import { BadRequestError } from '../error-handling/errors/bad-request.error'; import { removeExcludeProperties } from '../global/model-registry/remove-exclude-properties.function'; @@ -64,22 +62,19 @@ export class ChangeSetRepository< */ protected readonly keysToExcludeFromChangeSets: Set = new Set(); - private readonly changeSetRepository: Repository; - private readonly authService: AuthServiceInterface; - constructor( entityClass: Newable, repo: TORepository | Repository, logger: LoggerInterface, dataSource: DataSourceInterface, beforeSave: BeforeSaveHook, - beforeReturn: BeforeReturnHook + beforeReturn: BeforeReturnHook, + private readonly authService: AuthServiceInterface, + private readonly changeSetRepository: Repository + ) { super(entityClass, repo, logger, dataSource, beforeSave, beforeReturn); - this.authService = inject(ZIBRI_DI_TOKENS.AUTH_SERVICE); - this.changeSetRepository = inject(repositoryTokenFor(ChangeSet)); - this.keysToExcludeFromChangeSets.add('changeSets'); const props: Record = MetadataUtilities.getModelProperties(entityClass); for (const [key, m] of ObjectUtilities.entries(props)) { @@ -102,14 +97,16 @@ export class ChangeSetRepository< } override async updateById(id: T['id'], data: UpdateData, options?: UpdateByIdOptions): Promise { + const original: T = await this.findById(id, options); const res: T = await super.updateById(id, data, options); - await this.createChangeSet(res, data, ChangeSetType.UPDATE, options); + await this.createChangeSet(original, data, ChangeSetType.UPDATE, options); return res; } override async updateAll(where: Where, data: UpdateData, options?: UpdateAllOptions): Promise { + const original: T[] = await this.findAll({ where, ...options }); const res: T[] = await super.updateAll(where, data, options); - await this.createAllChangeSets(res, res.map(() => data), ChangeSetType.UPDATE, options); + await this.createAllChangeSets(original, original.map(() => data), ChangeSetType.UPDATE, options); return res; } @@ -275,8 +272,7 @@ export class ChangeSetRepository< ): Promise { const changeSets: ChangeSet[] = await this.changeSetRepository.findAll({ where: { changeSetEntityId: entity.id, createdAt: { greaterThan: timestampInNs } }, - relations: ['changes'], - order: { createdAt: 'ASC' } + relations: ['changes'] }); let data: DeepPartial = {} as DeepPartial; for (const changeSet of changeSets) { @@ -455,9 +451,9 @@ export class ChangeSetRepository< * @returns The id of the currently logged in user or undefined if that didn't work. */ protected async getCreatedBy(): Promise { - const context: HttpRequestContext | WebsocketRequestContext | undefined = inject(ZIBRI_DI_TOKENS.CURRENT_REQUEST_CONTEXT); + const context: HttpRequestContext | WebsocketRequestContext | undefined = AlsUtilities.getCurrentRequestContext(); if (!context) { - throw new Error('No request in context'); + return undefined; } const user: BaseUser | undefined = await this.authService.getCurrentUser( context, @@ -477,7 +473,11 @@ export class ChangeSetRepository< ): (keyof (CreateData | UpdateData | DeepPartial))[] { const keys: (keyof (CreateData | UpdateData | DeepPartial))[] = []; for (const key in data) { - if (!this.keysToExcludeFromChangeSets.has(key as keyof T)) { + if ( + !this.keysToExcludeFromChangeSets.has(key as keyof T) + // we need to use triple equals here, setting a value to null is valid and should be tracked + && data[key as keyof (CreateData | UpdateData | DeepPartial)] !== undefined + ) { keys.push(key as keyof (CreateData | UpdateData | DeepPartial)); } } diff --git a/src/change-sets/models/change-set-entity.model.ts b/src/change-sets/models/change-set-entity.model.ts index e720dbb..2b24b59 100644 --- a/src/change-sets/models/change-set-entity.model.ts +++ b/src/change-sets/models/change-set-entity.model.ts @@ -1,5 +1,6 @@ import { ChangeSet } from './change-set.model'; import { BaseEntity } from '../../entity/base-entity.model'; +import { Property } from '../../entity/decorators/property.decorator'; import { Newable } from '../../types/newable.type'; import { MetadataUtilities } from '../../utilities/metadata.utilities'; @@ -7,12 +8,13 @@ import { MetadataUtilities } from '../../utilities/metadata.utilities'; * An entity that can be handled by the ChangeSetRepository. * Has an uuid id and a relation to all its changeSets. */ -export type ChangeSetEntity = BaseEntity & { +export class ChangeSetEntity extends BaseEntity { /** * The change sets of the entity. */ - changeSets: ChangeSet[] -}; + @Property.oneToMany({ target: () => ChangeSet, inverseSide: 'changeSetEntityId' }) + changeSets!: ChangeSet[]; +} /** * Checks whether the given class is a ChangeSetEntity class. @@ -20,5 +22,5 @@ export type ChangeSetEntity = BaseEntity & { * @returns True if it has a changeSets property of type array. */ export function isChangeSetEntityNewable(cls: Newable): cls is Newable { - return MetadataUtilities.getModelProperties(cls)['changeSets']?.type === 'array'; + return MetadataUtilities.getModelProperties(cls)['changeSets'] !== undefined; } \ No newline at end of file diff --git a/src/change-sets/models/change-set.model.ts b/src/change-sets/models/change-set.model.ts index 7c36c2f..d9449c0 100644 --- a/src/change-sets/models/change-set.model.ts +++ b/src/change-sets/models/change-set.model.ts @@ -10,7 +10,7 @@ import { OmitStrict } from '../../types/omit-strict.type'; * A single change set. * Gets automatically created for configured entities whenever they are changed. */ -@Entity() +@Entity({ defaultOrder: { createdAt: 'ASC' }, defaultRelations: { changes: true } }) export class ChangeSet extends BaseEntity { /** * Whether this change set was initialized on creating, updating or deleting the entity. diff --git a/src/change-sets/models/change.model.ts b/src/change-sets/models/change.model.ts index 210c9b8..4f28841 100644 --- a/src/change-sets/models/change.model.ts +++ b/src/change-sets/models/change.model.ts @@ -27,8 +27,13 @@ export class Change extends BaseEntity { /** * The change set that this change belongs to. */ - @Property.manyToOne({ target: () => ChangeSet, inverseSide: 'changes' }) + @Property.manyToOne({ target: () => ChangeSet, inverseSide: 'changes', joinColumn: 'changeSetId' }) changeSet!: ChangeSet; + /** + * The id of the change set that this change belongs to. + */ + @Property.string({ format: 'uuid' }) + changeSetId!: string; } /** @@ -39,4 +44,4 @@ export type CreateChangeData = OmitStrict; /** * A new change. */ -export type NewChange = OmitStrict; \ No newline at end of file +export type NewChange = OmitStrict; \ No newline at end of file diff --git a/src/change-sets/models/soft-delete-entity.model.ts b/src/change-sets/models/soft-delete-entity.model.ts index 2a9607e..56f6b98 100644 --- a/src/change-sets/models/soft-delete-entity.model.ts +++ b/src/change-sets/models/soft-delete-entity.model.ts @@ -1,4 +1,5 @@ import { ChangeSetEntity, isChangeSetEntityNewable } from './change-set-entity.model'; +import { Property } from '../../entity/decorators/property.decorator'; import { Newable } from '../../types/newable.type'; import { MetadataUtilities } from '../../utilities/metadata.utilities'; @@ -6,12 +7,13 @@ import { MetadataUtilities } from '../../utilities/metadata.utilities'; * An entity that can be handled by the SoftDeleteRepository. * Has an uuid id, a relation to all its changeSets and a flag that determines whether it is "soft deleted" or not. */ -export type SoftDeleteEntity = ChangeSetEntity & { +export class SoftDeleteEntity extends ChangeSetEntity { /** * Whether or not the entity is soft deleted. */ - deleted: boolean -}; + @Property.boolean({ default: false }) + deleted!: boolean; +} /** * Checks whether the given class is a SoftDeleteEntity class. diff --git a/src/change-sets/soft-delete-repository.test.ts b/src/change-sets/soft-delete-repository.test.ts new file mode 100644 index 0000000..95898d1 --- /dev/null +++ b/src/change-sets/soft-delete-repository.test.ts @@ -0,0 +1,326 @@ +import assert from 'node:assert'; + +import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'; + +import { ChangeSetType } from './models/change-set-type.enum'; +import { ChangeSet, CreateChangeSetData } from './models/change-set.model'; +import { Change } from './models/change.model'; +import { SoftDeleteEntity } from './models/soft-delete-entity.model'; +import { SoftDeleteWhere } from './models/soft-delete-where.model'; +import { SoftDeleteRepository } from './soft-delete-repository'; +import { Roles } from '../__testing__/mocks/entities/roles.enum'; +import { createTestDataSource, defaultTestServerEntities } from '../__testing__/test-server/create-test-data-source.function'; +import { startTestServer, StartedTestServer } from '../__testing__/test-server/start-test-server.function'; +import { DefaultTestServerUserRepository } from '../__testing__/test-server/user-repository'; +import { AuthServiceInterface } from '../auth/auth-service.interface'; +import { Auth } from '../auth/decorators/auth.decorator'; +import { BaseUserEntity } from '../auth/models/base-user.model'; +import { JwtAuthData } from '../auth/strategies/jwt/jwt-auth-data.model'; +import { JwtCredentials } from '../auth/strategies/jwt/jwt-credentials.model'; +import { JwtAuthStrategy } from '../auth/strategies/jwt/jwt.auth-strategy'; +import { Repository } from '../data-source/repository'; +import { InjectRepository, repositoryTokenFor } from '../di/decorators/inject-repository.decorator'; +import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; +import { inject } from '../di/inject.function'; +import { Entity } from '../entity/decorators/entity.decorator'; +import { Property } from '../entity/decorators/property.decorator'; +import { OmitClass } from '../entity/omit-class.model'; +import { PartialClass } from '../entity/partial-class.model'; +import { Body } from '../routing/decorators/body.decorator'; +import { Controller } from '../routing/decorators/controller.decorator'; +import { Delete } from '../routing/decorators/delete.decorator'; +import { Get } from '../routing/decorators/get.decorator'; +import { Param } from '../routing/decorators/param.decorator'; +import { Patch } from '../routing/decorators/patch.decorator'; +import { Post } from '../routing/decorators/post.decorator'; +import { JsonUtilities } from '../utilities/json.utilities'; + +// ---------- Test entity ---------- +@Entity() +class Task extends SoftDeleteEntity { + @Property.string() + title!: string; +} + +class CreateTaskDto extends OmitClass(Task, ['changeSets', 'id', 'deleted']) {} +class UpdateTaskDto extends PartialClass(OmitClass(Task, ['changeSets', 'id', 'deleted'])) {} + +@Entity() +class TestUser extends BaseUserEntity(Roles) { + @Property.string({ hash: true }) + password!: string; +} + +// ---------- Controller ---------- +@Controller('/tasks') +class TaskController { + constructor( + @InjectRepository(Task) + private readonly repo: SoftDeleteRepository + ) {} + + @Post('/') + @Auth.isLoggedIn([JwtAuthStrategy]) + async create(@Body(CreateTaskDto) body: CreateTaskDto): Promise { + return this.repo.create(body); + } + + @Patch('/:id') + @Auth.isLoggedIn([JwtAuthStrategy]) + async update( + @Param.path('id') + id: string, + @Body(UpdateTaskDto) + body: UpdateTaskDto, + @Param.query('withDeleted', { type: 'boolean', required: false }) + withDeleted: boolean = false + ): Promise { + return this.repo.updateById(id, body, { withDeleted }); + } + + @Delete('/:id') + @Auth.isLoggedIn([JwtAuthStrategy]) + async softDelete(@Param.path('id') id: string): Promise { + return this.repo.deleteById(id); + } + + @Delete('/:id/hard') + @Auth.isLoggedIn([JwtAuthStrategy]) + async hardDelete(@Param.path('id') id: string): Promise { + return this.repo.deleteById(id, { hardDelete: true }); + } + + @Get('/') + @Auth.isLoggedIn([JwtAuthStrategy]) + async getAll(): Promise { + return this.repo.findAll(); + } + + @Get('/with-deleted') + @Auth.isLoggedIn([JwtAuthStrategy]) + async getAllWithDeleted(): Promise { + return this.repo.findAll({ withDeleted: true }); + } + + @Get('/:id') + @Auth.isLoggedIn([JwtAuthStrategy]) + async getById(@Param.path('id') id: string): Promise { + return this.repo.findById(id); + } +} + +// ---------- Test setup ---------- +let server: StartedTestServer; +let baseUrl: string; +let accessToken: string; + +let changeSetRepo: Repository; +let changeRepo: Repository; +let taskRepo: SoftDeleteRepository; + +beforeAll(async () => { + server = await startTestServer({ + dataSources: [ + createTestDataSource({ + entities: [...defaultTestServerEntities, Task, TestUser] + }) + ], + controllers: [TaskController] + }); + baseUrl = await server.start(); + + changeSetRepo = inject(repositoryTokenFor(ChangeSet)); + changeRepo = inject(repositoryTokenFor(Change)); + taskRepo = inject(repositoryTokenFor(Task)) as SoftDeleteRepository; + + const userRepo: DefaultTestServerUserRepository = inject(DefaultTestServerUserRepository); + const credentialsRepo: Repository = inject(repositoryTokenFor(JwtCredentials)); + + const testEmail: string = 'task-test@example.com'; + const testPassword: string = 'test123'; + await userRepo.create({ email: testEmail, roles: [Roles.USER] }); + await credentialsRepo.create({ + email: testEmail, + password: testPassword, + userId: (await userRepo.findOne({ where: { email: testEmail } }, true)).id + }); + + const authService: AuthServiceInterface = inject(ZIBRI_DI_TOKENS.AUTH_SERVICE); + const authData: JwtAuthData = await authService.login(JwtAuthStrategy, { + email: testEmail, + password: testPassword + }); + accessToken = authData.accessToken.value; +}, 15000); + +afterAll(async () => { + await server.shutdown(); +}); + +beforeEach(async () => { + await changeSetRepo.deleteAll({}); + await changeRepo.deleteAll({}); + await taskRepo.deleteAll({}, { hardDelete: true }); +}); + +async function authFetch(path: string, options: RequestInit = {}): Promise { + const res: Response = await fetch(`${baseUrl}${path}`, { + ...options, + headers: { + ...options.headers, + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + } + }); + if (!res.ok) { + const body: unknown = await res.json(); + throw new Error(`Request "${options.method ?? 'GET'} ${path}" failed:\n${JsonUtilities.stringify(body, undefined, 4)}`); + } + return res; +} + +// ---------- Tests ---------- +describe('SoftDeleteRepository behavior', () => { + it('created task is not deleted', async () => { + const res: Response = await authFetch('/tasks', { + method: 'POST', + body: JSON.stringify({ title: 'Buy milk' }) + }); + const task: Task = await res.json() as Task; + expect(task.deleted).toBe(false); + }); + + describe('soft delete', () => { + let taskId: string; + + beforeEach(async () => { + const res: Response = await authFetch('/tasks', { + method: 'POST', + body: JSON.stringify({ title: 'Temporary' }) + }); + const task: Task = await res.json() as Task; + taskId = task.id; + }); + + it('marks the entity as deleted and creates a DELETE change set', async () => { + await authFetch(`/tasks/${taskId}`, { method: 'DELETE' }); + + const tasks: Task[] = await taskRepo.findAll({ withDeleted: true }); + const task: Task | undefined = tasks.find(t => t.id === taskId); + assert(task); + expect(task.deleted).toBe(true); + + const changeSets: ChangeSet[] = await changeSetRepo.findAll({ where: { changeSetEntityId: taskId } }); + expect(changeSets.some(cs => cs.type === ChangeSetType.DELETE)).toBe(true); + }); + + it('findById throws NotFoundError for soft-deleted task', async () => { + await authFetch(`/tasks/${taskId}`, { method: 'DELETE' }); + await expect(taskRepo.findById(taskId)).rejects.toThrow('Could not find'); + }); + + it('findById with withDeleted=true returns the task', async () => { + await authFetch(`/tasks/${taskId}`, { method: 'DELETE' }); + const task: Task = await taskRepo.findById(taskId, { withDeleted: true }); + expect(task.deleted).toBe(true); + }); + + it('soft-deleted task can be updated if withDeleted is passed', async () => { + await authFetch(`/tasks/${taskId}`, { method: 'DELETE' }); + const params: URLSearchParams = new URLSearchParams({ withDeleted: 'true' }); + // Update using the controller, which uses updateById with options that allow withDeleted + await authFetch(`/tasks/${taskId}?${params.toString()}`, { + method: 'PATCH', + body: JSON.stringify({ title: 'Still here' }) + }); + }); + }); + + describe('hard delete', () => { + let taskId: string; + + beforeEach(async () => { + const res: Response = await authFetch('/tasks', { + method: 'POST', + body: JSON.stringify({ title: 'To be removed' }) + }); + const task: Task = await res.json() as Task; + taskId = task.id; + }); + + it('removes the entity from the database', async () => { + await authFetch(`/tasks/${taskId}/hard`, { method: 'DELETE' }); + await expect(taskRepo.findById(taskId)).rejects.toThrow('Could not find'); + }); + }); + + describe('findAll queries', () => { + let activeId: string; + let deletedId: string; + + beforeEach(async () => { + const res1: Response = await authFetch('/tasks', { + method: 'POST', + body: JSON.stringify({ title: 'Active' }) + }); + activeId = (await res1.json() as Task).id; + + const res2: Response = await authFetch('/tasks', { + method: 'POST', + body: JSON.stringify({ title: 'Removed' }) + }); + deletedId = (await res2.json() as Task).id; + await authFetch(`/tasks/${deletedId}`, { method: 'DELETE' }); + }); + + it('default findAll returns only non-deleted entities', async () => { + const res: Response = await authFetch('/tasks'); + const tasks: Task[] = await res.json() as Task[]; + expect(tasks.map(t => t.id)).toEqual([activeId]); + }); + + it('findAll with withDeleted returns all entities', async () => { + const res: Response = await authFetch('/tasks/with-deleted'); + const tasks: Task[] = await res.json() as Task[]; + expect(tasks.map(t => t.id).sort()).toEqual([activeId, deletedId].sort()); + }); + }); + + describe('deleteAll', () => { + let id1: string, id2: string; + + beforeEach(async () => { + const res1: Response = await authFetch('/tasks', { + method: 'POST', + body: JSON.stringify({ title: 'Task 1' }) + }); + id1 = (await res1.json() as Task).id; + + const res2: Response = await authFetch('/tasks', { + method: 'POST', + body: JSON.stringify({ title: 'Task 2' }) + }); + id2 = (await res2.json() as Task).id; + }); + + it('soft deletes all matching entities', async () => { + const where: SoftDeleteWhere = { title: 'Task 1' }; + await taskRepo.deleteAll(where); // soft delete by default + + const allTasks: Task[] = await taskRepo.findAll({ withDeleted: true }); + const task1: Task | undefined = allTasks.find(t => t.id === id1); + const task2: Task | undefined = allTasks.find(t => t.id === id2); + assert(task1 && task2); + expect(task1.deleted).toBe(true); + expect(task2.deleted).toBe(false); + }); + + it('hard deletes all matching entities', async () => { + const where: SoftDeleteWhere = { title: 'Task 2' }; + await taskRepo.deleteAll(where, { hardDelete: true }); + + const allTasks: Task[] = await taskRepo.findAll({ withDeleted: true }); + expect(allTasks.find(t => t.id === id2)).toBeUndefined(); + }); + }); +}); \ No newline at end of file diff --git a/src/change-sets/soft-delete-repository.ts b/src/change-sets/soft-delete-repository.ts index a372c17..cb6558d 100644 --- a/src/change-sets/soft-delete-repository.ts +++ b/src/change-sets/soft-delete-repository.ts @@ -16,12 +16,14 @@ import { SoftDeleteFindOneOptions } from './models/soft-delete-find-one-options. import { SoftDeleteUpdateAllOptions } from './models/soft-delete-update-all-options.model'; import { SoftDeleteUpdateByIdOptions } from './models/soft-delete-update-by-id-options.model'; import { SoftDeleteWhere } from './models/soft-delete-where.model'; -import { DataSourceInterface } from '../data-source/data-sources/data-source.interface'; import { BeforeReturnHook } from '../data-source/hooks/before-return'; import { BeforeSaveHook } from '../data-source/hooks/before-save'; import { Where } from '../data-source/models/where/where-filter.model'; import { NotFoundError } from '../error-handling/errors/not-found.error'; import { LoggerInterface } from '../logging/logger.interface'; +import { ChangeSet, CreateChangeSetData } from './models/change-set.model'; +import { AuthServiceInterface } from '../auth/auth-service.interface'; +import { DataSourceInterface } from '../data-source/data-sources/data-source.interface'; /** * Options for deleting a soft delete entity by its id. @@ -36,7 +38,7 @@ export type SoftDeleteByIdOptions = DeleteByIdOptions & { /** * Options for deleting multiple soft delete entities. */ -export type SoftDeleteAllOptions = DeleteAllOptions & { +export type SoftDeleteAllOptions = DeleteAllOptions & { /** * Whether or not to "actual" delete entities, rather than marking them as deleted. */ @@ -54,17 +56,17 @@ export class SoftDeleteRepository< > extends ChangeSetRepository { - protected override readonly keysToExcludeFromChangeSets: Set = new Set(); - constructor( entityClass: Newable, repo: TORepository | Repository, logger: LoggerInterface, dataSource: DataSourceInterface, beforeSave: BeforeSaveHook, - beforeReturn: BeforeReturnHook + beforeReturn: BeforeReturnHook, + authService: AuthServiceInterface, + changeSetRepository: Repository ) { - super(entityClass, repo, logger, dataSource, beforeSave, beforeReturn); + super(entityClass, repo, logger, dataSource, beforeSave, beforeReturn, authService, changeSetRepository); this.keysToExcludeFromChangeSets.add('deleted'); } @@ -130,7 +132,19 @@ export class SoftDeleteRepository< // eslint-disable-next-line jsdoc/require-jsdoc async updateById(id: T['id'], data: UpdateData, options?: SoftDeleteUpdateByIdOptions): Promise { await this.findById(id, options); - return await super.updateById(id, data, options); + try { + // The base updateById saves and then calls this.findById again. + // If the update set deleted = true, that final findById will throw NotFoundError + // because the soft‑delete guard now sees the entity as deleted. + return await super.updateById(id, data, options); + } + catch (error) { + // Only catch the case where the update itself caused the soft‑delete + if (error instanceof NotFoundError && data.deleted === true) { + return this.findById(id, { ...options, withDeleted: true }); + } + throw error; + } } // eslint-disable-next-line jsdoc/require-jsdoc @@ -154,9 +168,14 @@ export class SoftDeleteRepository< } // eslint-disable-next-line jsdoc/require-jsdoc - async deleteAll(where: SoftDeleteWhere, options?: SoftDeleteAllOptions | undefined): Promise { + async deleteAll(where: SoftDeleteWhere, options?: SoftDeleteAllOptions | undefined): Promise { if (options?.hardDelete === true) { - return await super.deleteAll(this.softDeleteWhereToWhere(true, where), options); + // eslint-disable-next-line jsdoc/require-jsdoc + const finalOptions: DeleteAllOptions & { withDeleted?: boolean } = { + ...options, + withDeleted: true + }; + return await super.deleteAll(this.softDeleteWhereToWhere(true, where), finalOptions); } const res: T[] = await this.updateAllWithoutChangeSet( this.softDeleteWhereToWhere(false, where), @@ -169,7 +188,7 @@ export class SoftDeleteRepository< private softDeleteWhereToWhere(withDeleted: boolean = false, where?: SoftDeleteWhere): Where { if (where == undefined) { - return { deleted: false } as Where; + return { deleted: withDeleted ? undefined : false } as Where; } if (Array.isArray(where)) { return where.map(f => ({ ...f, deleted: withDeleted ? undefined : false })); diff --git a/src/context/als.utilities.ts b/src/context/als.utilities.ts index 528ceda..87b501c 100644 --- a/src/context/als.utilities.ts +++ b/src/context/als.utilities.ts @@ -1,8 +1,8 @@ import { AsyncLocalStorage } from 'node:async_hooks'; +import { CacheContext } from './cache/cache.context'; import { HttpRequestContext } from './request/http-request.context'; import { WebsocketRequestContext } from './request/websocket-request.context'; -import { LogCacheContext } from '../logging/log-context.model'; /** * Encapsulates functionality around async local storage. @@ -10,7 +10,7 @@ import { LogCacheContext } from '../logging/log-context.model'; export abstract class AlsUtilities { private static readonly httpRequest: AsyncLocalStorage = new AsyncLocalStorage(); private static readonly websocketRequest: AsyncLocalStorage = new AsyncLocalStorage(); - private static readonly cacheContext: AsyncLocalStorage = new AsyncLocalStorage(); + private static readonly cacheContext: AsyncLocalStorage = new AsyncLocalStorage(); /** * Resolves the currently active request context from the async local storage. @@ -67,8 +67,8 @@ export abstract class AlsUtilities { * @param fn - The function to run. * @returns The result of the function. */ - static runWithCacheContext(context: LogCacheContext, fn: () => T): T { - const existing: LogCacheContext[] = this.getCurrentCacheContext() ?? []; + static runWithCacheContext(context: CacheContext, fn: () => T): T { + const existing: CacheContext[] = this.getCurrentCacheContext() ?? []; // New array — doesn't mutate the parent scope's array return this.cacheContext.run([...existing, context], fn); } @@ -78,7 +78,7 @@ export abstract class AlsUtilities { * @returns The currently active cache context. * @throws When the async local storage store has not been initialized yet. */ - static getCurrentCacheContext(): LogCacheContext[] | undefined { + static getCurrentCacheContext(): CacheContext[] | undefined { return this.cacheContext.getStore(); } } \ No newline at end of file diff --git a/src/context/cache/cache.context.ts b/src/context/cache/cache.context.ts new file mode 100644 index 0000000..15852ac --- /dev/null +++ b/src/context/cache/cache.context.ts @@ -0,0 +1,33 @@ +import { CacheOperation } from '../../caching/cache/cache-operation.enum'; +import { Property } from '../../entity/decorators/property.decorator'; + +/** + * Context information about a cache that the program is currently using. + */ +export class CacheContext { + /** + * The name of the cache. + */ + @Property.string() + cache!: string; + /** + * The cache operation currently running. + */ + @Property.string({ enum: CacheOperation }) + operation!: CacheOperation; + /** + * The key of the value in the cache. + */ + @Property.unknown({ required: false }) + key?: unknown; + /** + * Whether or not the cache has been hit. + */ + @Property.boolean({ required: false }) + hit?: boolean; + /** + * The duration that the original function took. + */ + @Property.number({ required: false }) + durationInMs?: number; +} \ No newline at end of file diff --git a/src/cron/cron.test.ts b/src/cron/cron.test.ts new file mode 100644 index 0000000..e580d90 --- /dev/null +++ b/src/cron/cron.test.ts @@ -0,0 +1,421 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; + +import { CronExpression, CronExpressionString } from './cron-expression.utilities'; +import { CronJobEntity, CreateCronJobEntityData } from './cron-job-entity.model'; +import { CronJob, InitialCronConfig } from './cron-job.model'; +import { CronService } from './cron.service'; +import { noOp } from '../__testing__/constants'; +import { createTestDataSource, defaultTestServerEntities } from '../__testing__/test-server/create-test-data-source.function'; +import { startTestServer, StartedTestServer } from '../__testing__/test-server/start-test-server.function'; +import { Repository } from '../data-source/repository'; +import { repositoryTokenFor } from '../di/decorators/inject-repository.decorator'; +import { inject } from '../di/inject.function'; + +// ---------- Helpers ---------- + +function makeCronJob(config: Partial & { onTickImpl?: () => void | Promise }): CronJob { + const { onTickImpl, ...cronConfig } = config; + class TestCronJob extends CronJob { + readonly initialConfig: InitialCronConfig = { + name: 'test-cron-job', + cron: CronExpression.every(1, 'minutes').build(), + runOnInit: false, + stopOnError: true, + syncToDataSource: true, + ...cronConfig + }; + + override onTick(): void | Promise { + return onTickImpl?.(); + } + } + return new TestCronJob(); +} + +// ---------- Test setup ---------- + +let server: StartedTestServer; +let cronService: CronService; +let cronJobRepo: Repository; + +beforeAll(async () => { + server = await startTestServer({ + dataSources: [ + createTestDataSource({ + entities: [...defaultTestServerEntities, CronJobEntity] + }) + ], + controllers: [], + cronJobs: [] + }); + await server.start(); + cronService = inject(CronService); + cronJobRepo = inject(repositoryTokenFor(CronJobEntity)); +}, 15000); + +afterAll(async () => { + await server.shutdown(); +}); + +beforeEach(async () => { + await cronJobRepo.deleteAll({}); + // Stop and clear all cron jobs between tests + for (const job of cronService.cronJobs) { + await job.shutdown(); + } + cronService.cronJobs.length = 0; +}); + +// ---------- Tests ---------- + +describe('CronJob initialization', () => { + it('creates a new entity in the DB when none exists', async () => { + const job: CronJob = makeCronJob({ name: 'init-test', syncToDataSource: true, runOnInit: false }); + await cronService.schedule(job); + + const entity: CronJobEntity | undefined = await cronJobRepo.findOne({ where: { name: 'init-test' } }, false); + expect(entity).toBeDefined(); + expect(entity?.name).toBe('init-test'); + expect(entity?.active).toBe(true); + }); + + it('reuses an existing entity from the DB', async () => { + // Pre-create entity with active: false + await cronJobRepo.create({ + name: 'reuse-test', + cron: CronExpression.every(1, 'minutes').build(), + active: false, + runOnInit: false, + stopOnError: true, + lastRun: undefined, + errorMessage: undefined + }); + + const job: CronJob = makeCronJob({ name: 'reuse-test', syncToDataSource: true, runOnInit: false }); + await cronService.schedule(job); + + // Should have picked up active: false from DB, not defaulted to true + expect(job.active).toBe(false); + + // Should not have created a duplicate + const entities: CronJobEntity[] = await cronJobRepo.findAll({ where: { name: 'reuse-test' } }); + expect(entities).toHaveLength(1); + }); + + it('calls onTick immediately when runOnInit is true', async () => { + // eslint-disable-next-line typescript/typedef + const onTickImpl = jest.fn(noOp); + const job: CronJob = makeCronJob({ name: 'run-on-init', runOnInit: true, syncToDataSource: false, onTickImpl }); + await cronService.schedule(job); + + expect(onTickImpl).toHaveBeenCalledTimes(1); + }); + + it('does not call onTick on init when runOnInit is false', async () => { + // eslint-disable-next-line typescript/typedef + const onTickImpl = jest.fn(noOp); + const job: CronJob = makeCronJob({ name: 'no-run-on-init', runOnInit: false, syncToDataSource: false, onTickImpl }); + await cronService.schedule(job); + + expect(onTickImpl).not.toHaveBeenCalled(); + }); + + it('throws if initialized twice', async () => { + const job: CronJob = makeCronJob({ name: 'double-init', syncToDataSource: false, runOnInit: false }); + await cronService.schedule(job); + + await expect( + cronService.schedule(job) + ).rejects.toThrow('already been initialized'); + }); +}); + +describe('CronJob tick behavior', () => { + it('calls onTick and updates lastRun', async () => { + // eslint-disable-next-line typescript/typedef + const onTickImpl = jest.fn(noOp); + const job: CronJob = makeCronJob({ name: 'tick-test', syncToDataSource: true, runOnInit: false, onTickImpl }); + await cronService.schedule(job); + + await job.runOnTick(); + + expect(onTickImpl).toHaveBeenCalledTimes(1); + expect(job['entity']?.lastRun).toBeInstanceOf(Date); + }); + + it('persists lastRun to DB when syncToDataSource is true', async () => { + const job: CronJob = makeCronJob({ name: 'tick-persist', syncToDataSource: true, runOnInit: false }); + await cronService.schedule(job); + + await job.runOnTick(); + + const entity: CronJobEntity | undefined = await cronJobRepo.findOne({ where: { name: 'tick-persist' } }, false); + expect(entity?.lastRun).toBeInstanceOf(Date); + }); + + it('does not persist lastRun to DB when syncToDataSource is false', async () => { + const job: CronJob = makeCronJob({ name: 'tick-no-persist', syncToDataSource: false, runOnInit: false }); + await cronService.schedule(job); + + await job.runOnTick(); + + // No entity should exist in the DB + const entity: CronJobEntity | undefined = await cronJobRepo.findOne({ where: { name: 'tick-no-persist' } }, false); + expect(entity).toBeUndefined(); + }); +}); + +describe('CronJob error behavior', () => { + it('sets errorMessage when onTick throws', async () => { + const job: CronJob = makeCronJob({ + name: 'error-test', + syncToDataSource: false, + runOnInit: false, + stopOnError: false, + onTickImpl: () => { + throw new Error('boom'); + } + }); + await cronService.schedule(job); + + await job.runOnTick(); + + expect(job['entity']?.errorMessage).toContain('boom'); + }); + + it('disables the job when stopOnError is true and onTick throws', async () => { + const job: CronJob = makeCronJob({ + name: 'stop-on-error', + syncToDataSource: false, + runOnInit: false, + stopOnError: true, + onTickImpl: () => { + throw new Error('fatal'); + } + }); + await cronService.schedule(job); + + await job.runOnTick(); + + expect(job.active).toBe(false); + }); + + it('does not disable the job when stopOnError is false and onTick throws', async () => { + const job: CronJob = makeCronJob({ + name: 'no-stop-on-error', + syncToDataSource: false, + runOnInit: false, + stopOnError: false, + onTickImpl: () => { + throw new Error('non-fatal'); + } + }); + await cronService.schedule(job); + + await job.runOnTick(); + + expect(job.active).toBe(true); + }); + + it('persists errorMessage to DB when syncToDataSource is true', async () => { + const job: CronJob = makeCronJob({ + name: 'error-persist', + syncToDataSource: true, + runOnInit: false, + stopOnError: false, + onTickImpl: () => { + throw new Error('db-error'); + } + }); + await cronService.schedule(job); + + await job.runOnTick(); + + const entity: CronJobEntity | undefined = await cronJobRepo.findOne({ where: { name: 'error-persist' } }, false); + expect(entity?.errorMessage).toContain('db-error'); + }); +}); + +describe('CronJob enable/disable', () => { + it('disable sets active to false', async () => { + const job: CronJob = makeCronJob({ name: 'disable-test', syncToDataSource: false, runOnInit: false }); + await cronService.schedule(job); + + await job.disable(); + + expect(job.active).toBe(false); + }); + + it('enable sets active to true after disable', async () => { + const job: CronJob = makeCronJob({ name: 'enable-test', syncToDataSource: false, runOnInit: false }); + await cronService.schedule(job); + + await job.disable(); + await job.enable(); + + expect(job.active).toBe(true); + }); + + it('persists active state to DB on disable', async () => { + const job: CronJob = makeCronJob({ name: 'disable-persist', syncToDataSource: true, runOnInit: false }); + await cronService.schedule(job); + + await job.disable(); + + const entity: CronJobEntity | undefined = await cronJobRepo.findOne({ where: { name: 'disable-persist' } }, false); + expect(entity?.active).toBe(false); + }); + + it('persists active state to DB on enable', async () => { + const job: CronJob = makeCronJob({ name: 'enable-persist', syncToDataSource: true, runOnInit: false }); + await cronService.schedule(job); + + await job.disable(); + await job.enable(); + + const entity: CronJobEntity | undefined = await cronJobRepo.findOne({ where: { name: 'enable-persist' } }, false); + expect(entity?.active).toBe(true); + }); +}); + +describe('CronService', () => { + it('schedule adds the job to cronJobs', async () => { + const job: CronJob = makeCronJob({ name: 'schedule-test', syncToDataSource: false, runOnInit: false }); + await cronService.schedule(job); + + expect(cronService.cronJobs).toContain(job); + }); + + it('enable enables a job by name', async () => { + const job: CronJob = makeCronJob({ name: 'service-enable', syncToDataSource: false, runOnInit: false }); + await cronService.schedule(job); + await job.disable(); + + await cronService.enable('service-enable'); + + expect(job.active).toBe(true); + }); + + it('disable disables a job by name', async () => { + const job: CronJob = makeCronJob({ name: 'service-disable', syncToDataSource: false, runOnInit: false }); + await cronService.schedule(job); + + await cronService.disable('service-disable'); + + expect(job.active).toBe(false); + }); + + it('enable throws when job is not found', async () => { + await expect(cronService.enable('nonexistent')).rejects.toThrow('Could not find cron job with name nonexistent'); + }); + + it('disable throws when job is not found', async () => { + await expect(cronService.disable('nonexistent')).rejects.toThrow('Could not find cron job with name nonexistent'); + }); + + it('changeCron throws when job is not found', async () => { + await expect( + cronService.changeCron('nonexistent', CronExpression.every(5, 'minutes').build()) + ).rejects.toThrow('Could not find cron job with name nonexistent'); + }); + + it('changeCron updates the cron expression', async () => { + const job: CronJob = makeCronJob({ name: 'change-cron', syncToDataSource: true, runOnInit: false }); + await cronService.schedule(job); + + const newCron: CronExpressionString = CronExpression.every(10, 'minutes').build(); + await cronService.changeCron('change-cron', newCron); + + const entity: CronJobEntity | undefined = await cronJobRepo.findOne({ where: { name: 'change-cron' } }, false); + expect(entity?.cron).toBe(newCron); + }); + + it('update throws when job is not found', async () => { + await expect(cronService.update('nonexistent', { name: 'other' })).rejects.toThrow('Could not find cron job with name nonexistent'); + }); + + it('update applies data to the job', async () => { + const job: CronJob = makeCronJob({ name: 'update-test', syncToDataSource: true, runOnInit: false }); + await cronService.schedule(job); + + await cronService.update('update-test', { runOnInit: false, stopOnError: false }); + + const entity: CronJobEntity | undefined = await cronJobRepo.findOne({ where: { name: 'update-test' } }, false); + expect(entity?.stopOnError).toBe(false); + }); + + it('update throws when renaming to an already-used name', async () => { + const job1: CronJob = makeCronJob({ name: 'taken-name', syncToDataSource: false, runOnInit: false }); + const job2: CronJob = makeCronJob({ name: 'rename-source', syncToDataSource: false, runOnInit: false }); + await cronService.schedule(job1); + await cronService.schedule(job2); + + await expect(cronService.update('rename-source', { name: 'taken-name' })).rejects.toThrow(); + }); +}); + +describe('CronExpression', () => { + it('every(5, minutes) produces correct expression', () => { + expect(CronExpression.every(5, 'minutes').build()).toBe('* */5 * * * *'); + }); + + it('every(1, minutes) produces wildcard, not */1', () => { + expect(CronExpression.every(1, 'minutes').build()).toBe('* * * * * *'); + }); + + it('daily() defaults to midnight', () => { + expect(CronExpression.daily().build()).toBe('0 0 0 * * *'); + }); + + it('daily().at(9, hours) produces correct expression', () => { + expect(CronExpression.daily().at(9, 'hours') + .build()).toBe('0 0 9 * * *'); + }); + + it('daily().at(9, hours).at(30, minutes) produces correct expression', () => { + expect(CronExpression.daily().at(9, 'hours') + .at(30, 'minutes') + .build()).toBe('0 30 9 * * *'); + }); + + it('weekly() defaults to Sunday midnight', () => { + expect(CronExpression.weekly().build()).toBe('0 0 0 * * 0'); + }); + + it('weekly().on(Monday) produces correct expression', () => { + expect(CronExpression.weekly().on('Monday') + .build()).toBe('0 0 0 * * 1'); + }); + + it('daily().on(Monday, Wednesday, Friday) produces correct expression', () => { + expect(CronExpression.daily().on('Monday', 'Wednesday', 'Friday') + .build()).toBe('0 0 0 * * 1,3,5'); + }); + + it('monthly() defaults to 1st at midnight', () => { + expect(CronExpression.monthly().build()).toBe('0 0 0 1 * *'); + }); + + it('monthly().in(March, June) produces correct expression', () => { + expect(CronExpression.monthly().in('March', 'June') + .build()).toBe('0 0 0 1 3,6 *'); + }); + + it('between() restricts to a range', () => { + expect(CronExpression.every(1, 'minutes').between(9, 17, 'hours') + .build()).toBe('* * 9-17 * * *'); + }); + + it('between() throws when from >= to', () => { + expect(() => CronExpression.every(1, 'minutes').between(17, 9, 'hours')).toThrow(RangeError); + }); + + it('fromString() round-trips a valid expression', () => { + const expr: string = '0 30 9 * * 1'; + expect(CronExpression.fromString(expr)).toBe(expr); + }); + + it('fromString() throws for wrong field count', () => { + expect(() => CronExpression.fromString('* * * *')).toThrow('Expected 6 fields'); + }); +}); \ No newline at end of file diff --git a/src/data-source/array-where-filter.test.ts b/src/data-source/array-where-filter.test.ts new file mode 100644 index 0000000..701577d --- /dev/null +++ b/src/data-source/array-where-filter.test.ts @@ -0,0 +1,332 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'; +import { type Relation } from 'typeorm'; + +import { createTestDataSource, defaultTestServerEntities } from '../__testing__/test-server/create-test-data-source.function'; +import { startTestServer, StartedTestServer } from '../__testing__/test-server/start-test-server.function'; +import { Repository } from '../data-source/repository'; +import { repositoryTokenFor } from '../di/decorators/inject-repository.decorator'; +import { inject } from '../di/inject.function'; +import { BaseEntity } from '../entity/base-entity.model'; +import { Entity } from '../entity/decorators/entity.decorator'; +import { Property } from '../entity/decorators/property.decorator'; +import { DeepPartial } from '../types/deep-partial.type'; + +// ---------- Test entities ---------- + +@Entity() +class Category extends BaseEntity { + @Property.string() + name!: string; + + @Property.manyToMany({ target: () => Post, inverseSide: 'categories', joinTable: false }) + posts!: Post[]; +} + +@Entity() +class Comment extends BaseEntity { + @Property.string() + text!: string; + + @Property.manyToOne({ target: () => Post, inverseSide: 'comments', joinColumn: 'postId' }) + post!: Relation; + + @Property.string({ format: 'uuid' }) + postId!: string; +} + +class Review { + @Property.number() + rating!: number; + + @Property.string() + comment!: string; +} + +class Metadata { + @Property.array({ items: { type: 'string' }, required: false }) + notes: string[] | null | undefined; + + @Property.array({ items: { type: 'object', cls: () => Review }, required: false }) + reviews: Review[] | null | undefined; +} + +@Entity() +class Post extends BaseEntity { + @Property.string() + title!: string; + + @Property.array({ items: { type: 'string' }, required: false }) + tags: string[] | null | undefined; + + @Property.object({ cls: () => Metadata, required: false }) + metadata: Metadata | null | undefined; + + @Property.oneToMany({ target: () => Comment, inverseSide: 'post' }) + comments!: Comment[]; + + @Property.manyToMany({ target: () => Category, inverseSide: 'posts', joinTable: true }) + categories!: Category[]; +} + +let server: StartedTestServer; +let postRepo: Repository; +let commentRepo: Repository; +let categoryRepo: Repository; + +beforeAll(async () => { + server = await startTestServer({ + dataSources: [ + createTestDataSource({ + entities: [...defaultTestServerEntities, Post, Comment, Category] + }) + ] + }); + postRepo = inject(repositoryTokenFor(Post)); + commentRepo = inject(repositoryTokenFor(Comment)); + categoryRepo = inject(repositoryTokenFor(Category)); +}, 15000); + +afterAll(async () => { + await server.shutdown(); +}); + +beforeEach(async () => { + await postRepo.deleteAll({}); + await commentRepo.deleteAll({}); + await categoryRepo.deleteAll({}); +}); + +// Helper +async function seed(posts: DeepPartial[]): Promise { + const result: Post[] = []; + for (const p of posts) { + const post: Post = await postRepo.create({ + title: p.title ?? 'Untitled', + tags: p.tags ?? [], + metadata: p.metadata ?? { notes: [], reviews: [] }, + ...p + }); + result.push(post); + } + return result; +} + +// ---------- Tests ---------- +describe('Array length filters', () => { + + // =================== Native array (tags) =================== + describe('native array (tags)', () => { + let postA: Post; + let postB: Post; + + beforeEach(async () => { + [postA, postB] = await seed([ + { title: 'A', tags: ['a', 'b', 'c'] }, + { title: 'B', tags: ['d'] } + ]); + }); + + it('length equals', async () => { + const res: Post[] = await postRepo.findAll({ where: { tags: { length: 3 } } }); + expect(res.map(p => p.id)).toEqual([postA.id]); + }); + + it('lengthGreaterThan', async () => { + const res: Post[] = await postRepo.findAll({ where: { tags: { lengthGreaterThan: 1 } } }); + expect(res.map(p => p.id)).toEqual([postA.id]); + }); + + it('lengthLesserThanEquals', async () => { + const res: Post[] = await postRepo.findAll({ where: { tags: { lengthLesserThanEquals: 1 } } }); + expect(res.map(p => p.id)).toEqual([postB.id]); + }); + }); + + // =================== JSONB scalar array (metadata.notes) =================== + describe('JSONB scalar array (metadata.notes)', () => { + let postA: Post; + + beforeEach(async () => { + [postA] = await seed([ + { title: 'A', metadata: { notes: ['n1', 'n2', 'n3'], reviews: [] } }, + { title: 'B', metadata: { notes: ['n4'], reviews: [] } } + ]); + }); + + it('length equals', async () => { + const res: Post[] = await postRepo.findAll({ + where: { metadata: { where: { notes: { length: 3 } } } } + }); + expect(res.map(p => p.id)).toEqual([postA.id]); + }); + + it('lengthGreaterThanEquals', async () => { + const res: Post[] = await postRepo.findAll({ + where: { metadata: { where: { notes: { lengthGreaterThanEquals: 2 } } } } + }); + expect(res.map(p => p.id)).toEqual([postA.id]); + }); + }); + + // =================== JSONB object array (metadata.reviews) =================== + describe('JSONB object array (metadata.reviews)', () => { + let postA: Post; + + beforeEach(async () => { + [postA] = await seed([ + { + title: 'A', + metadata: { + notes: [], + reviews: [ + { rating: 5, comment: 'Great' }, + { rating: 4, comment: 'Good' }, + { rating: 3, comment: 'Okay' } + ] + } + }, + { + title: 'B', + metadata: { + notes: [], + reviews: [{ rating: 1, comment: 'Bad' }] + } + } + ]); + }); + + it('length equals', async () => { + const res: Post[] = await postRepo.findAll({ + where: { metadata: { where: { reviews: { length: 3 } } } } + }); + expect(res.map(p => p.id)).toEqual([postA.id]); + }); + + it('combined with element filter (where)', async () => { + // Posts with at least 2 reviews where one of them has rating >= 4 + const res: Post[] = await postRepo.findAll({ + where: { + metadata: { + where: { + reviews: { + lengthGreaterThanEquals: 2, + where: { rating: { greaterThanEquals: 4 } } + } + } + } + } + }); + expect(res.map(p => p.id)).toEqual([postA.id]); + }); + }); + + // =================== One-to-many relation (comments) =================== + describe('one-to-many (comments)', () => { + let postA: Post; + let postB: Post; + + beforeEach(async () => { + [postA, postB] = await seed([ + { title: 'A' }, + { title: 'B' } + ]); + await commentRepo.create({ text: 'c1', postId: postA.id }); + await commentRepo.create({ text: 'c2', postId: postA.id }); + await commentRepo.create({ text: 'c3', postId: postB.id }); + }); + + it('length equals', async () => { + const res: Post[] = await postRepo.findAll({ where: { comments: { length: 2 } } }); + expect(res.map(p => p.id)).toEqual([postA.id]); + }); + + it('lengthGreaterThan', async () => { + const res: Post[] = await postRepo.findAll({ where: { comments: { lengthGreaterThan: 0 } } }); + expect(res.map(p => p.id).sort()).toEqual([postA.id, postB.id].sort()); + }); + + it('combined with element filter (where)', async () => { + const res: Post[] = await postRepo.findAll({ + where: { + comments: { + lengthGreaterThanEquals: 1, + where: { text: 'c1' } + } + } + }); + expect(res.map(p => p.id)).toEqual([postA.id]); + }); + }); + + // =================== Many-to-many relation (categories) =================== + describe('many-to-many (categories)', () => { + let postA: Post; + let postB: Post; + let cat1: Category; + let cat2: Category; + let cat3: Category; + + beforeEach(async () => { + [postA, postB] = await seed([ + { title: 'A' }, + { title: 'B' } + ]); + cat1 = await categoryRepo.create({ name: 'Cat1' }); + cat2 = await categoryRepo.create({ name: 'Cat2' }); + cat3 = await categoryRepo.create({ name: 'Cat3' }); + + // Associate categories directly using repository or raw query if needed. + // We'll use the Post entity's category collection. Since it's many-to-many with join table, + // we can update the post with the categories array. + await postRepo.updateById(postA.id, { categories: [cat1, cat2] }); + await postRepo.updateById(postB.id, { categories: [cat3] }); + }); + + it('length equals', async () => { + const res: Post[] = await postRepo.findAll({ where: { categories: { length: 2 } } }); + expect(res.map(p => p.id)).toEqual([postA.id]); + }); + + it('lengthLesserThan', async () => { + const res: Post[] = await postRepo.findAll({ where: { categories: { lengthLesserThan: 2 } } }); + expect(res.map(p => p.id)).toEqual([postB.id]); + }); + + it('combined with element filter (includes)', async () => { + // Note: includes on many-to-many might not be directly supported; we'll assume similar semantics. + // This test will verify that length + includes works. + const res: Post[] = await postRepo.findAll({ + where: { + categories: { + length: 2, + includes: [cat1, cat2] // exact entity object, which should be unwrapped to id via existing logic + } + } + }); + expect(res.map(p => p.id)).toEqual([postA.id]); + }); + }); + + // =================== OR combination with length =================== + describe('OR combination', () => { + let postA: Post; + let postB: Post; + + beforeEach(async () => { + [postA, postB] = await seed([ + { title: 'A', tags: ['a', 'b'] }, + { title: 'B', tags: ['c', 'd', 'e'] } + ]); + }); + + it('length OR another condition', async () => { + const res: Post[] = await postRepo.findAll({ + where: [ + { tags: { length: 2 } }, + { title: 'B' } + ] + }); + expect(res.map(p => p.id).sort()).toEqual([postA.id, postB.id].sort()); + }); + }); +}); \ No newline at end of file diff --git a/src/data-source/data-sources/data-source-initialization.error.ts b/src/data-source/data-sources/data-source-initialization.error.ts new file mode 100644 index 0000000..39df169 --- /dev/null +++ b/src/data-source/data-sources/data-source-initialization.error.ts @@ -0,0 +1,9 @@ +/** + * An error to throw when a data source that hasn't been initialized yet is being used. + */ +export class DataSourceInitializationError extends Error { + constructor() { + super('The data source needs to be initialized before it can be used.'); + this.name = 'DataSourceInitializationError'; + } +} \ No newline at end of file diff --git a/src/data-source/data-sources/data-source.interface.ts b/src/data-source/data-sources/data-source.interface.ts index c3f2a4e..f0850d5 100644 --- a/src/data-source/data-sources/data-source.interface.ts +++ b/src/data-source/data-sources/data-source.interface.ts @@ -1,4 +1,8 @@ import { BackupResourceInterface } from '../../backup/backup-resource.interface'; +import { ChangeSetRepository } from '../../change-sets/change-set-repository'; +import { ChangeSetEntity } from '../../change-sets/models/change-set-entity.model'; +import { SoftDeleteEntity } from '../../change-sets/models/soft-delete-entity.model'; +import { SoftDeleteRepository } from '../../change-sets/soft-delete-repository'; import { BaseEntity } from '../../entity/base-entity.model'; import { PropertyMetadataInput, PropertyMetadata, RelationMetadata } from '../../entity/decorators/property.decorator'; import { FilePropertyMetadata } from '../../entity/models/file-property-metadata.model'; @@ -18,6 +22,15 @@ export enum IsolationLevel { SERIALIZABLE = 'SERIALIZABLE' } +/** + * The inferred repository type for the given entity. + */ +export type RepositoryTypeForEntity = T extends SoftDeleteEntity + ? SoftDeleteRepository + : T extends ChangeSetEntity + ? ChangeSetRepository + : Repository; + /** * Definition for a data source. */ @@ -49,7 +62,7 @@ export interface DataSourceInterface extends BackupResourceInterface { * @returns A repository for the provided entity class. * @throws When the data source has not been initialized yet or the provided entity does not belong to this data source. */ - getRepository: (cls: Newable) => Repository, + getRepository: (cls: Newable) => RepositoryTypeForEntity, /** * Starts a new transaction. diff --git a/src/data-source/data-sources/postgres-data-source.model.ts b/src/data-source/data-sources/postgres-data-source.model.ts deleted file mode 100644 index 7cbd29b..0000000 --- a/src/data-source/data-sources/postgres-data-source.model.ts +++ /dev/null @@ -1,675 +0,0 @@ -import { ChildProcessByStdio, spawn } from 'node:child_process'; -import { PassThrough, Readable, Writable } from 'node:stream'; - -import { DataSource as TODataSource, Repository as TORepository, EntityMetadata as TOEntityMetadata, EntitySchema, EntitySchemaColumnOptions, QueryRunner, EntitySchemaRelationOptions, Table, TableColumnOptions, TableColumn, EntityTarget } from 'typeorm'; -import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.js'; -import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata.js'; -import { OnDeleteType } from 'typeorm/metadata/types/OnDeleteType.js'; -import { OnUpdateType } from 'typeorm/metadata/types/OnUpdateType.js'; - -import { DataSourceInterface, IsolationLevel } from './data-source.interface'; -import { ChangeSetRepository } from '../../change-sets/change-set-repository'; -import { isChangeSetEntityNewable, ChangeSetEntity } from '../../change-sets/models/change-set-entity.model'; -import { isSoftDeleteEntityNewable, SoftDeleteEntity } from '../../change-sets/models/soft-delete-entity.model'; -import { SoftDeleteRepository } from '../../change-sets/soft-delete-repository'; -import { repositoryTokenFor } from '../../di/decorators/inject-repository.decorator'; -import { Inject } from '../../di/decorators/inject.decorator'; -import { ZIBRI_DI_TOKENS } from '../../di/default/zibri-di-tokens.default'; -import { inject } from '../../di/inject.function'; -import { register } from '../../di/register.function'; -import { BaseEntity } from '../../entity/base-entity.model'; -import { EntityMetadata } from '../../entity/decorators/entity.decorator'; -import { PropertyMetadata, PropertyMetadataInput, RelationMetadata } from '../../entity/decorators/property.decorator'; -import { FilePropertyMetadata } from '../../entity/models/file-property-metadata.model'; -import { Relation } from '../../entity/models/relation.enum'; -import { StringPropertyMetadata } from '../../entity/models/string-property-metadata.model'; -import { GlobalRegistry } from '../../global/global-registry'; -import { type LoggerInterface } from '../../logging/logger.interface'; -import { ExcludeStrict } from '../../types/exclude-strict.type'; -import { Newable } from '../../types/newable.type'; -import { OmitStrict } from '../../types/omit-strict.type'; -import { Version } from '../../types/version.type'; -import { compareVersion } from '../../utilities/compare-versions.function'; -import { MetadataUtilities } from '../../utilities/metadata.utilities'; -import { ObjectUtilities } from '../../utilities/object.utilities'; -import { getDefaultBeforeReturnHook, getDefaultBeforeSaveHook } from '../hooks/hooks.default'; -import { MigrationEntity } from '../migration/migration-entity.model'; -import { Migration } from '../migration/migration.model'; -import { ColumnType } from '../models/column-type.model'; -import { DataSourceOptions } from '../models/data-source-options.model'; -import { Repository } from '../repository'; -import { Transaction } from '../transaction/transaction.model'; -import { TypeOrmTransaction } from '../transaction/typeorm-transaction.model'; - -// eslint-disable-next-line jsdoc/require-jsdoc -type ToColumnMappableTypes = ExcludeStrict>['type']; - -// eslint-disable-next-line jsdoc/require-jsdoc -type MigrationWithName = { migration: Migration, name: string }; - -/** - * Postgres-specific connection options. - */ -export type PostgresOptions = OmitStrict; - -/** - * A base postgres data source definition. - */ -export abstract class PostgresDataSource implements DataSourceInterface { - /** - * Mapping from a Zibri property type to a typeorm column type. - */ - protected readonly columnTypeMappingOverride: Partial> = {}; - - private get columnTypeMapping(): Record { - return { - array: 'array', - number: 'decimal', - string: 'varchar', - object: 'jsonb', - date: 'timestamptz', - boolean: 'boolean', - unknown: 'jsonb', - file: 'bytea', - ...this.columnTypeMappingOverride - }; - } - - abstract readonly options: PostgresOptions; - abstract readonly entities: Newable[]; - /** - * The optional root password. - */ - readonly rootPw: string | undefined; - /** - * The optional root username. - */ - readonly rootUsername: string | undefined; - - // eslint-disable-next-line jsdoc/require-jsdoc - readonly migrations: Newable[] = []; - - /** - * The internal typeorm data source. - */ - protected ds?: TODataSource; - - constructor( - @Inject(ZIBRI_DI_TOKENS.LOGGER) - private readonly logger: LoggerInterface - ) { } - - // eslint-disable-next-line jsdoc/require-jsdoc - createBackupData(): Readable { - const dumpCommand: string = 'pg_dumpall'; - const { host, port } = this.options; - if (!this.rootUsername || !host || !port) { - throw new Error('Could not create a backup, missing this.rootUsername, this.options.host or this.options.port'); - } - const args: string[] = ['-U', this.rootUsername, '-h', host, '-p', port.toString()]; - - const child: ChildProcessByStdio = spawn(dumpCommand, args, { - stdio: ['ignore', 'pipe', 'inherit'], - env: { ...process.env, PGPASSWORD: this.rootPw } - }); - - const out: PassThrough = new PassThrough(); - child.stdout.pipe(out); - child.on('error', err => { - out.destroy(err); - }); - child.on('exit', code => { - if (code !== 0) { - out.destroy(new Error(`${dumpCommand} exited with code ${code}`)); - } - else { - out.end(); - } - }); - - return out; - } - - // eslint-disable-next-line jsdoc/require-jsdoc - async restoreBackup(backupData: Readable): Promise { - const { host, port, database } = this.options; - if (!this.rootUsername || !host || !port || !database) { - throw new Error('Missing rootUsername, host, port, or database for restore'); - } - - const args: string[] = ['-U', this.rootUsername, '-h', host, '-p', port.toString(), '-d', database]; - const child: ChildProcessByStdio = spawn('psql', args, { - stdio: ['pipe', 'inherit', 'inherit'], - env: { ...process.env, PGPASSWORD: this.rootPw } - }); - - return new Promise((resolve, reject) => { - backupData.pipe(child.stdin); - - child.on('error', reject); - child.on('close', code => { - if (code !== 0) { - reject(new Error(`psql exited with code ${code}`)); - } - else { - resolve(); - } - }); - }); - } - - // eslint-disable-next-line jsdoc/require-jsdoc - async init(): Promise { - if (this.ds) { - throw new Error('The postgres data source has already been initialized.'); - } - - if (this.options.username === 'postgres' && this.options.password === 'password') { - await this.logger.warn( - `The data source "${this.constructor.name}" uses the default credentials, you probably want to change that.` - ); - } - - for (const entityClass of this.entities) { - register({ - token: repositoryTokenFor(entityClass), - useFactory: () => this.getRepository(entityClass) - }); - } - - const schemas: EntitySchema[] = this.getEntitySchemas(); - this.ds = new TODataSource({ - entities: schemas, - poolSize: 100, - type: 'postgres', - ...this.options, - synchronize: false - } as DataSourceOptions); - await this.ds.initialize(); - - await this.runMigrations(); - - if (this.options.synchronize !== false) { - await this.ds.synchronize(); - } - } - - // eslint-disable-next-line jsdoc/require-jsdoc - async shutDown(): Promise { - await this.ds?.destroy(); - } - - /** - * Gets entity schemas for the entities of this data source. - * @returns Typeorm entity schemas. - */ - protected getEntitySchemas(): EntitySchema[] { - const schemas: EntitySchema[] = this.entities.map(e => this.createSchemaForEntity(e)); - return schemas; - } - - /** - * Creates a typeorm entity schema for a single entity. - * @param cls - The entity class to create the schema for. - * @returns A typeorm entity schema. - * @throws When the provided entity was configured incorrectly. - */ - protected createSchemaForEntity(cls: Newable): EntitySchema { - const entityMetadata: EntityMetadata | undefined = MetadataUtilities.getEntityMetadata(cls); - if (!entityMetadata) { - throw new Error(`Could not find metadata for entity "${cls.name}". Did you forget to decorate it with @Entity?`); - } - const props: Record = MetadataUtilities.getModelProperties(cls); - - const numberOfPrimaryKeys: number = ObjectUtilities - .values(props) - // eslint-disable-next-line typescript/no-explicit-any - .filter(d => (d as StringPropertyMetadata).primary) - .length; - if (numberOfPrimaryKeys === 0) { - throw new Error(`no primary key specified for entity "${cls.name}".`); - } - if (numberOfPrimaryKeys > 1) { - throw new Error(`more than 1 primary key specified for entity "${cls.name}".`); - } - - const columns: Record = {}; - const relations: Record = {}; - for (const [key, m] of ObjectUtilities.entries(props)) { - if ( - m.type === Relation.MANY_TO_ONE - || m.type === Relation.ONE_TO_MANY - || m.type === Relation.ONE_TO_ONE - || m.type === Relation.MANY_TO_MANY - ) { - relations[key] = this.propertyToRelationOptions(m); - continue; - } - columns[key] = this.propertyToColumnOptions(m); - } - - return new EntitySchema({ - name: cls.name, - target: cls, - tableName: entityMetadata.tableName, - columns, - relations - }); - } - - /** - * Transforms the given relation metadata to typeorm relation options. - * @param metadata - The relation metadata to transform. - * @returns Typeorm relation options. - */ - protected propertyToRelationOptions(metadata: RelationMetadata): EntitySchemaRelationOptions { - const thisHasRemove: boolean = this.hasCascadeFlag(metadata.cascade, 'remove'); - const thisHasUpdate: boolean = this.hasCascadeFlag(metadata.cascade, 'update'); - const thisHasInsert: boolean = this.hasCascadeFlag(metadata.cascade, 'insert'); - - // try to inspect inverse property's cascade (if inverseSide provided) - const targetClass: Newable = metadata.target(); - const targetProps: Record = MetadataUtilities.getModelProperties(targetClass); - const inv: RelationMetadata = targetProps[metadata.inverseSide] as RelationMetadata; - const inverseHasRemove: boolean = this.hasCascadeFlag(inv.cascade, 'remove'); - const inverseHasUpdate: boolean = this.hasCascadeFlag(inv.cascade, 'update'); - const inverseHasInsert: boolean = this.hasCascadeFlag(inv.cascade, 'insert'); - - const onDelete: OnDeleteType | undefined = thisHasRemove || inverseHasRemove ? 'CASCADE' : undefined; - const onUpdate: OnUpdateType | undefined = thisHasUpdate || inverseHasUpdate ? 'CASCADE' : undefined; - const persistence: boolean = 'persistence' in metadata ? metadata.persistence : thisHasInsert || inverseHasInsert; - const nullable: boolean = typeof metadata.required === 'boolean' ? !metadata.required : true; - - switch (metadata.type) { - case Relation.ONE_TO_ONE: - case Relation.ONE_TO_MANY: - case Relation.MANY_TO_MANY: { - return { - nullable, - ...metadata, - inverseSide: metadata.inverseSide as string, - onDelete, - onUpdate, - persistence - }; - } - case Relation.MANY_TO_ONE: { - return { - nullable, - joinColumn: true, - ...metadata, - inverseSide: metadata.inverseSide as string, - onDelete, - onUpdate, - persistence - }; - } - } - } - - private hasCascadeFlag(c: RelationMetadata['cascade'], flag: 'remove' | 'update' | 'insert'): boolean { - if (c === true) { - return true; - } - if (Array.isArray(c)) { - return c.includes(flag); - } - return false; - } - - /** - * Transforms the given property metadata to typeorm column options. - * @param metadata - The property metadata to transform. - * @returns Typeorm column options. - * @throws When the metadata is incorrect. - */ - protected propertyToColumnOptions( - metadata: ExcludeStrict> - ): EntitySchemaColumnOptions { - const nullable: boolean = typeof metadata.required === 'boolean' ? !metadata.required : true; - switch (metadata.type) { - case 'file': - case 'boolean': - case 'object': - case 'unknown': - case 'date': { - return { - nullable, - ...metadata, - type: this.columnTypeMapping[metadata.type], - default: undefined - }; - } - case 'array': { - if (metadata.items.type === 'object') { - return { - nullable, - ...metadata, - type: this.columnTypeMapping[metadata.items.type], - default: undefined - }; - } - return { - nullable, - ...metadata, - type: this.columnTypeMapping[metadata.items.type], - array: true, - default: undefined - }; - } - case 'number': { - return { - nullable, - generated: metadata.primary ? 'increment' : undefined, - ...metadata, - type: metadata.format ?? this.columnTypeMapping[metadata.type], - default: undefined, - transformer: { - // eslint-disable-next-line unicorn/no-null - to: (v: number | bigint | null) => v != null ? String(v) : null, - from: (v: string | null) => { - if (v == undefined) { - return v; - } - if (metadata.format === 'bigint') { - return BigInt(v); - } - return Number(v); - } - } - }; - } - case 'string': { - return { - nullable, - generated: metadata.primary ? 'uuid' : undefined, - ...metadata, - type: metadata.format === 'uuid' || metadata.primary ? 'uuid' : this.columnTypeMapping[metadata.type], - length: metadata.maxLength, - enum: metadata.enum ? ObjectUtilities.values(metadata.enum) : undefined, - default: undefined - }; - } - } - } - - // eslint-disable-next-line jsdoc/require-jsdoc - getRepository(cls: Newable): Repository { - if (!this.ds) { - // eslint-disable-next-line sonar/no-duplicate-string - throw new Error('The postgres data source needs to be initialized before it can be used.'); - } - if (!this.entities.find(e => e === cls)) { - throw new Error(`The entity "${cls.name}" is not in this database. Did you forget to include it in the entities array?`); - } - const repo: TORepository = this.ds.getRepository(cls); - - if (isSoftDeleteEntityNewable(cls)) { - return new SoftDeleteRepository( - cls, - repo as unknown as TORepository, - this.logger, - this, - getDefaultBeforeSaveHook(), - getDefaultBeforeReturnHook() - ) as unknown as Repository; - } - if (isChangeSetEntityNewable(cls)) { - return new ChangeSetRepository( - cls, - repo as unknown as TORepository, - this.logger, - this, - getDefaultBeforeSaveHook(), - getDefaultBeforeReturnHook() - ) as unknown as Repository; - } - return new Repository( - cls, - repo, - this.logger, - this, - getDefaultBeforeSaveHook(), - getDefaultBeforeReturnHook() - ); - } - - // eslint-disable-next-line jsdoc/require-jsdoc - async startTransaction(isolationLevel?: IsolationLevel): Promise { - if (!this.ds) { - throw new Error('The postgres data source needs to be initialized before it can be used.'); - } - - const runner: QueryRunner = this.createQueryRunner(); - try { - await runner.connect(); - await runner.startTransaction(isolationLevel); - return new TypeOrmTransaction(runner); - } - catch (error) { - await runner.release(); - throw error; - } - } - - /** - * Creates a query runner. - * @returns A query runner. - * @throws When the data source has not been initialized yet. - */ - createQueryRunner(): QueryRunner { - if (!this.ds) { - throw new Error('The postgres data source needs to be initialized before it can be used.'); - } - return this.ds.createQueryRunner(); - } - - // eslint-disable-next-line jsdoc/require-jsdoc - async runMigrations(): Promise { - await this.createMigrationTableIfNotExists(); - - // we need to dynamically inject here because the repositories aren't ready in the constructor. - const migrationsRepository: Repository = inject(repositoryTokenFor(MigrationEntity)); - const finishedMigrationVersions: string[] = (await migrationsRepository.findAll()).map(m => m.version); - const allMigrations: MigrationWithName[] = this.migrations.map(m => ({ migration: inject(m), name: m.name })); - - const migrationsToRunUp: MigrationWithName[] = allMigrations.filter(m => { - return !finishedMigrationVersions.includes(m.migration.version) - && compareVersion(m.migration.version, GlobalRegistry.getAppData('version') as Version) !== 'bigger'; - }); - - const migrationsToRunDown: MigrationWithName[] = allMigrations.filter(m => { - return finishedMigrationVersions.includes(m.migration.version) - && compareVersion(m.migration.version, GlobalRegistry.getAppData('version') as Version) === 'bigger'; - }); - - for (const migration of migrationsToRunUp) { - await this.logger.info(` > runs up migration ${migration.name}`); - await migration.migration.runUp(); - } - - for (const migration of migrationsToRunDown) { - await this.logger.info(` > runs down migration ${migration.name}`); - await migration.migration.runDown(); - } - - const skipped: number = allMigrations.length - migrationsToRunDown.length - migrationsToRunUp.length; - if (skipped) { - await this.logger.info(` > skipped ${skipped} migrations that have already been applied`); - } - } - - // eslint-disable-next-line jsdoc/require-jsdoc - async addPropertyToEntity( - entity: Newable, - key: keyof T, - transaction: Transaction - ): Promise { - const col: TableColumnOptions = this.propertyToTableColumnOptions(entity, key); - await transaction.queryRunner.addColumn( - this.getEntityMetadata(entity, transaction).tableName, - new TableColumn({ ...col, isNullable: true }) - ); - } - - // eslint-disable-next-line jsdoc/require-jsdoc - async changePropertyOfEntity( - entity: Newable, - oldColumn: keyof T | string & {}, - newColumn: PropertyMetadataInput & { - /** - * The name of the new column. - */ - name?: keyof T, - /** - * The type of the new column. - */ - type: ExcludeStrict | FilePropertyMetadata>['type'] - }, - transaction: Transaction - ): Promise { - const entityMetadata: TOEntityMetadata = this.getEntityMetadata(entity, transaction); - const columnMetadata: ColumnMetadata = this.getColumnMetadata(entity, oldColumn, transaction); - - const col: TableColumnOptions = { - ...columnMetadata, - ...newColumn, - enum: 'enum' in newColumn && newColumn.enum - ? ObjectUtilities.values(newColumn.enum).map(v => String(v)) - : columnMetadata.enum - ? columnMetadata.enum.map(v => String(v)) - : undefined, - name: String(newColumn.name ?? oldColumn), - type: this.normalizeColumnType({ - precision: undefined, - scale: undefined, - ...columnMetadata, - ...newColumn, - type: this.columnTypeMapping[newColumn.type] - }) - }; - - await transaction.queryRunner.changeColumn(entityMetadata.tableName, String(oldColumn), new TableColumn(col)); - } - - /** - * Gets the typeorm metadata for a given entity. - * @param target - The target entity. - * @param transaction - The transaction to run this command with. - * @returns The typeorm metadata. - */ - protected getEntityMetadata(target: EntityTarget, transaction: Transaction): TOEntityMetadata { - return transaction.queryRunner.connection.getMetadata(target); - } - - /** - * Gets the metadata for a typeorm column. - * @param target - The entity. - * @param propertyName - The name of the property to get the column metadata for. - * @param transaction - The transaction to use to get the column metadata. - * @returns The typeorm column metadata. - * @throws When the provided propertyName could not be found as a column. - */ - protected getColumnMetadata( - target: EntityTarget, - propertyName: keyof T | string & {}, - transaction: Transaction - ): ColumnMetadata { - const metadata: TOEntityMetadata = this.getEntityMetadata(target, transaction); - const column: ColumnMetadata | undefined = metadata.columns.find( - (col) => col.propertyName === propertyName - ); - - if (!column) { - throw new Error( - `Column ${propertyName.toString()} not found in model` - ); - } - - return column; - } - - /** - * Creates a table for migrations if it does not exist already. - */ - protected async createMigrationTableIfNotExists(): Promise { - if (!this.ds) { - throw new Error('The postgres data source needs to be initialized before it can be used.'); - } - - const runner: QueryRunner = this.createQueryRunner(); - try { - await runner.connect(); - const schema: EntitySchema = this.createSchemaForEntity(MigrationEntity); - const metadata: TOEntityMetadata = this.ds.getMetadata(schema); - const table: Table = new Table({ - name: metadata.tablePath, - columns: metadata.columns.map(col => ({ - name: col.databaseName, - ...col, - enum: col.enum?.map(v => String(v)), - // eslint-disable-next-line typescript/no-non-null-assertion - type: this.ds!.driver.normalizeType(col) - })) - }); - await runner.createTable(table, true); - - } - finally { - await runner.release(); - } - } - - /** - * Transforms the property on the given entity class to typeorm column options. - * @param entity - The entity class which property should be transformed. - * @param property - The key of the actual property that should be transformed. - * @returns Typeorm column options. - * @throws When no data source has been provided or no column metadata could be found. - */ - protected propertyToTableColumnOptions(entity: Newable, property: keyof T): TableColumnOptions { - if (!this.ds) { - throw new Error('The postgres data source needs to be initialized before it can be used.'); - } - const schema: EntitySchema = this.createSchemaForEntity(entity); - const metadata: TOEntityMetadata = this.ds.getMetadata(schema); - const col: ColumnMetadata | undefined = metadata.columns.find(c => c.propertyName === property); - - if (!col) { - throw new Error(`Could not determine column metadata for ${entity.name}.${String(property)}`); - } - - return { - name: col.databaseName, - ...col, - enum: col.enum ? col.enum?.map(v => String(v)) : undefined, - type: this.normalizeColumnType({ - isArray: col.isArray, - length: col.length, - precision: col.precision, - scale: col.scale, - type: col.type - }) - }; - } - - private normalizeColumnType( - column: { - // eslint-disable-next-line jsdoc/require-jsdoc - type: ColumnType | string & {} | undefined, - // eslint-disable-next-line jsdoc/require-jsdoc - length: number | string | undefined, - // eslint-disable-next-line jsdoc/require-jsdoc - precision: number | null | undefined, - // eslint-disable-next-line jsdoc/require-jsdoc - scale: number | undefined, - // eslint-disable-next-line jsdoc/require-jsdoc - isArray: boolean | undefined - } - ): string { - if (!this.ds) { - throw new Error('The postgres data source needs to be initialized before it can be used.'); - } - return this.ds.driver.normalizeType(column); - } -} \ No newline at end of file diff --git a/src/data-source/data-sources/postgres-typeorm-data-source.model.ts b/src/data-source/data-sources/postgres-typeorm-data-source.model.ts new file mode 100644 index 0000000..60ae077 --- /dev/null +++ b/src/data-source/data-sources/postgres-typeorm-data-source.model.ts @@ -0,0 +1,330 @@ +import { ChildProcessByStdio, spawn } from 'node:child_process'; +import { PassThrough, Readable, Writable } from 'node:stream'; + +import { EntitySchemaColumnOptions, EntitySchemaRelationOptions, ColumnType, QueryBuilder } from 'typeorm'; +import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.js'; +import { OnDeleteType } from 'typeorm/metadata/types/OnDeleteType.js'; +import { OnUpdateType } from 'typeorm/metadata/types/OnUpdateType.js'; + +import { DataSourceInitializationError } from './data-source-initialization.error'; +import { QueryOptions, SqlDataSourceInterface } from './sql-data-source.interface'; +import { TypeOrmBaseDataSource, ToColumnMappableTypes } from './typeorm-base-data-source.model'; +import { PostgresTypeOrmWhereFilterConverter } from './where-converter/postgres-typeorm-where-filter.converter'; +import { type AuthServiceInterface } from '../../auth/auth-service.interface'; +import { Inject } from '../../di/decorators/inject.decorator'; +import { ZIBRI_DI_TOKENS } from '../../di/default/zibri-di-tokens.default'; +import { BaseEntity } from '../../entity/base-entity.model'; +import { PropertyMetadata, RelationMetadata } from '../../entity/decorators/property.decorator'; +import { Relation } from '../../entity/models/relation.enum'; +import { type LoggerInterface } from '../../logging/logger.interface'; +import { ExcludeStrict } from '../../types/exclude-strict.type'; +import { Newable } from '../../types/newable.type'; +import { OmitStrict } from '../../types/omit-strict.type'; +import { MetadataUtilities } from '../../utilities/metadata.utilities'; +import { ObjectUtilities } from '../../utilities/object.utilities'; + +/** + * Postgres-specific connection options. + */ +export type PostgresOptions = PostgresConnectionOptions; + +/** + * A base postgres data source definition. + */ +export abstract class PostgresDataSource extends TypeOrmBaseDataSource implements SqlDataSourceInterface { + + // eslint-disable-next-line jsdoc/require-jsdoc + protected whereFilterConverter: PostgresTypeOrmWhereFilterConverter | undefined; + // eslint-disable-next-line jsdoc/require-jsdoc + protected readonly type: 'postgres' = 'postgres'; + + // eslint-disable-next-line jsdoc/require-jsdoc + protected readonly defaultColumnTypeMapping: Record = { + array: 'array', + number: 'decimal', + string: 'varchar', + object: 'jsonb', + date: 'timestamptz', + boolean: 'boolean', + unknown: 'jsonb', + file: 'bytea' + }; + + // eslint-disable-next-line jsdoc/require-jsdoc + protected readonly defaultOptions: OmitStrict = { + poolSize: 100 + }; + + /** + * The optional root password. + */ + readonly rootPw: string | undefined; + /** + * The optional root username. + */ + readonly rootUsername: string | undefined; + + constructor( + @Inject(ZIBRI_DI_TOKENS.LOGGER) + logger: LoggerInterface, + @Inject(ZIBRI_DI_TOKENS.AUTH_SERVICE) + authService: AuthServiceInterface + ) { + super(logger, authService); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + query(entityClass: Newable, options?: QueryOptions): QueryBuilder { + if (!this.ds) { + throw new DataSourceInitializationError(); + } + + const alias: string = options?.alias ?? entityClass.name; + if (options?.transaction) { + return options.transaction.queryRunner.manager.createQueryBuilder(entityClass, alias); + } + return this.ds.createQueryBuilder(entityClass, alias); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + protected beforeMigrations(): void { + if (!this.ds) { + throw new Error('The data source needs to be initialized before it can be used.'); + } + this.whereFilterConverter = new PostgresTypeOrmWhereFilterConverter(this.ds); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + createBackupData(): Readable { + const dumpCommand: string = 'pg_dumpall'; + const { host, port } = this.options; + if (!this.rootUsername || !host || !port) { + throw new Error('Could not create a backup, missing this.rootUsername, this.options.host or this.options.port'); + } + const args: string[] = ['-U', this.rootUsername, '-h', host, '-p', port.toString()]; + + const child: ChildProcessByStdio = spawn(dumpCommand, args, { + stdio: ['ignore', 'pipe', 'inherit'], + env: { ...process.env, PGPASSWORD: this.rootPw } + }); + + const out: PassThrough = new PassThrough(); + child.stdout.pipe(out); + child.on('error', err => { + out.destroy(err); + }); + child.on('exit', code => { + if (code !== 0) { + out.destroy(new Error(`${dumpCommand} exited with code ${code}`)); + } + else { + out.end(); + } + }); + + return out; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async restoreBackup(backupData: Readable): Promise { + const { host, port, database } = this.options; + if (!this.rootUsername || !host || !port || !database) { + throw new Error('Missing rootUsername, host, port, or database for restore'); + } + + const args: string[] = ['-U', this.rootUsername, '-h', host, '-p', port.toString(), '-d', database]; + const child: ChildProcessByStdio = spawn('psql', args, { + stdio: ['pipe', 'inherit', 'inherit'], + env: { ...process.env, PGPASSWORD: this.rootPw } + }); + + return new Promise((resolve, reject) => { + backupData.pipe(child.stdin); + + child.on('error', reject); + child.on('close', code => { + if (code !== 0) { + reject(new Error(`psql exited with code ${code}`)); + } + else { + resolve(); + } + }); + }); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + protected async validateOptions(options: OmitStrict): Promise { + if (options.username === 'postgres' && options.password === 'password') { + await this.logger.warn( + `The data source "${this.constructor.name}" uses the default credentials, you probably want to change that.` + ); + } + } + + // eslint-disable-next-line jsdoc/require-jsdoc + protected propertyToRelationOptions( + cls: Newable, + key: string, + metadata: RelationMetadata + ): EntitySchemaRelationOptions { + const thisHasRemove: boolean = this.hasCascadeFlag(metadata.cascade, 'remove'); + const thisHasUpdate: boolean = this.hasCascadeFlag(metadata.cascade, 'update'); + const thisHasInsert: boolean = this.hasCascadeFlag(metadata.cascade, 'insert'); + + // try to inspect inverse property's cascade (if inverseSide provided) + const targetClass: Newable = metadata.target(); + const targetProps: Record = MetadataUtilities.getModelProperties(targetClass); + const inv: RelationMetadata = targetProps[metadata.inverseSide] as RelationMetadata; + const inverseHasRemove: boolean = this.hasCascadeFlag(inv.cascade, 'remove'); + const inverseHasUpdate: boolean = this.hasCascadeFlag(inv.cascade, 'update'); + const inverseHasInsert: boolean = this.hasCascadeFlag(inv.cascade, 'insert'); + + const onDelete: OnDeleteType | undefined = thisHasRemove || inverseHasRemove ? 'CASCADE' : undefined; + const onUpdate: OnUpdateType | undefined = thisHasUpdate || inverseHasUpdate ? 'CASCADE' : undefined; + const persistence: boolean = 'persistence' in metadata ? metadata.persistence : thisHasInsert || inverseHasInsert; + const nullable: boolean = typeof metadata.required === 'boolean' ? !metadata.required : true; + + // eslint-disable-next-line typescript/typedef + const shared = { + inverseSide: metadata.inverseSide as string, + onDelete, + onUpdate, + persistence, + nullable + } as const satisfies Partial; + + switch (metadata.type) { + case Relation.ONE_TO_MANY: { + return { + ...metadata, + ...shared + }; + } + case Relation.HAS_ONE: { + return { + ...metadata, + ...shared, + type: 'one-to-one' + }; + } + case Relation.MANY_TO_MANY: { + if (metadata.joinTable == undefined) { + throw new Error( + `The property ${cls.name}.${key} needs to have "joinTable" set inside of the @Property.manyToMany() decorator.` + ); + } + return { + ...metadata, + ...shared + }; + } + case Relation.BELONGS_TO_ONE: { + if (metadata.joinColumn == undefined) { + throw new Error( + `The property ${cls.name}.${key} needs to have "joinColumn" set inside of the @Property.belongsToOne() decorator.` + ); + } + return { + ...metadata, + ...shared, + type: 'one-to-one', + joinColumn: { name: metadata.joinColumn } + }; + } + case Relation.MANY_TO_ONE: { + if (metadata.joinColumn == undefined) { + throw new Error( + `The property ${cls.name}.${key} needs to have "joinColumn" set inside of the @Property.manyToOne() decorator.` + ); + } + return { + ...metadata, + ...shared, + joinColumn: { name: metadata.joinColumn } + }; + } + } + } + + private hasCascadeFlag(c: RelationMetadata['cascade'], flag: 'remove' | 'update' | 'insert'): boolean { + if (c === true) { + return true; + } + if (Array.isArray(c)) { + return c.includes(flag); + } + return false; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + protected propertyToColumnOptions( + metadata: ExcludeStrict> + ): EntitySchemaColumnOptions { + const nullable: boolean = typeof metadata.required === 'boolean' ? !metadata.required : true; + switch (metadata.type) { + case 'file': + case 'boolean': + case 'object': + case 'unknown': + case 'date': { + return { + nullable, + ...metadata, + type: this.fullColumnTypeMapping[metadata.type], + default: undefined + }; + } + case 'array': { + if (metadata.items.type === 'object') { + return { + nullable, + ...metadata, + type: this.fullColumnTypeMapping[metadata.items.type], + default: undefined + }; + } + return { + nullable, + ...metadata, + type: this.fullColumnTypeMapping[metadata.items.type], + array: true, + default: undefined + }; + } + case 'number': { + return { + nullable, + generated: metadata.primary ? 'increment' : undefined, + ...metadata, + type: metadata.format ?? this.fullColumnTypeMapping[metadata.type], + default: undefined, + transformer: { + // eslint-disable-next-line unicorn/no-null + to: (v: number | bigint | null) => v != null ? String(v) : null, + from: (v: string | null) => { + if (v == undefined) { + return v; + } + if (metadata.format === 'bigint') { + return BigInt(v); + } + return Number(v); + } + } + }; + } + case 'string': { + return { + nullable, + generated: metadata.primary ? 'uuid' : undefined, + ...metadata, + type: metadata.format === 'uuid' || metadata.primary ? 'uuid' : this.fullColumnTypeMapping[metadata.type], + length: metadata.maxLength, + enum: metadata.enum ? ObjectUtilities.values(metadata.enum) : undefined, + default: undefined + }; + } + } + } +} \ No newline at end of file diff --git a/src/data-source/data-sources/sql-data-source.interface.ts b/src/data-source/data-sources/sql-data-source.interface.ts new file mode 100644 index 0000000..459c1e1 --- /dev/null +++ b/src/data-source/data-sources/sql-data-source.interface.ts @@ -0,0 +1,35 @@ +import { QueryBuilder as ToQueryBuilder } from 'typeorm'; + +import { DataSourceInterface } from './data-source.interface'; +import { BaseEntity } from '../../entity/base-entity.model'; +import { Newable } from '../../types/newable.type'; +import { Transaction } from '../transaction/transaction.model'; + +/** + * Definition of a sql query builder. + */ +export type QueryBuilder = ToQueryBuilder; + +/** + * Options for starting a sql query. + */ +export type QueryOptions = { + /** + * The transaction to run the query in. + */ + transaction?: Transaction, + /** + * The alias of the entity in the queries. Defaults to the name of the entity. + */ + alias?: string +}; + +/** + * Definition for a sql data source. + */ +export interface SqlDataSourceInterface extends DataSourceInterface { + /** + * Starts a custom query builder on the table of the given entity. + */ + query: (entityClass: Newable, options?: QueryOptions) => QueryBuilder +} \ No newline at end of file diff --git a/src/data-source/data-sources/typeorm-base-data-source.model.ts b/src/data-source/data-sources/typeorm-base-data-source.model.ts new file mode 100644 index 0000000..ba03d7e --- /dev/null +++ b/src/data-source/data-sources/typeorm-base-data-source.model.ts @@ -0,0 +1,544 @@ +import { Readable } from 'node:stream'; + +import { EntitySchema, EntitySchemaColumnOptions, EntitySchemaRelationOptions, QueryRunner, Table, TableColumn, TableColumnOptions, DataSource as TODataSource, EntityMetadata as TOEntityMetadata, Repository as TORepository, FindOptionsWhere as ToFindOptionsWhere } from 'typeorm'; +import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata.js'; + +import { DataSourceInitializationError } from './data-source-initialization.error'; +import { DataSourceInterface, IsolationLevel, RepositoryTypeForEntity } from './data-source.interface'; +import { TypeOrmWhereFilterConverter } from './where-converter/typeorm-where-filter.converter'; +import { type AuthServiceInterface } from '../../auth/auth-service.interface'; +import { ChangeSetRepository } from '../../change-sets/change-set-repository'; +import { ChangeSetEntity, isChangeSetEntityNewable } from '../../change-sets/models/change-set-entity.model'; +import { ChangeSet } from '../../change-sets/models/change-set.model'; +import { isSoftDeleteEntityNewable, SoftDeleteEntity } from '../../change-sets/models/soft-delete-entity.model'; +import { SoftDeleteRepository } from '../../change-sets/soft-delete-repository'; +import { repositoryTokenFor } from '../../di/decorators/inject-repository.decorator'; +import { Inject } from '../../di/decorators/inject.decorator'; +import { ZIBRI_DI_TOKENS } from '../../di/default/zibri-di-tokens.default'; +import { inject } from '../../di/inject.function'; +import { register } from '../../di/register.function'; +import { BaseEntity } from '../../entity/base-entity.model'; +import { EntityMetadata } from '../../entity/decorators/entity.decorator'; +import { PropertyMetadata, PropertyMetadataInput, RelationMetadata } from '../../entity/decorators/property.decorator'; +import { EntityMetadataMissingError } from '../../entity/entity-metadata-missing.error'; +import { FilePropertyMetadata } from '../../entity/models/file-property-metadata.model'; +import { Relation } from '../../entity/models/relation.enum'; +import { GlobalRegistry } from '../../global/global-registry'; +import { type LoggerInterface } from '../../logging/logger.interface'; +import { ExcludeStrict } from '../../types/exclude-strict.type'; +import { Newable } from '../../types/newable.type'; +import { OmitStrict } from '../../types/omit-strict.type'; +import { Version } from '../../types/version.type'; +import { compareVersion } from '../../utilities/compare-versions.function'; +import { MetadataUtilities } from '../../utilities/metadata.utilities'; +import { ObjectUtilities } from '../../utilities/object.utilities'; +import { TypeOrmUtilities } from '../../utilities/typeorm.utilities'; +import { getDefaultBeforeReturnHook, getDefaultBeforeSaveHook } from '../hooks/hooks.default'; +import { MigrationEntity } from '../migration/migration-entity.model'; +import { Migration } from '../migration/migration.model'; +import { ColumnType } from '../models/column-type.model'; +import { DataSourceOptions } from '../models/data-source-options.model'; +import { Where, WhereFilter } from '../models/where/where-filter.model'; +import { Repository } from '../repository'; +import { Transaction } from '../transaction/transaction.model'; +import { TypeOrmTransaction } from '../transaction/typeorm-transaction.model'; + +// eslint-disable-next-line jsdoc/require-jsdoc +export type MigrationWithName = { migration: Migration, name: string }; + +// eslint-disable-next-line jsdoc/require-jsdoc +export type ToColumnMappableTypes = ExcludeStrict>['type']; + +/** + * Base data source implementation of zibri. + * Uses typeorm under the hood. + */ +export abstract class TypeOrmBaseDataSource implements DataSourceInterface { + abstract readonly entities: Newable[]; + + /** + * The type of the data source. + */ + protected abstract readonly type: TOptions['type']; + + /** + * Converter responsible for transforming a Zibri WhereFilter into a TypeORM FindOptionsWhere. + */ + protected abstract whereFilterConverter: TypeOrmWhereFilterConverter | undefined; + + /** + * The configuration options of the data source. + */ + readonly options: Partial> = {}; + + /** + * The default configuration options of the data source. + */ + protected abstract readonly defaultOptions: OmitStrict; + + // eslint-disable-next-line jsdoc/require-returns + /** + * Combination of the default and user provided options. + */ + protected get fullOptions(): OmitStrict { + return { + ...this.defaultOptions, + ...this.options + }; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + readonly migrations: Newable[] = []; + + /** + * The internal typeorm data source. + */ + protected ds?: TODataSource; + + /** + * Mapping from a Zibri property type to a typeorm column type. + */ + protected readonly columnTypeMapping: Partial> = {}; + + /** + * Default mapping from a Zibri property type to a typeorm column type. + */ + protected abstract readonly defaultColumnTypeMapping: Record; + + // eslint-disable-next-line jsdoc/require-returns + /** + * Combination of the default and user provided column type mapping. + */ + protected get fullColumnTypeMapping(): Record { + return { + ...this.defaultColumnTypeMapping, + ...this.columnTypeMapping + }; + } + + private readonly repositories: Map, Repository> = new Map(); + + constructor( + @Inject(ZIBRI_DI_TOKENS.LOGGER) + protected readonly logger: LoggerInterface, + @Inject(ZIBRI_DI_TOKENS.AUTH_SERVICE) + protected readonly authService: AuthServiceInterface + ) {} + + abstract createBackupData(): Readable; + abstract restoreBackup(backupData: Readable): void | Promise; + + /** + * Transforms the given Zibri where filter to typeorm's FindOptionsWhere. + * @param filter - The filter to transform. + * @param entityClass - The entity class that the where filter is for. + * @returns TypeOrm's FindOptionsWhere. + * @throws When the data source hasn't been initialized yet. + */ + whereFilterToFindOptionsWhere( + filter: Where, + entityClass: Newable + ): Where extends WhereFilter[] ? ToFindOptionsWhere[] : ToFindOptionsWhere { + if (!this.whereFilterConverter) { + throw new Error('The data source needs to be initialized before it can be used.'); + } + return this.whereFilterConverter.convert(filter, entityClass) as Where extends WhereFilter[] + ? ToFindOptionsWhere[] + : ToFindOptionsWhere; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async init(): Promise { + if (this.ds) { + throw new Error('The data source has already been initialized.'); + } + + await this.validateOptions(this.fullOptions); + + for (const entityClass of this.entities) { + register({ + token: repositoryTokenFor(entityClass), + useFactory: () => this.getRepository(entityClass) + }); + } + + const schemas: EntitySchema[] = this.getEntitySchemas(); + this.ds = new TODataSource({ + ...this.fullOptions, + entities: schemas, + type: this.type, + synchronize: false + } as DataSourceOptions); + await this.ds.initialize(); + + this.beforeMigrations(); + + await this.runMigrations(); + + // Only skip if synchronize has been explicitly set to false + if (this.options.synchronize === false) { + return; + } + + await this.ds.synchronize(); + } + + protected abstract beforeMigrations(): void; + + // eslint-disable-next-line jsdoc/require-jsdoc + async shutDown(): Promise { + await this.ds?.destroy(); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async startTransaction(isolationLevel?: IsolationLevel): Promise { + if (!this.ds) { + throw new DataSourceInitializationError(); + } + + const runner: QueryRunner = this.createQueryRunner(); + try { + await runner.connect(); + await runner.startTransaction(isolationLevel); + return new TypeOrmTransaction(runner); + } + catch (error) { + await runner.release(); + throw error; + } + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async runMigrations(): Promise { + await this.createMigrationTableIfNotExists(); + + // we need to dynamically inject here because the repositories aren't ready in the constructor. + const migrationsRepository: Repository = inject(repositoryTokenFor(MigrationEntity)); + const finishedMigrationVersions: string[] = (await migrationsRepository.findAll()).map(m => m.version); + const allMigrations: MigrationWithName[] = this.migrations.map(m => ({ migration: inject(m), name: m.name })); + + const appVersion: Version | undefined = GlobalRegistry.getAppData('version'); + if (!appVersion) { + throw new Error('Couldn\'t run migrations: No app version could be resolved'); + } + + const migrationsToRunUp: MigrationWithName[] = allMigrations.filter(m => { + return !finishedMigrationVersions.includes(m.migration.version) + && compareVersion(m.migration.version, appVersion) !== 'bigger'; + }); + + const migrationsToRunDown: MigrationWithName[] = allMigrations.filter(m => { + return finishedMigrationVersions.includes(m.migration.version) + && compareVersion(m.migration.version, appVersion) === 'bigger'; + }); + + for (const migration of migrationsToRunUp) { + await this.logger.info(` > runs up migration ${migration.name}`); + await migration.migration.runUp(); + } + + for (const migration of migrationsToRunDown) { + await this.logger.info(` > runs down migration ${migration.name}`); + await migration.migration.runDown(); + } + + const skipped: number = allMigrations.length - migrationsToRunDown.length - migrationsToRunUp.length; + if (skipped) { + await this.logger.info(` > skipped ${skipped} migrations that have already been applied`); + } + } + + /** + * Creates a query runner. + * @returns A query runner. + * @throws When the data source has not been initialized yet. + */ + createQueryRunner(): QueryRunner { + if (!this.ds) { + throw new DataSourceInitializationError(); + } + return this.ds.createQueryRunner(); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + getRepository(cls: Newable): RepositoryTypeForEntity { + if (!this.ds) { + throw new DataSourceInitializationError(); + } + if (!this.entities.find(e => e === cls)) { + throw new Error(`The entity "${cls.name}" is not in this database. Did you forget to include it in the entities array?`); + } + + // eslint-disable-next-line stylistic/max-len + const existingRepository: RepositoryTypeForEntity | undefined = this.repositories.get(cls) as RepositoryTypeForEntity | undefined; + if (existingRepository) { + return existingRepository; + } + + const repo: TORepository = this.ds.getRepository(cls); + + if (isSoftDeleteEntityNewable(cls)) { + const res: RepositoryTypeForEntity = new SoftDeleteRepository( + cls, + repo as unknown as TORepository, + this.logger, + this, + getDefaultBeforeSaveHook(), + getDefaultBeforeReturnHook(), + this.authService, + this.getRepository(ChangeSet) + ) as RepositoryTypeForEntity; + this.repositories.set(cls, res as Repository); + return res; + } + if (isChangeSetEntityNewable(cls)) { + const res: RepositoryTypeForEntity = new ChangeSetRepository( + cls, + repo as unknown as TORepository, + this.logger, + this, + getDefaultBeforeSaveHook(), + getDefaultBeforeReturnHook(), + this.authService, + this.getRepository(ChangeSet) + ) as RepositoryTypeForEntity; + this.repositories.set(cls, res as Repository); + return res; + } + const res: RepositoryTypeForEntity = new Repository( + cls, + repo, + this.logger, + this, + getDefaultBeforeSaveHook(), + getDefaultBeforeReturnHook() + ) as RepositoryTypeForEntity; + this.repositories.set(cls, res as Repository); + return res; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async addPropertyToEntity( + entity: Newable, + key: keyof T, + transaction: Transaction + ): Promise { + const col: TableColumnOptions = this.propertyToTableColumnOptions(entity, key); + await transaction.queryRunner.addColumn( + TypeOrmUtilities.getEntityMetadata(entity, transaction).tableName, + new TableColumn({ ...col, isNullable: true }) + ); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async changePropertyOfEntity( + entity: Newable, + oldColumn: keyof T | string & {}, + newColumn: PropertyMetadataInput & { + /** + * The name of the new column. + */ + name?: keyof T, + /** + * The type of the new column. + */ + type: ExcludeStrict | FilePropertyMetadata>['type'] + }, + transaction: Transaction + ): Promise { + const entityMetadata: TOEntityMetadata = TypeOrmUtilities.getEntityMetadata(entity, transaction); + const columnMetadata: ColumnMetadata = TypeOrmUtilities.getColumnMetadata(entity, oldColumn, transaction); + + const col: TableColumnOptions = { + ...columnMetadata, + ...newColumn, + enum: 'enum' in newColumn && newColumn.enum + ? ObjectUtilities.values(newColumn.enum).map(v => String(v)) + : columnMetadata.enum + ? columnMetadata.enum.map(v => String(v)) + : undefined, + name: String(newColumn.name ?? oldColumn), + type: TypeOrmUtilities.normalizeColumnType( + this.ds, + { + precision: undefined, + scale: undefined, + ...columnMetadata, + ...newColumn, + type: this.fullColumnTypeMapping[newColumn.type] + } + ) + }; + + await transaction.queryRunner.changeColumn(entityMetadata.tableName, String(oldColumn), new TableColumn(col)); + } + + /** + * Gets entity schemas for the entities of this data source. + * @returns TypeOrm entity schemas. + */ + protected getEntitySchemas(): EntitySchema[] { + const schemas: EntitySchema[] = this.entities.map(e => this.createSchemaForEntity(e)); + return schemas; + } + + /** + * Transforms the property on the given entity class to typeorm column options. + * @param entity - The entity class which property should be transformed. + * @param property - The key of the actual property that should be transformed. + * @returns TypeOrm column options. + * @throws When no data source has been provided or no column metadata could be found. + */ + protected propertyToTableColumnOptions(entity: Newable, property: keyof T): TableColumnOptions { + if (!this.ds) { + throw new DataSourceInitializationError(); + } + const schema: EntitySchema = this.createSchemaForEntity(entity); + const metadata: TOEntityMetadata = this.ds.getMetadata(schema); + const col: ColumnMetadata | undefined = metadata.columns.find(c => c.propertyName === property); + + if (!col) { + throw new Error(`Could not determine column metadata for ${entity.name}.${String(property)}`); + } + + return { + name: col.databaseName, + ...col, + enum: col.enum ? col.enum?.map(v => String(v)) : undefined, + type: TypeOrmUtilities.normalizeColumnType( + this.ds, + { + isArray: col.isArray, + length: col.length, + precision: col.precision, + scale: col.scale, + type: col.type + } + ) + }; + } + + /** + * Creates a typeorm entity schema for a single entity. + * @param cls - The entity class to create the schema for. + * @returns A typeorm entity schema. + * @throws When the provided entity was configured incorrectly. + */ + protected createSchemaForEntity(cls: Newable): EntitySchema { + const entityMetadata: EntityMetadata | undefined = MetadataUtilities.getEntityMetadata(cls); + if (!entityMetadata) { + throw new EntityMetadataMissingError(cls); + } + const props: Record = MetadataUtilities.getModelProperties(cls); + + this.validateEntityClassMetadata(entityMetadata, props); + + const numberOfPrimaryKeys: number = ObjectUtilities + .values(props) + .filter(d => 'primary' in d && d.primary) + .length; + if (numberOfPrimaryKeys === 0) { + throw new Error(`no primary key specified for entity "${cls.name}".`); + } + if (numberOfPrimaryKeys > 1) { + throw new Error(`more than 1 primary key specified for entity "${cls.name}".`); + } + + const columns: Record = {}; + const relations: Record = {}; + for (const [key, m] of ObjectUtilities.entries(props)) { + if ( + m.type === Relation.MANY_TO_ONE + || m.type === Relation.ONE_TO_MANY + || m.type === Relation.HAS_ONE + || m.type === Relation.BELONGS_TO_ONE + || m.type === Relation.MANY_TO_MANY + ) { + relations[key] = this.propertyToRelationOptions(cls, key, m); + continue; + } + columns[key] = this.propertyToColumnOptions(m); + } + + return new EntitySchema({ + name: cls.name, + target: cls, + tableName: entityMetadata.tableName, + columns, + relations + }); + } + + /** + * Transforms the given relation metadata to typeorm relation options. + * @param cls + * @param key + * @param metadata - The relation metadata to transform. + * @returns TypeOrm relation options. + * @throws If a belongs to one or many to one relation hasn't specified a join column. + */ + protected abstract propertyToRelationOptions( + cls: Newable, + key: string, + metadata: RelationMetadata + ): EntitySchemaRelationOptions; + + /** + * Transforms the given property metadata to typeorm column options. + * @param metadata - The property metadata to transform. + * @returns TypeOrm column options. + * @throws When the metadata is incorrect. + */ + protected abstract propertyToColumnOptions( + metadata: ExcludeStrict> + ): EntitySchemaColumnOptions; + + /** + * Validates the entity class metadata and its properties. + * @param metadata - The entity class metadata. + * @param props - The properties of the entity class. + */ + // eslint-disable-next-line unusedImports/no-unused-vars + protected validateEntityClassMetadata(metadata: EntityMetadata, props: Record): void { + // do nothing by default. + } + + /** + * Validates the data source options. + * @param options - The full options to validate. + */ + // eslint-disable-next-line unusedImports/no-unused-vars + protected validateOptions(options: OmitStrict): void | Promise { + // do nothing by default. + } + + /** + * Creates a table for migrations if it does not exist already. + */ + protected async createMigrationTableIfNotExists(): Promise { + if (!this.ds) { + throw new DataSourceInitializationError(); + } + + const runner: QueryRunner = this.createQueryRunner(); + try { + await runner.connect(); + const schema: EntitySchema = this.createSchemaForEntity(MigrationEntity); + const metadata: TOEntityMetadata = this.ds.getMetadata(schema); + const table: Table = new Table({ + name: metadata.tablePath, + columns: metadata.columns.map(col => ({ + name: col.databaseName, + ...col, + enum: col.enum?.map(v => String(v)), + type: TypeOrmUtilities.normalizeColumnType(this.ds, col) + })) + }); + await runner.createTable(table, true); + + } + finally { + await runner.release(); + } + } +} \ No newline at end of file diff --git a/src/data-source/data-sources/where-converter/postgres-typeorm-where-filter.converter.ts b/src/data-source/data-sources/where-converter/postgres-typeorm-where-filter.converter.ts new file mode 100644 index 0000000..fa4f1bc --- /dev/null +++ b/src/data-source/data-sources/where-converter/postgres-typeorm-where-filter.converter.ts @@ -0,0 +1,451 @@ +import { Raw, FindOperator, DataSource as ToDataSource } from 'typeorm'; +import { RelationMetadata as ToRelationMetadata } from 'typeorm/metadata/RelationMetadata.js'; + +import { TypeOrmWhereFilterConverter } from './typeorm-where-filter.converter'; +import { BaseEntity } from '../../../entity/base-entity.model'; +import { EntityMetadata } from '../../../entity/decorators/entity.decorator'; +import { PropertyMetadata, RelationMetadata } from '../../../entity/decorators/property.decorator'; +import { EntityMetadataMissingError } from '../../../entity/entity-metadata-missing.error'; +import { Relation } from '../../../entity/models/relation.enum'; +import { Newable } from '../../../types/newable.type'; +import { MetadataUtilities } from '../../../utilities/metadata.utilities'; +import { WhereFilterKeys } from '../../models/where/where-filter-keys.model'; +import { Where } from '../../models/where/where-filter.model'; + +const ALIAS: string = '$$COL$$'; +const elemAlias: string = 'elem'; + +/** + * Handler function for a single where-filter key inside of jsonb. + */ +type JsonbOperatorHandler = ( + jsonPath: string, + textPath: string, + castPath: string, + value: unknown, + fieldKey: string, + propMeta: PropertyMetadata | undefined +) => string; + +/** + * Converter that transforms a Zibri WhereFilter into a TypeORM FindOptionsWhere for postgres. + */ +export class PostgresTypeOrmWhereFilterConverter extends TypeOrmWhereFilterConverter { + + private readonly jsonbOperatorHandlers: Record = { + is: (jp, _tp, _cp, val) => val === null + ? `${jp} = 'null'::jsonb` + : `${jp} @> ${this.toJsonbLiteral(val as object)}`, + not: (jp, _tp, cp, val) => { + if (val === null) { + return `${jp} IS NOT NULL`; + } + if (typeof val === 'object' && !Array.isArray(val)) { + return `NOT (${jp} @> ${this.toJsonbLiteral(val)})`; + } + return `${cp} != ${this.toSqlLiteral(val)}`; + }, + oneOf: (_jp, _tp, cp, val, fieldKey) => { + if (!Array.isArray(val)) { + throw new Error(`"oneOf" must be an array for JSONB field "${fieldKey}"`); + } + return `${cp} IN (${(val as unknown[]).map(v => this.toSqlLiteral(v)).join(', ')})`; + }, + notOneOf: (_jp, _tp, cp, val, fieldKey) => { + if (!Array.isArray(val)) { + throw new Error(`"notOneOf" must be an array for JSONB field "${fieldKey}"`); + } + return `${cp} NOT IN (${(val as unknown[]).map(v => this.toSqlLiteral(v)).join(', ')})`; + }, + like: (_jp, tp, _cp, val) => `${tp} LIKE ${this.toSqlLiteral(val)}`, + iLike: (_jp, tp, _cp, val) => `${tp} ILIKE ${this.toSqlLiteral(val)}`, + greaterThan: (_jp, _tp, cp, val) => `${cp} > ${this.toSqlLiteral(val)}`, + after: (_jp, _tp, cp, val) => `${cp} > ${this.toSqlLiteral(val)}`, + greaterThanEquals: (_jp, _tp, cp, val) => `${cp} >= ${this.toSqlLiteral(val)}`, + lesserThan: (_jp, _tp, cp, val) => `${cp} < ${this.toSqlLiteral(val)}`, + before: (_jp, _tp, cp, val) => `${cp} < ${this.toSqlLiteral(val)}`, + lesserThanEquals: (_jp, _tp, cp, val) => `${cp} <= ${this.toSqlLiteral(val)}`, + length: (jp, _tp, _cp, val) => `jsonb_array_length(${jp}) = ${this.toSqlLiteral(val)}`, + lengthGreaterThan: (jp, _tp, _cp, val) => `jsonb_array_length(${jp}) > ${this.toSqlLiteral(val)}`, + lengthGreaterThanEquals: (jp, _tp, _cp, val) => `jsonb_array_length(${jp}) >= ${this.toSqlLiteral(val)}`, + lengthLesserThan: (jp, _tp, _cp, val) => `jsonb_array_length(${jp}) < ${this.toSqlLiteral(val)}`, + lengthLesserThanEquals: (jp, _tp, _cp, val) => `jsonb_array_length(${jp}) <= ${this.toSqlLiteral(val)}`, + includes: (jp, _tp, _cp, val, fieldKey) => { + if (!Array.isArray(val)) { + throw new Error(`"includes" must be an array for JSONB field "${fieldKey}"`); + } + return `${jp} @> ${this.toJsonbLiteral(val)}`; + }, + isIncludedIn: (jp, _tp, _cp, val, fieldKey) => { + if (!Array.isArray(val)) { + throw new Error(`"isIncludedIn" must be an array for JSONB field "${fieldKey}"`); + } + return `${jp} <@ ${this.toJsonbLiteral(val)}`; + }, + where: (jp, _tp, _cp, val, fieldKey, propMeta) => this.buildJsonbWhereCondition(jp, val, propMeta, fieldKey) + }; + + constructor(dataSource: ToDataSource) { + super(dataSource); + } + + // ── JSONB-aware "where" handler ───────────────────────────── + + // eslint-disable-next-line jsdoc/require-jsdoc + protected whereHandler( + value: unknown, + metadata: PropertyMetadata, + nestedProperties: Record | undefined, + entityClass: Newable + ): FindOperator { + if (metadata.type !== 'object' && metadata.type !== 'array') { + return super.whereHandler(value, metadata, nestedProperties, entityClass); + } + + if (metadata.type === 'array') { + if (metadata.items.type !== 'object') { + throw new Error('The "where" operator on an array field requires an array of objects.'); + } + const itemMeta: Record = MetadataUtilities.getModelProperties(metadata.items.cls()); + const innerSql: string = this.buildJsonbCondition(elemAlias, value as Where>, itemMeta); + const sql: string = `EXISTS (SELECT 1 FROM jsonb_array_elements(${ALIAS}) AS ${elemAlias} WHERE ${innerSql})`; + return Raw(alias => { + const quotedAlias: string = alias.split('.').map(part => `"${part.replaceAll('"', '')}"`) + .join('.'); + return sql.replaceAll(ALIAS, quotedAlias); + }) as FindOperator; + } + + // object property + const nestedMeta: Record = MetadataUtilities.getModelProperties(metadata.cls()); + const sql: string = this.buildJsonbCondition(ALIAS, value as Where>, nestedMeta); + return Raw(alias => { + const quotedAlias: string = alias.split('.').map(part => `"${part.replaceAll('"', '')}"`) + .join('.'); + return sql.replaceAll(ALIAS, quotedAlias); + }) as FindOperator; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + protected buildRelationLengthRawOperator( + entityClass: Newable, + propertyName: string, + metadata: RelationMetadata, + lengthKey: string, + value: number + ): FindOperator { + const operator: string = this.lengthKeyToSqlOperator(lengthKey); + + if (metadata.type === Relation.ONE_TO_MANY) { + const targetEntity: Newable = metadata.target(); + const targetMeta: EntityMetadata | undefined = MetadataUtilities.getEntityMetadata(targetEntity); + if (!targetMeta) { + throw new EntityMetadataMissingError(targetEntity, `used in ${entityClass.name}.${propertyName}`); + } + const targetProps: Record = MetadataUtilities.getModelProperties(targetEntity); + const inverseProp: PropertyMetadata | undefined = targetProps[metadata.inverseSide]; + if (inverseProp?.type !== Relation.MANY_TO_ONE) { + throw new Error(`Could not find inverse many-to-one relation "${metadata.inverseSide}" on ${targetEntity.name}`); + } + const fkColumn: string | undefined = inverseProp.joinColumn; + const rawSql: string = `(SELECT COUNT(*) FROM "${targetMeta.tableName}" ` + + `WHERE "${targetMeta.tableName}"."${fkColumn}" = $alias$) ${operator} ${value}`; + return Raw(alias => rawSql.replaceAll('$alias$', alias)); + } + + // MANY_TO_MANY + const ormRelation: ToRelationMetadata | undefined = this.typeOrmDataSource + .getMetadata(entityClass) + .findRelationWithPropertyPath(propertyName); + if (ormRelation?.isManyToMany !== true || !ormRelation.junctionEntityMetadata) { + throw new Error(`Could not resolve many-to-many relation metadata for ${entityClass.name}.${propertyName}`); + } + const junctionTable: string = ormRelation.junctionEntityMetadata.tableName; + const ownFkColumn: string = ormRelation.junctionEntityMetadata.columns[0].databaseName; + const rawSql: string = `(SELECT COUNT(*) FROM "${junctionTable}" ` + + `WHERE "${junctionTable}"."${ownFkColumn}" = $alias$) ${operator} ${value}`; + return Raw(alias => rawSql.replaceAll('$alias$', alias)); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + protected buildRelationIncludesOperator( + entityClass: Newable, + propertyName: string, + metadata: RelationMetadata, + entities: BaseEntity[] + ): FindOperator { + if (entities.length === 0) { + return Raw(() => 'TRUE'); + } + + if (metadata.type === Relation.ONE_TO_MANY) { + const targetEntity: Newable = metadata.target(); + const targetMeta: EntityMetadata | undefined = MetadataUtilities.getEntityMetadata(targetEntity); + if (!targetMeta) { + throw new EntityMetadataMissingError(targetEntity, `used in ${entityClass.name}.${propertyName}`); + } + const targetProps: Record = MetadataUtilities.getModelProperties(targetEntity); + const inverseProp: PropertyMetadata | undefined = targetProps[metadata.inverseSide]; + if (inverseProp?.type !== Relation.MANY_TO_ONE) { + throw new Error(`Could not find inverse many-to-one relation "${metadata.inverseSide}" on ${targetEntity.name}`); + } + const fkColumn: string | undefined = inverseProp.joinColumn; + const ids: string = entities.map(e => `'${e.id}'`).join(', '); + const rawSql: string = `(SELECT COUNT(*) FROM "${targetMeta.tableName}" ` + + `WHERE "${targetMeta.tableName}"."${fkColumn}" = $alias$ ` + + `AND "${targetMeta.tableName}"."id" IN (${ids})) = ${entities.length}`; + return Raw(alias => rawSql.replaceAll('$alias$', alias)); + } + + // MANY_TO_MANY + const ormRelation: ToRelationMetadata | undefined = this.typeOrmDataSource + .getMetadata(entityClass) + .findRelationWithPropertyPath(propertyName); + if (ormRelation?.isManyToMany !== true || !ormRelation.junctionEntityMetadata) { + throw new Error(`Could not resolve many-to-many relation metadata for ${entityClass.name}.${propertyName}`); + } + const junctionTable: string = ormRelation.junctionEntityMetadata.tableName; + const ownFkColumn: string = ormRelation.junctionEntityMetadata.columns[0].databaseName; + const targetFkColumn: string = ormRelation.junctionEntityMetadata.columns[1].databaseName; + const ids: string = entities.map(e => `'${e.id}'`).join(', '); + const rawSql: string = `(SELECT COUNT(*) FROM "${junctionTable}" ` + + `WHERE "${junctionTable}"."${ownFkColumn}" = $alias$ ` + + `AND "${junctionTable}"."${targetFkColumn}" IN (${ids})) = ${entities.length}`; + return Raw(alias => rawSql.replaceAll('$alias$', alias)); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + protected buildRelationIsIncludedInOperator( + entityClass: Newable, + propertyName: string, + metadata: RelationMetadata, + entities: BaseEntity[] + ): FindOperator { + if (entities.length === 0) { + return Raw(() => 'FALSE'); + } + + if (metadata.type === Relation.ONE_TO_MANY) { + const targetEntity: Newable = metadata.target(); + const targetMeta: EntityMetadata | undefined = MetadataUtilities.getEntityMetadata(targetEntity); + if (!targetMeta) { + throw new EntityMetadataMissingError(targetEntity, `used in ${entityClass.name}.${propertyName}`); + } + const targetProps: Record = MetadataUtilities.getModelProperties(targetEntity); + const inverseProp: PropertyMetadata | undefined = targetProps[metadata.inverseSide]; + if (inverseProp?.type !== Relation.MANY_TO_ONE || !inverseProp.joinColumn) { + throw new Error(`Could not find inverse many-to-one relation "${metadata.inverseSide}" on ${targetEntity.name}`); + } + const fkColumn: string = inverseProp.joinColumn; + const ids: string = entities.map(e => `'${e.id}'`).join(', '); + const rawSql: string = `NOT EXISTS (SELECT 1 FROM "${targetMeta.tableName}" ` + + `WHERE "${targetMeta.tableName}"."${fkColumn}" = $alias$ ` + + `AND "${targetMeta.tableName}"."id" NOT IN (${ids}))`; + return Raw(alias => rawSql.replaceAll('$alias$', alias)); + } + + // MANY_TO_MANY + const ormRelation: ToRelationMetadata | undefined = this.typeOrmDataSource + .getMetadata(entityClass) + .findRelationWithPropertyPath(propertyName); + if (ormRelation?.isManyToMany !== true || !ormRelation.junctionEntityMetadata) { + throw new Error(`Could not resolve many-to-many relation metadata for ${entityClass.name}.${propertyName}`); + } + const junctionTable: string = ormRelation.junctionEntityMetadata.tableName; + const ownFkColumn: string = ormRelation.junctionEntityMetadata.columns[0].databaseName; + const targetFkColumn: string = ormRelation.junctionEntityMetadata.columns[1].databaseName; + const ids: string = entities.map(e => `'${e.id}'`).join(', '); + const rawSql: string = `NOT EXISTS (SELECT 1 FROM "${junctionTable}" ` + + `WHERE "${junctionTable}"."${ownFkColumn}" = $alias$ ` + + `AND "${junctionTable}"."${targetFkColumn}" NOT IN (${ids}))`; + return Raw(alias => rawSql.replaceAll('$alias$', alias)); + } + + // ── JSONB SQL generation (moved from PostgresDataSource) ──── + + private buildJsonbCondition( + jsonbAlias: string, + whereFilter: Where>, + propertyMetadataMap: Record + ): string { + if (Array.isArray(whereFilter)) { + return '(' + whereFilter + .map(f => `(${this.buildJsonbCondition(jsonbAlias, f, propertyMetadataMap)})`) + .join(' OR ') + ')'; + } + + const andParts: string[] = []; + for (const [key, filterValue] of Object.entries(whereFilter as Record)) { + const propMeta: PropertyMetadata = propertyMetadataMap[key]; + andParts.push(this.buildJsonbFieldCondition(jsonbAlias, key, filterValue, propMeta)); + } + return andParts.length > 0 ? andParts.join(' AND ') : 'TRUE'; + } + + private buildJsonbWhereCondition( + jsonPath: string, + val: unknown, + propMeta: PropertyMetadata | undefined, + fieldKey: string + ): string { + if (propMeta?.type === 'object') { + return this.buildJsonbCondition( + jsonPath, + val as Where>, + MetadataUtilities.getModelProperties(propMeta.cls()) + ); + } + if (propMeta?.type === 'array') { + if (propMeta.items.type !== 'object') { + throw new Error(`"where" on array field "${fieldKey}" requires object items`); + } + const itemMeta: Record = MetadataUtilities.getModelProperties(propMeta.items.cls()); + const elemAlias: string = `elem_${fieldKey}`; + return `EXISTS (SELECT 1 FROM jsonb_array_elements(${jsonPath}) AS ${elemAlias} WHERE ` + + this.buildJsonbCondition(elemAlias, val as Where>, itemMeta) + ')'; + } + throw new Error(`"where" operator used on a non-object, non-array JSONB field "${fieldKey}"`); + } + + private buildJsonbFieldCondition( + jsonbAlias: string, + fieldKey: string, + filterValue: unknown, + propMeta: PropertyMetadata | undefined + ): string { + // Guard against relations inside JSONB + if (propMeta && ( + propMeta.type === Relation.HAS_ONE + || propMeta.type === Relation.BELONGS_TO_ONE + || propMeta.type === Relation.MANY_TO_ONE + || propMeta.type === Relation.ONE_TO_MANY + || propMeta.type === Relation.MANY_TO_MANY + )) { + throw new Error(`Cannot filter on relation "${fieldKey}" inside a JSONB column. ` + + 'Relations are not supported as part of embedded JSON objects/arrays.'); + } + + const jsonPath: string = `${jsonbAlias}->'${fieldKey}'`; + const textPath: string = `${jsonbAlias}->>'${fieldKey}'`; + const cast: string = this.getJsonbCast(propMeta); + const castPath: string = `(${textPath})${cast}`; + + if (filterValue === null) { + return `${jsonPath} = 'null'::jsonb`; + } + + if (typeof filterValue === 'string' || typeof filterValue === 'number' + || typeof filterValue === 'boolean' || filterValue instanceof Date) { + return `${castPath} = ${this.toSqlLiteral(filterValue)}`; + } + + if (Array.isArray(filterValue)) { + // exact array match + const literal: string = this.toJsonbLiteral(filterValue); + return `(${jsonPath} @> ${literal} AND ${jsonPath} <@ ${literal})`; + } + + if (typeof filterValue !== 'object') { + throw new Error(`Unexpected JSONB filter value for field "${fieldKey}": ${JSON.stringify(filterValue)}`); + } + + return this.buildJsonbOperatorCondition(filterValue, jsonPath, castPath, fieldKey, textPath, propMeta); + } + + private buildJsonbOperatorCondition( + filterValue: object, + jsonPath: string, + castPath: string, + fieldKey: string, + textPath: string, + propMeta: PropertyMetadata | undefined + ): string { + const filterObj: Record = filterValue as Record; + const conditions: string[] = []; + + for (const [op, val] of Object.entries(filterObj)) { + const handler: JsonbOperatorHandler | undefined = this.jsonbOperatorHandlers[op as WhereFilterKeys]; + if (handler == undefined) { + throw new Error(`Unknown JSONB filter operator "${op}" on field "${fieldKey}"`); + } + conditions.push(handler(jsonPath, textPath, castPath, val, fieldKey, propMeta)); + } + + return conditions.length > 0 ? conditions.join(' AND ') : 'TRUE'; + } + + private toSqlLiteral(val: unknown): string { + if (val === null) { + return 'NULL'; + } + if (typeof val === 'boolean') { + return val ? 'TRUE' : 'FALSE'; + } + if (typeof val === 'number') { + return String(val); + } + if (val instanceof Date) { + return `'${val.toISOString()}'`; + } + if (typeof val === 'string') { + return `'${val.replaceAll('\'', '\'\'')}'`; + } + throw new Error(`Cannot convert value of type "${typeof val}" to a SQL literal`); + } + + private toJsonbLiteral(val: object): string { + return `'${JSON.stringify(val).replaceAll('\'', '\'\'')}'::jsonb`; + } + + private getJsonbCast(propMeta: PropertyMetadata | undefined): string { + if (!propMeta) { + return ''; + } + switch (propMeta.type) { + case 'number': { + return '::numeric'; + } + case 'date': { + return '::timestamptz'; + } + case 'boolean': { + return '::boolean'; + } + case 'string': + case 'object': + case Relation.HAS_ONE: + case Relation.BELONGS_TO_ONE: + case Relation.ONE_TO_MANY: + case Relation.MANY_TO_ONE: + case Relation.MANY_TO_MANY: + case 'array': + case 'file': + case 'unknown': { + return ''; + } + } + } + + private lengthKeyToSqlOperator(lengthKey: string): string { + switch (lengthKey) { + case 'length': { + return '='; + } + case 'lengthGreaterThan': { + return '>'; + } + case 'lengthGreaterThanEquals': { + return '>='; + } + case 'lengthLesserThan': { + return '<'; + } + case 'lengthLesserThanEquals': { + return '<='; + } + default: { + throw new Error(`Unknown length filter key: ${lengthKey}`); + } + } + } +} \ No newline at end of file diff --git a/src/data-source/data-sources/where-converter/typeorm-where-filter.converter.ts b/src/data-source/data-sources/where-converter/typeorm-where-filter.converter.ts new file mode 100644 index 0000000..7ea53bf --- /dev/null +++ b/src/data-source/data-sources/where-converter/typeorm-where-filter.converter.ts @@ -0,0 +1,376 @@ +import { And, ArrayContainedBy, ArrayContains, Equal, FindOperator, ILike, In, IsNull, LessThan, LessThanOrEqual, Like, MoreThan, MoreThanOrEqual, Not, Or, Raw, FindOptionsWhere as ToFindOptionsWhere, FindOptionsWhereProperty as ToFindOptionsWhereProperty, DataSource as ToDataSource } from 'typeorm'; + +import { BaseEntity } from '../../../entity/base-entity.model'; +import { PropertyMetadata, RelationMetadata } from '../../../entity/decorators/property.decorator'; +import { Relation } from '../../../entity/models/relation.enum'; +import { ExcludeStrict } from '../../../types/exclude-strict.type'; +import { Newable } from '../../../types/newable.type'; +import { MetadataUtilities } from '../../../utilities/metadata.utilities'; +import { ObjectUtilities } from '../../../utilities/object.utilities'; +import { ArrayWhereFilter } from '../../models/where/array-where-filter.model'; +import { isWhereFilterKey, WhereFilterKeys } from '../../models/where/where-filter-keys.model'; +import { Where, WhereFilter, WhereFilterProperty } from '../../models/where/where-filter.model'; + +// eslint-disable-next-line typescript/typedef +const lengthWhereFilterKeys = [ + 'length', + 'lengthGreaterThan', + 'lengthGreaterThanEquals', + 'lengthLesserThan', + 'lengthLesserThanEquals' +] as const satisfies (keyof ExcludeStrict, null | object[]>)[]; + +/** + * Handler function for a single where-filter key. + */ +export type WhereFilterHandler = ( + value: unknown, + metadata: PropertyMetadata, + nestedProperties: Record | undefined, + entityClass: Newable +) => FindOperator; + +/** + * Abstract base converter that transforms a Zibri WhereFilter into a TypeORM FindOptionsWhere. + */ +export abstract class TypeOrmWhereFilterConverter { + /** + * A map that defines how Zibri's where filter properties are mapped to their typeorm counterpart. + */ + protected handleFilterKeyMap: Record = { + not: (value) => Not(value), + like: (value) => Like(value), + oneOf: (value) => { + if (!Array.isArray(value)) { + throw new Error('The "oneOf" property of the where filter needs to be an array.'); + } + return In(value); + }, + notOneOf: (value) => { + if (!Array.isArray(value)) { + throw new Error('The "notOneOf" property of the where filter needs to be an array.'); + } + return Not(In(value)); + }, + after: (value) => MoreThan(value), + before: (value) => LessThan(value), + greaterThan: (value) => MoreThan(value), + greaterThanEquals: (value) => MoreThanOrEqual(value), + lesserThan: (value) => LessThan(value), + lesserThanEquals: (value) => LessThanOrEqual(value), + iLike: (value) => ILike(value), + is: (value, metadata) => { + if (value === null) { + return IsNull(); + } + // Unwrap a full entity for MANY_TO_ONE / BELONGS_TO_ONE + if ( + (metadata.type === Relation.MANY_TO_ONE + || metadata.type === Relation.BELONGS_TO_ONE) + && value !== null + && typeof value === 'object' + && !Array.isArray(value) + && 'id' in value + ) { + return Equal(value.id); + } + return Equal(value); + }, + where: (value, metadata, nestedProperties, entityClass) => this.whereHandler(value, metadata, nestedProperties, entityClass), + includes: (value) => { + if (!Array.isArray(value)) { + throw new Error('The "includes" property of the where filter needs to be an array.'); + } + return ArrayContains(value); + }, + isIncludedIn: (value) => { + if (!Array.isArray(value)) { + throw new Error('The "isIncludedIn" property of the where filter needs to be an array.'); + } + return ArrayContainedBy(value); + }, + length: (value) => Raw(alias => `array_length(${alias}, 1) = ${value}`), + lengthGreaterThan: (value) => Raw(alias => `array_length(${alias}, 1) > ${value}`), + lengthGreaterThanEquals: (value) => Raw(alias => `array_length(${alias}, 1) >= ${value}`), + lengthLesserThan: (value) => Raw(alias => `array_length(${alias}, 1) < ${value}`), + lengthLesserThanEquals: (value) => Raw(alias => `array_length(${alias}, 1) <= ${value}`) + }; + + constructor(protected readonly typeOrmDataSource: ToDataSource) {} + + // ── Public entry point (concrete) ────────────────────────── + /** + * Converts the given zibri filter to a typeorm filter. + * @param filter - The zibri filter to convert. + * @param entityClass - The entity class to search for by this filter. + * @returns The typeorm filter. + */ + convert( + filter: Where, + entityClass: Newable + ): ToFindOptionsWhere | ToFindOptionsWhere[] { + const properties: Record = MetadataUtilities.getModelProperties(entityClass); + if (Array.isArray(filter)) { + return filter.map(f => this.singleWhereFilterToFindOptionsWhere(f, properties, entityClass)); + } + return this.singleWhereFilterToFindOptionsWhere(filter, properties, entityClass); + } + + /** + * Handles converting a where filter to a typeorm FindOperator. + * @param value - The value of the where filter. + * @param metadata - The metadata of the property for which the where filter has been specified. + * @param nestedProperties - Nested properties on the where filter. Need to exist. + * @param entityClass - The entity class that the where filter belongs to. + * @returns A typeorm FindOperator. + * @throws Wheen now nested properties have been found. + */ + protected whereHandler( + value: unknown, + metadata: PropertyMetadata, + nestedProperties: Record | undefined, + entityClass: Newable + ): FindOperator { + if (nestedProperties == undefined) { + throw new Error('The "where" operator is not supported on this property without nested metadata.'); + } + let targetClass: Newable = entityClass; + if (metadata.type === 'object') { + targetClass = metadata.cls(); + } + else if ( + metadata.type === Relation.HAS_ONE || metadata.type === Relation.BELONGS_TO_ONE + || metadata.type === Relation.MANY_TO_ONE || metadata.type === Relation.ONE_TO_MANY + || metadata.type === Relation.MANY_TO_MANY + ) { + targetClass = metadata.target(); + } + return this.singleWhereFilterToFindOptionsWhere( + value as WhereFilter>, + nestedProperties, + targetClass as Newable> + ) as unknown as FindOperator; + } + + // ── Pipeline stages (concrete, overridable) ───────────────── + + /** + * Converts a single where filter to a typeorm FindOptionsWhere property. + * @param filter - The filter to convert. + * @param properties - The property metadata of the model that the filter is for. + * @param entityClass - The entity class to search for by this filter. + * @returns A typeorm FindOptionsWhere property. + */ + protected singleWhereFilterToFindOptionsWhere( + filter: WhereFilter, + properties: Record, + entityClass: Newable + ): ToFindOptionsWhere { + const res: ToFindOptionsWhere = {}; + const extraRawConditions: FindOperator[] = []; + + for (const key of ObjectUtilities.keys(filter)) { + const prop: WhereFilterProperty | WhereFilterProperty[] | undefined = filter[key]; + if (prop === undefined) { + continue; + } + + const propertyMetadata: PropertyMetadata = properties[key]; + let nestedProperties: Record | undefined; + + if (propertyMetadata.type === Relation.ONE_TO_MANY || propertyMetadata.type === Relation.MANY_TO_MANY) { + this.processRelationFilter(key, prop, propertyMetadata, entityClass, res, extraRawConditions); + continue; + } + + if (propertyMetadata.type === 'object') { + nestedProperties = MetadataUtilities.getModelProperties(propertyMetadata.cls()); + } + else if ( + propertyMetadata.type === Relation.HAS_ONE + || propertyMetadata.type === Relation.BELONGS_TO_ONE + || propertyMetadata.type === Relation.MANY_TO_ONE + ) { + nestedProperties = MetadataUtilities.getModelProperties(propertyMetadata.target()); + } + + res[key] = this.propertyToFindOperator( + prop, + propertyMetadata, + nestedProperties, + entityClass as Newable]> + ) as typeof key extends 'toString' ? unknown : ToFindOptionsWhereProperty>; + } + + if (extraRawConditions.length) { + const existingIdFilter: FindOperator | undefined = res['id' as keyof T] as FindOperator | undefined; + const combined: FindOperator = existingIdFilter + ? And(existingIdFilter, ...extraRawConditions) + : And(...extraRawConditions); + (res as Record>)['id'] = combined; + } + + return res; + } + + private processRelationFilter( + key: string, + prop: WhereFilterProperty]> | WhereFilterProperty]>[], + propertyMetadata: RelationMetadata, + entityClass: Newable, + res: ToFindOptionsWhere, + extraRawConditions: FindOperator[] + ): void { + const filterObj: Record = prop as Record; + + for (const lengthKey of lengthWhereFilterKeys) { + if (lengthKey in filterObj && typeof filterObj[lengthKey] === 'number') { + extraRawConditions.push( + this.buildRelationLengthRawOperator( + entityClass, key, propertyMetadata, lengthKey, filterObj[lengthKey] + ) + ); + // eslint-disable-next-line typescript/no-dynamic-delete + delete filterObj[lengthKey]; + } + } + + // 2) includes / isIncludedIn + if ('includes' in filterObj && Array.isArray(filterObj.includes)) { + extraRawConditions.push( + this.buildRelationIncludesOperator( + entityClass, key, propertyMetadata, filterObj.includes as BaseEntity[] + ) + ); + delete filterObj.includes; + } + if ('isIncludedIn' in filterObj && Array.isArray(filterObj.isIncludedIn)) { + extraRawConditions.push( + this.buildRelationIsIncludedInOperator( + entityClass, key, propertyMetadata, filterObj.isIncludedIn as BaseEntity[] + ) + ); + delete filterObj.isIncludedIn; + } + + // 3) Remaining element filters (e.g. where) + if (Object.keys(filterObj).length > 0) { + const targetEntity: Newable = propertyMetadata.target(); + const nestedProps: Record = MetadataUtilities.getModelProperties(targetEntity); + (res as Record)[key] = this.propertyToFindOperator( + filterObj as WhereFilterProperty | WhereFilterProperty[], + propertyMetadata, + nestedProps, + entityClass + ); + } + } + + /** + * Converts a where filter property to a typeorm FindOperator property. + * @param property - The property to convert. + * @param propertyMetadata - The property metadata of the property. + * @param nestedProperties - Nested properties, if any. + * @param entityClass - The entity class that the property is on. + * @returns A typeorm FindOperator property. + */ + protected propertyToFindOperator( + property: WhereFilterProperty | WhereFilterProperty[], + propertyMetadata: PropertyMetadata, + nestedProperties: Record | undefined, + entityClass: Newable + ): FindOperator { + if (Array.isArray(property)) { + return propertyMetadata.type === 'array' + ? this.singlePropertyToFindOperator(property as WhereFilterProperty, propertyMetadata, nestedProperties, entityClass) + : Or( + ...property.map( + p => this.singlePropertyToFindOperator( + p as WhereFilterProperty, + propertyMetadata, + nestedProperties, + entityClass + ) + ) + ); + } + return this.singlePropertyToFindOperator(property, propertyMetadata, nestedProperties, entityClass); + } + + /** + * Transforms a single where filter property to a typeorm FindOperator. + * @param property - The where filter property to transform. + * @param propertyMetadata - The metadata of the where filter property. + * @param nestedProperties - Any nested properties of the where filter. + * @param entityClass - The entity class that the property is on. + * @returns A typeorm FindOperator. + * @throws When the where filter property is invalid. + */ + protected singlePropertyToFindOperator( + property: WhereFilterProperty, + propertyMetadata: PropertyMetadata, + nestedProperties: Record | undefined, + entityClass: Newable + ): FindOperator { + if (property === null) { + // eslint-disable-next-line typescript/no-unsafe-return + return IsNull(); + } + if ( + typeof property === 'string' + || typeof property === 'bigint' + || typeof property === 'number' + || typeof property === 'boolean' + || property instanceof Date + || (Array.isArray(property) && propertyMetadata.type === 'array') + ) { + return Equal(property as T); + } + + const operators: FindOperator[] = []; + const filterKeys: unknown[] = ObjectUtilities.keys(property); + if (!filterKeys.length) { + throw new Error('Empty where filter'); + } + + for (const key of filterKeys) { + if (!isWhereFilterKey(key)) { + throw new Error(`Unknown key "${key}" on where filter ${property}`); + } + const handler: WhereFilterHandler = this.handleFilterKeyMap[key]; + const value: unknown = (property as Record)[key]; + const op: FindOperator = handler(value, propertyMetadata, nestedProperties, entityClass); + if (op !== undefined) { + operators.push(op as FindOperator); + } + } + + return operators.length === 1 + ? operators[0] + : And(...operators); + } + + // ── Abstract – DB‑specific building blocks ─────────────────── + + protected abstract buildRelationLengthRawOperator( + entityClass: Newable, + propertyName: string, + metadata: RelationMetadata, + lengthKey: string, + value: number + ): FindOperator; + + protected abstract buildRelationIncludesOperator( + entityClass: Newable, + propertyName: string, + metadata: RelationMetadata, + entities: BaseEntity[] + ): FindOperator; + + protected abstract buildRelationIsIncludedInOperator( + entityClass: Newable, + propertyName: string, + metadata: RelationMetadata, + entities: BaseEntity[] + ): FindOperator; +} \ No newline at end of file diff --git a/src/data-source/exclude-property.test.ts b/src/data-source/exclude-property.test.ts index efe022b..bb3e2fe 100644 --- a/src/data-source/exclude-property.test.ts +++ b/src/data-source/exclude-property.test.ts @@ -26,8 +26,11 @@ class Order { @Property.string({ exclude: true }) internalNote!: string; - @Property.manyToOne({ target: () => User, inverseSide: 'orders' }) + @Property.manyToOne({ target: () => User, joinColumn: 'userId', inverseSide: 'orders' }) user!: unknown; + + @Property.string({ format: 'uuid' }) + userId!: string; } class User extends BaseEntity { diff --git a/src/data-source/hooks/hooks.test.ts b/src/data-source/hooks/hooks.test.ts new file mode 100644 index 0000000..fadf9f3 --- /dev/null +++ b/src/data-source/hooks/hooks.test.ts @@ -0,0 +1,268 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'; + +import { createTestDataSource, defaultTestServerEntities } from '../../__testing__/test-server/create-test-data-source.function'; +import { StartedTestServer, startTestServer } from '../../__testing__/test-server/start-test-server.function'; +import { ChangeSetEntity } from '../../change-sets/models/change-set-entity.model'; +import { ChangeSetType } from '../../change-sets/models/change-set-type.enum'; +import { ChangeSet } from '../../change-sets/models/change-set.model'; +import { SoftDeleteEntity } from '../../change-sets/models/soft-delete-entity.model'; +import { SoftDeleteRepository } from '../../change-sets/soft-delete-repository'; +import { repositoryTokenFor } from '../../di/decorators/inject-repository.decorator'; +import { inject } from '../../di/inject.function'; +import { BaseEntity } from '../../entity/base-entity.model'; +import { Entity } from '../../entity/decorators/entity.decorator'; +import { Property } from '../../entity/decorators/property.decorator'; +import { PostgresDataSource } from '../data-sources/postgres-typeorm-data-source.model'; +import { Repository } from '../repository'; + +// ==================== TEST ENTITIES ==================== + +// Entity that tests ALL hook-related features +@Entity() +class HookTestEntity extends BaseEntity implements ChangeSetEntity, SoftDeleteEntity { + @Property.string() + name!: string; + + @Property.string({ encryption: true, exclude: true }) + secretValue!: string; // encrypted on save, decrypted on read, excluded from response + + @Property.string({ hash: true }) + password!: string; // hashed on save + + @Property.boolean({ default: true }) + isActive!: boolean; // default value set on create + + @Property.string({ required: false, excludeFromChangeSets: true }) + internalNote?: string; // excluded from change sets + + @Property.boolean({ default: false }) + deleted!: boolean; // required by SoftDeleteEntity + + @Property.oneToMany({ target: () => ChangeSet, inverseSide: 'changeSetEntityId' }) + changeSets!: ChangeSet[]; // required by ChangeSetEntity +} + +let server: StartedTestServer; +let repo: Repository; +let changeSetRepo: Repository; + +beforeAll(async () => { + server = await startTestServer({ + dataSources: [ + createTestDataSource({ + entities: [...defaultTestServerEntities, HookTestEntity, ChangeSet] + }) + ] + }); + repo = inject(repositoryTokenFor(HookTestEntity)); + changeSetRepo = inject(repositoryTokenFor(ChangeSet)); +}, 15000); + +afterAll(async () => { + await server.shutdown(); +}); + +beforeEach(async () => { + await changeSetRepo.deleteAll({}); + await repo.deleteAll({}); +}); + +describe('before‑save hooks', () => { + describe('on create', () => { + it('encrypts properties marked with encryption', async () => { + const entity: HookTestEntity = await repo.create({ + name: 'Test', + secretValue: 'top-secret', + password: 'plain' + }); + + // secretValue should be encrypted (not the plain text) + expect(entity.secretValue).toBe('top-secret'); + }); + + it('hashes properties marked with hash', async () => { + const entity: HookTestEntity = await repo.create({ + name: 'Test', + secretValue: 'top-secret', + password: 'my-password' + }); + + // password should be hashed (not the plain text) + expect(entity.password).not.toBe('my-password'); + expect(entity.password).toContain('$'); // hash contains algorithm prefix + }); + + it('sets default values when not provided', async () => { + const entity: HookTestEntity = await repo.create({ + name: 'Test', + secretValue: 'top-secret', + password: 'plain' + }); + + // isActive should default to true + expect(entity.isActive).toBe(true); + }); + + it('does NOT override explicitly provided values with defaults', async () => { + const entity: HookTestEntity = await repo.create({ + name: 'Test', + secretValue: 'top-secret', + password: 'plain', + isActive: false + }); + + expect(entity.isActive).toBe(false); + }); + + it('encrypts values at rest (raw database access)', async () => { + const entity: HookTestEntity = await repo.create({ + name: 'EncAtRest', + secretValue: 'super-secret', + password: 'pw' + }); + + // Use the query builder to read the raw column – no before‑return hook runs + const raw: HookTestEntity | null = await (repo.dataSource as PostgresDataSource) + .query(HookTestEntity) + .select(`${HookTestEntity.name}.secretValue`) + .where(`${HookTestEntity.name}.id = :id`, { id: entity.id }) + .getOne(); + + // The stored value must be encrypted (not plain text) + expect(raw?.secretValue).not.toBe('super-secret'); + expect(raw?.secretValue.length).toBeGreaterThan(20); + }); + }); + + describe('on update', () => { + let entityId: string; + + beforeEach(async () => { + const entity: HookTestEntity = await repo.create({ + name: 'Original', + secretValue: 'initial-secret', + password: 'initial-password' + }); + entityId = entity.id; + }); + + it('encrypts updated encrypted properties', async () => { + const updated: HookTestEntity = await repo.updateById(entityId, { secretValue: 'new-secret' }); + expect(updated.secretValue).toBe('new-secret'); + }); + + it('hashes updated hash properties', async () => { + const updated: HookTestEntity = await repo.updateById(entityId, { password: 'new-password' }); + expect(updated.password).not.toBe('new-password'); + expect(updated.password).toContain('$'); + }); + + it('does NOT re‑set default values (setDefault = false)', async () => { + // Update without providing isActive + const updated: HookTestEntity = await repo.updateById(entityId, { name: 'Updated' }); + // isActive should remain its original value (true from create's default) + expect(updated.isActive).toBe(true); + }); + }); +}); + +describe('before‑return hooks', () => { + describe('on find', () => { + let entityId: string; + let originalSecret: string; + + beforeEach(async () => { + // We need the entity as stored (with encrypted/hashed values) before the return hook runs. + // So we create one and capture what findById returns after decryption. + const entity: HookTestEntity = await repo.create({ + name: 'ReturnTest', + secretValue: 'my-secret', + password: 'my-password' + }); + entityId = entity.id; + // After create, before‑return has already run, so we see decrypted values. + originalSecret = entity.secretValue; + }); + + it('decrypts encrypted properties on findById', async () => { + const fetched: HookTestEntity = await repo.findById(entityId); + // secretValue should be decrypted (equal to the original plain text after re‑encryption/decryption) + expect(fetched.secretValue).toBe(originalSecret); + }); + + it('decrypts encrypted properties on findAll', async () => { + const results: HookTestEntity[] = await repo.findAll(); + expect(results).toHaveLength(1); + expect(results[0].secretValue).toBe(originalSecret); + }); + + it('decrypts encrypted properties on findOne', async () => { + const fetched: HookTestEntity = await repo.findOne({ where: { id: entityId } }, true); + expect(fetched.secretValue).toBe(originalSecret); + }); + + it('removes excluded properties from the returned entity', async () => { + const fetched: HookTestEntity = await repo.findById(entityId); + expect(fetched.secretValue).toBe(originalSecret); + expect(JSON.stringify(fetched)).not.toContain('secretValue'); + }); + }); +}); + +describe('ChangeSetRepository hooks integration', () => { + it('excludes properties marked with excludeFromChangeSets from change sets', async () => { + const entity: HookTestEntity = await repo.create({ + name: 'ChangeSetTest', + secretValue: 'test-secret', + password: 'test-password', + internalNote: 'do not track' + }); + + const changeSets: ChangeSet[] = await changeSetRepo.findAll({ where: { changeSetEntityId: entity.id } }); + expect(changeSets).toHaveLength(1); + + const cs: ChangeSet = changeSets[0]; + // internalNote should NOT appear in changes + expect(cs.changes.some(c => c.key === 'internalNote')).toBe(false); + // other properties should be tracked + expect(cs.changes.some(c => c.key === 'name')).toBe(true); + }); + + it('records change sets for create and update operations', async () => { + const entity: HookTestEntity = await repo.create({ + name: 'TrackingTest', + secretValue: 'secret', + password: 'pass' + }); + + // Update name + await repo.updateById(entity.id, { name: 'Updated' }); + + const changeSets: ChangeSet[] = await changeSetRepo.findAll({ + where: { changeSetEntityId: entity.id }, + order: { createdAt: 'ASC' } + }); + expect(changeSets).toHaveLength(2); + expect(changeSets[0].type).toBe(ChangeSetType.CREATE); + expect(changeSets[1].type).toBe(ChangeSetType.UPDATE); + }); +}); + +describe('SoftDeleteRepository hooks integration', () => { + it('excludes "deleted" from change sets', async () => { + const entity: HookTestEntity = await repo.create({ + name: 'SoftDeleteTest', + secretValue: 'secret', + password: 'pass' + }); + + // Soft delete + await (repo as unknown as SoftDeleteRepository).deleteById(entity.id); + + const changeSets: ChangeSet[] = await changeSetRepo.findAll({ where: { changeSetEntityId: entity.id } }); + const deleteCs: ChangeSet | undefined = changeSets.find(cs => cs.type === ChangeSetType.DELETE); + expect(deleteCs).toBeDefined(); + // deleted flag should NOT appear in changes + expect(deleteCs?.changes.some(c => c.key === 'deleted')).toBe(false); + }); +}); \ No newline at end of file diff --git a/src/data-source/migration/migration.test.ts b/src/data-source/migration/migration.test.ts index 7d6d063..ecabdbf 100644 --- a/src/data-source/migration/migration.test.ts +++ b/src/data-source/migration/migration.test.ts @@ -19,8 +19,9 @@ import { Entity } from '../../entity/decorators/entity.decorator'; import { Property } from '../../entity/decorators/property.decorator'; import { GlobalRegistry } from '../../global/global-registry'; import { Newable } from '../../types/newable.type'; +import { OmitStrict } from '../../types/omit-strict.type'; import { Version } from '../../types/version.type'; -import { PostgresDataSource, PostgresOptions } from '../data-sources/postgres-data-source.model'; +import { PostgresDataSource, PostgresOptions } from '../data-sources/postgres-typeorm-data-source.model'; import { DataSource } from '../decorators/data-source.decorator'; import { Repository } from '../repository'; import { Transaction } from '../transaction/transaction.model'; @@ -33,7 +34,7 @@ class LegacyItem { @DataSource() class LegacyDbDataSource extends PostgresDataSource { - options: PostgresOptions = { + options: OmitStrict = { host: 'localhost', username: 'postgres', password: 'password', @@ -54,7 +55,7 @@ class Item { @DataSource() class DbDataSource extends PostgresDataSource { - options: PostgresOptions = { + options: OmitStrict = { host: 'localhost', username: 'postgres', password: 'password', diff --git a/src/data-source/models/options/count-options.model.ts b/src/data-source/models/options/count-options.model.ts index b1bf6f9..9f274e9 100644 --- a/src/data-source/models/options/count-options.model.ts +++ b/src/data-source/models/options/count-options.model.ts @@ -1,14 +1,9 @@ import { BaseRepositoryOptions } from './base-repository-options.model'; +import { FindAllOptions } from './find-all-options.model'; import { BaseEntity } from '../../../entity/base-entity.model'; -import { Where } from '../where/where-filter.model'; /** * Options for counting entities. */ export type CountOptions = BaseRepositoryOptions - & { - /** - * The where filter to count the options by. - */ - where?: Where - }; \ No newline at end of file + & Pick, 'where'>; \ No newline at end of file diff --git a/src/data-source/models/options/delete-all-options.model.ts b/src/data-source/models/options/delete-all-options.model.ts index b242799..8c517c5 100644 --- a/src/data-source/models/options/delete-all-options.model.ts +++ b/src/data-source/models/options/delete-all-options.model.ts @@ -1,8 +1,6 @@ -import { FindAllOptions } from './find-all-options.model'; -import { BaseEntity } from '../../../entity/base-entity.model'; -import { OmitStrict } from '../../../types/omit-strict.type'; +import { BaseRepositoryOptions } from './base-repository-options.model'; /** * Options for deleting multiple entities. */ -export type DeleteAllOptions = OmitStrict, 'where'>; \ No newline at end of file +export type DeleteAllOptions = BaseRepositoryOptions; \ No newline at end of file diff --git a/src/data-source/models/options/find-all-options.model.ts b/src/data-source/models/options/find-all-options.model.ts index 7f3840a..3f48579 100644 --- a/src/data-source/models/options/find-all-options.model.ts +++ b/src/data-source/models/options/find-all-options.model.ts @@ -1,4 +1,4 @@ -import { FindManyOptions } from 'typeorm'; +import { FindManyOptions, FindOptionsRelations } from 'typeorm'; import { BaseRepositoryOptions } from './base-repository-options.model'; import { BaseEntity } from '../../../entity/base-entity.model'; @@ -17,5 +17,5 @@ export type FindAllOptions = BaseRepositoryOptions /** * The relations to include in the found entities. */ - relations?: (keyof T)[] + relations?: FindOptionsRelations | (keyof T)[] }; \ No newline at end of file diff --git a/src/data-source/models/options/find-by-id-options.model.ts b/src/data-source/models/options/find-by-id-options.model.ts index 2a57fff..87d55f1 100644 --- a/src/data-source/models/options/find-by-id-options.model.ts +++ b/src/data-source/models/options/find-by-id-options.model.ts @@ -5,9 +5,4 @@ import { OmitStrict } from '../../../types/omit-strict.type'; /** * Options for finding a single entity by its id. */ -export type FindByIdOptions = OmitStrict, 'where' | 'relations'> & { - /** - * The relations to include in the found entity. - */ - relations?: (keyof T)[] -}; \ No newline at end of file +export type FindByIdOptions = OmitStrict, 'where'>; \ No newline at end of file diff --git a/src/data-source/models/options/find-one-options.model.ts b/src/data-source/models/options/find-one-options.model.ts index 38b7dbe..f5b10e2 100644 --- a/src/data-source/models/options/find-one-options.model.ts +++ b/src/data-source/models/options/find-one-options.model.ts @@ -1,18 +1,9 @@ import { BaseRepositoryOptions } from './base-repository-options.model'; +import { FindAllOptions } from './find-all-options.model'; import { BaseEntity } from '../../../entity/base-entity.model'; -import { Where } from '../where/where-filter.model'; /** * Options for finding a single entity. */ export type FindOneOptions = BaseRepositoryOptions - & { - /** - * The where filter to find the entity by. - */ - where?: Where, - /** - * The relations to include in the found entity. - */ - relations?: (keyof T)[] - }; \ No newline at end of file + & Pick, 'where' | 'relations'>; \ No newline at end of file diff --git a/src/data-source/models/options/update-all-options.model.ts b/src/data-source/models/options/update-all-options.model.ts index ebaafe4..82d3605 100644 --- a/src/data-source/models/options/update-all-options.model.ts +++ b/src/data-source/models/options/update-all-options.model.ts @@ -4,9 +4,6 @@ import { BaseRepositoryOptions } from './base-repository-options.model'; * Options for updating multiple entities at once. */ export type UpdateAllOptions = BaseRepositoryOptions & { - /** - * Whether or not setting the id property manually is allowed. - * @default false. - */ - allowId?: boolean + // should never be allowed, as that would cause typeorm's manager.save method to create new entities instead of updating the existing ones. + // allowId?: boolean }; \ No newline at end of file diff --git a/src/data-source/models/options/update-by-id-options.model.ts b/src/data-source/models/options/update-by-id-options.model.ts index 9cfb11c..87f14fa 100644 --- a/src/data-source/models/options/update-by-id-options.model.ts +++ b/src/data-source/models/options/update-by-id-options.model.ts @@ -4,9 +4,6 @@ import { BaseRepositoryOptions } from './base-repository-options.model'; * Options for updating an entity by its id. */ export type UpdateByIdOptions = BaseRepositoryOptions & { - /** - * Whether or not setting the id property manually is allowed. - * @default false. - */ - allowId?: boolean + // should never be allowed, as that would cause typeorm's manager.save method to create a new entity instead of updating the existing one. + // allowId?: boolean }; \ No newline at end of file diff --git a/src/data-source/models/where/array-where-filter.model.ts b/src/data-source/models/where/array-where-filter.model.ts index 85b028e..dfa50b3 100644 --- a/src/data-source/models/where/array-where-filter.model.ts +++ b/src/data-source/models/where/array-where-filter.model.ts @@ -1,13 +1,13 @@ +import { Where, WhereFilterProperty } from './where-filter.model'; /** * A filter for an array where property. */ -export type ArrayWhereFilter = null | { +export type ArrayWhereFilter = null | ItemType[] | { /** - * The property needs to equal the value. + * The property needs to be not this value. */ - equals: ItemType[] -} | { + not?: null | ItemType[], /** * The property needs to include the value. */ @@ -15,5 +15,147 @@ export type ArrayWhereFilter = null | { /** * The properties items needs to be included in the value. */ - isIncludedIn?: ItemType[] + isIncludedIn?: ItemType[], + /** + * The length that this array needs to have. + */ + length?: number, + /** + * The length of this array needs to be greater than the provided value. + */ + lengthGreaterThan?: number, + /** + * The length of this array needs to be greater than or equal to the provided value. + */ + lengthGreaterThanEquals?: number, + /** + * The length of this array needs to be lesser than the provided value. + */ + lengthLesserThan?: number, + /** + * The length of this array needs to be lesser than or equal to the provided value. + */ + lengthLesserThanEquals?: number, + /** + * A where condition that at least one of the items inside this array needs to match. + */ + where?: never +} +| { + /** + * The property needs to be not this value. + */ + not?: never, + /** + * The property needs to include the value. + */ + includes?: never, + /** + * The properties items needs to be included in the value. + */ + isIncludedIn?: never, + /** + * The length that this array needs to have. + */ + length?: number, + /** + * The length of this array needs to be greater than the provided value. + */ + lengthGreaterThan?: number, + /** + * The length of this array needs to be greater than or equal to the provided value. + */ + lengthGreaterThanEquals?: number, + /** + * The length of this array needs to be lesser than the provided value. + */ + lengthLesserThan?: number, + /** + * The length of this array needs to be lesser than or equal to the provided value. + */ + lengthLesserThanEquals?: number, + /** + * A where condition that at least one of the items inside this array needs to match. + */ + where: WhereFilterProperty | WhereFilterProperty[] +}; + +/** + * A filter for an array of object items where property. + */ +export type ObjectArrayWhereFilter = null | ItemType[] | { + /** + * The property needs to be not this value. + */ + not?: null | ItemType[], + /** + * The property needs to include the value. + */ + includes?: ItemType[], + /** + * The properties items needs to be included in the value. + */ + isIncludedIn?: ItemType[], + /** + * The length that this array needs to have. + */ + length?: number, + /** + * The length of this array needs to be greater than the provided value. + */ + lengthGreaterThan?: number, + /** + * The length of this array needs to be greater than or equal to the provided value. + */ + lengthGreaterThanEquals?: number, + /** + * The length of this array needs to be lesser than the provided value. + */ + lengthLesserThan?: number, + /** + * The length of this array needs to be lesser than or equal to the provided value. + */ + lengthLesserThanEquals?: number, + /** + * A where condition that at least one of the items inside this array needs to match. + */ + where?: never +} +| { + /** + * The property needs to be not this value. + */ + not?: never, + /** + * The property needs to include the value. + */ + includes?: never, + /** + * The properties items needs to be included in the value. + */ + isIncludedIn?: never, + /** + * The length that this array needs to have. + */ + length?: number, + /** + * The length of this array needs to be greater than the provided value. + */ + lengthGreaterThan?: number, + /** + * The length of this array needs to be greater than or equal to the provided value. + */ + lengthGreaterThanEquals?: number, + /** + * The length of this array needs to be lesser than the provided value. + */ + lengthLesserThan?: number, + /** + * The length of this array needs to be lesser than or equal to the provided value. + */ + lengthLesserThanEquals?: number, + /** + * A where condition that at least one of the items inside this array needs to match. + */ + where: Where }; \ No newline at end of file diff --git a/src/data-source/models/where/base-where-filter.model.ts b/src/data-source/models/where/base-where-filter.model.ts index 8bdcad6..0a3ede3 100644 --- a/src/data-source/models/where/base-where-filter.model.ts +++ b/src/data-source/models/where/base-where-filter.model.ts @@ -1,4 +1,22 @@ +/** + * The base where filter object. + */ +export type BaseWhereFilterObject = { + /** + * The property needs to be not this value. + */ + not?: T | null, + /** + * The property needs to be one of the values in the array. + */ + oneOf?: (T | null)[], + /** + * The property needs to be NOT one of the values in the array. + */ + notOneOf?: (T | null)[] +}; + /** * A base where filter, shared by all property types. */ -export type BaseWhereFilter = T | null; \ No newline at end of file +export type BaseWhereFilter> = T | null | FilterObject; \ No newline at end of file diff --git a/src/data-source/models/where/boolean-where-filter.model.ts b/src/data-source/models/where/boolean-where-filter.model.ts index 2041a61..78ba464 100644 --- a/src/data-source/models/where/boolean-where-filter.model.ts +++ b/src/data-source/models/where/boolean-where-filter.model.ts @@ -1,6 +1,11 @@ -import { BaseWhereFilter } from './base-where-filter.model'; +import { BaseWhereFilter, BaseWhereFilterObject } from './base-where-filter.model'; +import { ExcludeStrict } from '../../../types/exclude-strict.type'; +import { OmitStrict } from '../../../types/omit-strict.type'; /** * A filter for a boolean where property. */ -export type BooleanWhereFilter = BaseWhereFilter; \ No newline at end of file +export type BooleanWhereFilter = ExcludeStrict< + BaseWhereFilter>, + BaseWhereFilterObject +> | Required, 'oneOf' | 'notOneOf'>>; \ No newline at end of file diff --git a/src/data-source/models/where/date-where-filter.model.ts b/src/data-source/models/where/date-where-filter.model.ts index 4d8d092..317c51f 100644 --- a/src/data-source/models/where/date-where-filter.model.ts +++ b/src/data-source/models/where/date-where-filter.model.ts @@ -1,21 +1,9 @@ -import { BaseWhereFilter } from './base-where-filter.model'; +import { BaseWhereFilter, BaseWhereFilterObject } from './base-where-filter.model'; /** - * A filter for a date where property. + * The date where filter object. */ -export type DateWhereFilter = BaseWhereFilter | { - /** - * The property needs to be not this value. - */ - not?: Date, - /** - * The property needs to be one of the values in the array. - */ - oneOf?: Date[], - /** - * The property needs to be NOT one of the values in the array. - */ - notOneOf?: Date[], +type DateFilterWhereObject = BaseWhereFilterObject & { /** * The property needs to be after the value. */ @@ -24,4 +12,9 @@ export type DateWhereFilter = BaseWhereFilter | { * The property needs to be before the value. */ before?: Date -}; \ No newline at end of file +}; + +/** + * A filter for a date where property. + */ +export type DateWhereFilter = BaseWhereFilter; \ No newline at end of file diff --git a/src/data-source/models/where/number-where-filter.model.ts b/src/data-source/models/where/number-where-filter.model.ts index 0c36d84..3d91a7c 100644 --- a/src/data-source/models/where/number-where-filter.model.ts +++ b/src/data-source/models/where/number-where-filter.model.ts @@ -1,21 +1,9 @@ -import { BaseWhereFilter } from './base-where-filter.model'; +import { BaseWhereFilter, BaseWhereFilterObject } from './base-where-filter.model'; /** - * A filter for a number where property. + * The number where filter object. */ -export type NumberWhereFilter = BaseWhereFilter | { - /** - * The property needs to be not this value. - */ - not?: T, - /** - * The property needs to be one of the values in the array. - */ - oneOf?: T[], - /** - * The property needs to be NOT one of the values in the array. - */ - notOneOf?: T[], +type NumberWhereFilterObject = BaseWhereFilterObject & { /** * The property needs to be greater than the provided value. */ @@ -32,4 +20,9 @@ export type NumberWhereFilter = BaseWhereFilter | * The property needs to be lesser than or equal to the provided value. */ lesserThanEquals?: T -}; \ No newline at end of file +}; + +/** + * A filter for a number where property. + */ +export type NumberWhereFilter = BaseWhereFilter>; \ No newline at end of file diff --git a/src/data-source/models/where/object-where-filter.model.ts b/src/data-source/models/where/object-where-filter.model.ts index 0512ba1..c9ca2ab 100644 --- a/src/data-source/models/where/object-where-filter.model.ts +++ b/src/data-source/models/where/object-where-filter.model.ts @@ -3,10 +3,69 @@ import { Where } from './where-filter.model'; /** * A filter for a object where property. */ -export type ObjectWhereFilter = null - // eslint-disable-next-line jsdoc/require-jsdoc - | { equals: T, where?: never, not?: never, oneOf?: never, notOneOf?: never } - // eslint-disable-next-line jsdoc/require-jsdoc - | { equals?: never, where: Where, not?: never, oneOf?: never, notOneOf?: never } - // eslint-disable-next-line jsdoc/require-jsdoc - | { equals?: never, where?: never, not?: T, oneOf?: T[], notOneOf?: T[] }; \ No newline at end of file +export type ObjectWhereFilter = { + /** + * The property needs to equal the value. + */ + is: T | null, + /** + * A where condition that the property needs to match. + */ + where?: never, + /** + * The property needs to be not this value. + */ + not?: never, + /** + * The property should be one of the provided values. + */ + oneOf?: never, + /** + * The property should NOT be one of the provided values. + */ + notOneOf?: never +} +| { + /** + * The property needs to equal the value. + */ + is?: never, + /** + * A where condition that the property needs to match. + */ + where: Where, + /** + * The property needs to be not this value. + */ + not?: never, + /** + * The property should be one of the provided values. + */ + oneOf?: never, + /** + * The property should NOT be one of the provided values. + */ + notOneOf?: never +} +| { + /** + * The property needs to equal the value. + */ + is?: never, + /** + * A where condition that the property needs to match. + */ + where?: never, + /** + * The property needs to be not this value. + */ + not?: T | null, + /** + * The property should be one of the provided values. + */ + oneOf?: (T | null)[], + /** + * The property should NOT be one of the provided values. + */ + notOneOf?: (T | null)[] +}; \ No newline at end of file diff --git a/src/data-source/models/where/string-where-filter.model.ts b/src/data-source/models/where/string-where-filter.model.ts index 9977deb..66e7863 100644 --- a/src/data-source/models/where/string-where-filter.model.ts +++ b/src/data-source/models/where/string-where-filter.model.ts @@ -1,21 +1,9 @@ -import { BaseWhereFilter } from './base-where-filter.model'; +import { BaseWhereFilter, BaseWhereFilterObject } from './base-where-filter.model'; /** - * A filter for a string where property. + * The string where filter object. */ -export type StringWhereFilter = BaseWhereFilter | { - /** - * The property needs to be not this value. - */ - not?: string, - /** - * The property needs to be one of the values in the array. - */ - oneOf?: string[], - /** - * The property needs to be NOT one of the values in the array. - */ - notOneOf?: string[], +type StringWhereFilterObject = BaseWhereFilterObject & { /** * The property needs to be like the provided string. * @example '%.com' @@ -26,4 +14,9 @@ export type StringWhereFilter = BaseWhereFilter | { * @example '%.cOm' */ iLike?: string -}; \ No newline at end of file +}; + +/** + * A filter for a string where property. + */ +export type StringWhereFilter = BaseWhereFilter; \ No newline at end of file diff --git a/src/data-source/models/where/where-filter-keys.model.ts b/src/data-source/models/where/where-filter-keys.model.ts new file mode 100644 index 0000000..59ab4da --- /dev/null +++ b/src/data-source/models/where/where-filter-keys.model.ts @@ -0,0 +1,88 @@ + +import { ArrayWhereFilter, ObjectArrayWhereFilter } from './array-where-filter.model'; +import { BooleanWhereFilter } from './boolean-where-filter.model'; +import { DateWhereFilter } from './date-where-filter.model'; +import { NumberWhereFilter } from './number-where-filter.model'; +import { ObjectWhereFilter } from './object-where-filter.model'; +import { StringWhereFilter } from './string-where-filter.model'; +import { ExcludeStrict } from '../../../types/exclude-strict.type'; +import { ObjectUtilities } from '../../../utilities/object.utilities'; + +/** + * Where filter keys of object properties. + */ +type ObjectWhereFilterKeys = keyof ObjectWhereFilter; + +/** + * Where filter keys of array properties. + */ +type ArrayWhereFilterKeys = keyof ExcludeStrict, null | object[]>; + +/** + * Where filter keys of object array properties. + */ +type ObjectArrayWhereFilterKeys = keyof ExcludeStrict, null | object[]>; + +/** + * Where filter keys of boolean properties. + */ +type BooleanWhereFilterKeys = keyof BooleanWhereFilter; + +/** + * Where filter keys of date properties. + */ +type DateWhereFilterKeys = keyof ExcludeStrict; + +/** + * Where filter keys of number properties. + */ +type NumberWhereFilterKeys = keyof ExcludeStrict, null | (number | bigint)>; + +/** + * Where filter keys of string properties. + */ +type StringWhereFilterKeys = keyof ExcludeStrict; + +/** + * All where filter keys. + */ +export type WhereFilterKeys = ArrayWhereFilterKeys + | ObjectArrayWhereFilterKeys + | BooleanWhereFilterKeys + | DateWhereFilterKeys + | NumberWhereFilterKeys + | ObjectWhereFilterKeys + | StringWhereFilterKeys; + +const whereFilterKeysRecord: Record = { + not: 'not', + like: 'like', + oneOf: 'oneOf', + notOneOf: 'notOneOf', + after: 'after', + before: 'before', + greaterThan: 'greaterThan', + greaterThanEquals: 'greaterThanEquals', + lesserThan: 'lesserThan', + lesserThanEquals: 'lesserThanEquals', + iLike: 'iLike', + is: 'is', + where: 'where', + includes: 'includes', + isIncludedIn: 'isIncludedIn', + length: 'length', + lengthGreaterThan: 'lengthGreaterThan', + lengthLesserThanEquals: 'lengthLesserThanEquals', + lengthGreaterThanEquals: 'lengthGreaterThanEquals', + lengthLesserThan: 'lengthLesserThan' +}; +const whereFilterKeySet: Set = new Set(ObjectUtilities.values(whereFilterKeysRecord)); + +// eslint-disable-next-line jsdoc/require-returns +/** + * Checks if the given key is a where filter key. + * @param key - The key to check. + */ +export function isWhereFilterKey(key: unknown): key is WhereFilterKeys { + return whereFilterKeySet.has(key as WhereFilterKeys); +} \ No newline at end of file diff --git a/src/data-source/models/where/where-filter-to-find-options-where-function.test.ts b/src/data-source/models/where/where-filter-to-find-options-where-function.test.ts index ede10dd..71a0336 100644 --- a/src/data-source/models/where/where-filter-to-find-options-where-function.test.ts +++ b/src/data-source/models/where/where-filter-to-find-options-where-function.test.ts @@ -1,12 +1,35 @@ /* eslint-disable unicorn/no-null */ -import { describe, expect, it } from '@jest/globals'; -import { EqualOperator, FindOptionsWhere, FindOperator, Equal, Raw } from 'typeorm'; +import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'; +import { EqualOperator, FindOptionsWhere, FindOperator, Equal, FindOptionsWhere as ToFindOptionsWhere } from 'typeorm'; -import { whereFilterToFindOptionsWhere } from './where-filter-to-find-options-where.function'; -import { Where } from './where-filter.model'; +import { Where, WhereFilter } from './where-filter.model'; import { Address } from '../../../__testing__/mocks/entities/address.model'; import { User } from '../../../__testing__/mocks/entities/user.entity'; -import { JsonUtilities } from '../../../utilities/json.utilities'; +import { createTestDataSource } from '../../../__testing__/test-server/create-test-data-source.function'; +import { StartedTestServer, startTestServer } from '../../../__testing__/test-server/start-test-server.function'; +import { inject } from '../../../di/inject.function'; +import { Newable } from '../../../types/newable.type'; +import { DataSource } from '../../decorators/data-source.decorator'; + +@DataSource() +class DbDataSource extends createTestDataSource() {} + +function whereFilterToFindOptionsWhere( + filter: Where, + entityClass: Newable +): Where extends WhereFilter[] ? ToFindOptionsWhere[] : ToFindOptionsWhere { + return inject(DbDataSource).whereFilterToFindOptionsWhere(filter, entityClass); +} + +let server: StartedTestServer; + +beforeAll(async () => { + server = await startTestServer({ dataSources: [DbDataSource] }); +}, 15000); + +afterAll(async () => { + await server.shutdown(); +}); describe('whereFilterToFindOptionsWhere - primitive filters', () => { it('string equality', () => { @@ -80,16 +103,15 @@ describe('whereFilterToFindOptionsWhere - date filters', () => { }); describe('whereFilterToFindOptionsWhere - object filters', () => { - it('nested where on json fields yields Raw operator', () => { + it('nested where on json fields yields Raw operator with inline SQL', () => { const filter: Where = { address: { where: { street: 'Main St' } } }; const result: FindOptionsWhere = whereFilterToFindOptionsWhere(filter, User); - const expectedResult: FindOptionsWhere = { - address: Raw
( - alias => `${alias} @> :json`, - { json: { street: Equal('Main St') } } - ) - }; - expect(JsonUtilities.stringify(result)).toEqual(JsonUtilities.stringify(expectedResult)); + + expect(result.address).toBeInstanceOf(FindOperator); + const raw: FindOperator
= result.address as FindOperator
; + + const sql: string | undefined = raw.getSql?.('"TestAlias"."address"'); + expect(sql).toEqual('("TestAlias"."address"->>\'street\') = \'Main St\''); }); it('nested where on relation fields yields nested filter', () => { const filter: Where = { company: { where: { id: '42' } } }; diff --git a/src/data-source/models/where/where-filter-to-find-options-where.function.ts b/src/data-source/models/where/where-filter-to-find-options-where.function.ts deleted file mode 100644 index 2dc72a7..0000000 --- a/src/data-source/models/where/where-filter-to-find-options-where.function.ts +++ /dev/null @@ -1,307 +0,0 @@ -import assert from 'node:assert'; - -import { FindOptionsWhere as ToFindOptionsWhere, FindOptionsWhereProperty as ToFindOptionsWhereProperty, Or, FindOperator, Equal, IsNull, Not, And, Like, In, MoreThan, LessThan, MoreThanOrEqual, LessThanOrEqual, ILike, ArrayContains, ArrayContainedBy, Raw } from 'typeorm'; - -import { ArrayWhereFilter } from './array-where-filter.model'; -import { BaseWhereFilter } from './base-where-filter.model'; -import { DateWhereFilter } from './date-where-filter.model'; -import { NumberWhereFilter } from './number-where-filter.model'; -import { ObjectWhereFilter } from './object-where-filter.model'; -import { StringWhereFilter } from './string-where-filter.model'; -import { WhereFilter, Where, WhereFilterProperty } from './where-filter.model'; -import { PropertyMetadata } from '../../../entity/decorators/property.decorator'; -import { ObjectPropertyMetadata } from '../../../entity/models/object-property-metadata.model'; -import { Relation } from '../../../entity/models/relation.enum'; -import { ExcludeStrict } from '../../../types/exclude-strict.type'; -import { Newable } from '../../../types/newable.type'; -import { MetadataUtilities } from '../../../utilities/metadata.utilities'; -import { ObjectUtilities } from '../../../utilities/object.utilities'; - -/** - * Transforms the given Zibri where filter to typeorm's FindOptionsWhere. - * @param filter - The filter to transform. - * @param entityClass - The entity class that the where filter is for. - * @returns Typeorm's FindOptionsWhere. - */ -export function whereFilterToFindOptionsWhere( - filter: Where, - entityClass: Newable -): Where extends WhereFilter[] ? ToFindOptionsWhere[] : ToFindOptionsWhere { - const properties: Record = MetadataUtilities.getModelProperties(entityClass); - if (Array.isArray(filter)) { - const values: ToFindOptionsWhere[] = []; - for (const f of filter) { - values.push(singleWhereFilterToFindOptionsWhere(f, properties)); - } - return values as Where extends WhereFilter[] ? ToFindOptionsWhere[] : ToFindOptionsWhere; - } - // eslint-disable-next-line stylistic/max-len - return singleWhereFilterToFindOptionsWhere(filter, properties) as Where extends WhereFilter[] ? ToFindOptionsWhere[] : ToFindOptionsWhere; -} - -/** - * Transforms a single Zibri where filter to typeorm FindOptionsWhere. - * @param filter - The filter to transform. - * @param properties - The properties of the entity that the filter is for. - * @returns Typeorm FindOptionsWhere. - */ -function singleWhereFilterToFindOptionsWhere( - filter: WhereFilter, - properties: Record -): ToFindOptionsWhere { - const res: ToFindOptionsWhere = {}; - for (const key of ObjectUtilities.keys(filter)) { - const prop: WhereFilterProperty | WhereFilterProperty[] | undefined = filter[key]; - if (prop === undefined) { - continue; - } - - const propertyMetadata: PropertyMetadata = properties[key]; - let nestedProperties: Record | undefined; - - switch (propertyMetadata.type) { - case 'object': { - nestedProperties = MetadataUtilities.getModelProperties(propertyMetadata.cls()); - break; - } - case Relation.ONE_TO_ONE: - case Relation.MANY_TO_ONE: { - nestedProperties = MetadataUtilities.getModelProperties(propertyMetadata.target()); - break; - } - case 'string': - case 'number': - case 'boolean': - case Relation.ONE_TO_MANY: - case Relation.MANY_TO_MANY: - case 'array': - case 'date': - case 'file': - case 'unknown': - default: { - break; - } - } - res[key] = propertyToFindOperator( - prop, - propertyMetadata, - nestedProperties - ) as typeof key extends 'toString' ? unknown : ToFindOptionsWhereProperty>; - } - return res; -} - -/** - * Transforms a property filter or multiple property filters to a typeorm FindOperator. - * @param property - The property filter to transform. - * @param propertyMetadata - The metadata of the property. - * @param nestedProperties - Any nested properties of the where filter. - * @returns The typeorm FindOperator. - */ -function propertyToFindOperator( - property: WhereFilterProperty | WhereFilterProperty[], - propertyMetadata: PropertyMetadata, - nestedProperties: Record | undefined -): FindOperator { - if (Array.isArray(property)) { - return Or(...property.map(p => singlePropertyToFindOperator(p, propertyMetadata, nestedProperties))); - } - return singlePropertyToFindOperator(property, propertyMetadata, nestedProperties); -} - -/** - * Where filter keys of object properties. - */ -type ObjectWhereFilterKeys = ( - keyof ExcludeStrict< - ObjectWhereFilter, - // eslint-disable-next-line jsdoc/require-jsdoc - null | { equals: object } | { where: Where } - > -) | 'equals' | 'where'; - -/** - * Where filter keys of array properties. - */ -type ArrayWhereFilterKeys = ( - keyof ExcludeStrict< - ArrayWhereFilter, - // eslint-disable-next-line jsdoc/require-jsdoc - null | { equals: object[] } - > -) | 'equals'; - -/** - * All where filter keys. - */ -type WhereFilterKeys = ArrayWhereFilterKeys - | keyof ExcludeStrict> - | keyof ExcludeStrict, BaseWhereFilter> - | ObjectWhereFilterKeys - | keyof ExcludeStrict>; - -const whereFilterKeysRecord: Record = { - not: 'not', - like: 'like', - oneOf: 'oneOf', - notOneOf: 'notOneOf', - after: 'after', - before: 'before', - greaterThan: 'greaterThan', - greaterThanEquals: 'greaterThanEquals', - lesserThan: 'lesserThan', - lesserThanEquals: 'lesserThanEquals', - iLike: 'iLike', - equals: 'equals', - where: 'where', - includes: 'includes', - isIncludedIn: 'isIncludedIn' -}; -const whereFilterKeySet: Set = new Set(ObjectUtilities.values(whereFilterKeysRecord)); - -/** - * Transforms a single where filter property to a typeorm FindOperator. - * @param property - The where filter property to transform. - * @param propertyMetadata - The metadata of the where filter property. - * @param nestedProperties - Any nested properties of the where filter. - * @returns A typeorm FindOperator. - * @throws When the where filter property is invalid. - */ -// eslint-disable-next-line sonar/cognitive-complexity -function singlePropertyToFindOperator( - property: WhereFilterProperty, - propertyMetadata: PropertyMetadata, - nestedProperties: Record | undefined -): FindOperator { - if (property === null) { - // eslint-disable-next-line typescript/no-unsafe-return - return IsNull(); - } - if ( - typeof property === 'string' - || typeof property === 'number' - || typeof property === 'boolean' - || property instanceof Date - ) { - return Equal(property as T); - } - - const operators: FindOperator[] = []; - const filterKeys: (keyof (WhereFilterProperty & {}))[] = ObjectUtilities.keys(property); - if (!filterKeys.length) { - throw new Error('Empty where filter'); - } - for (const key of filterKeys) { - if (!isWhereFilterKey(key)) { - throw new Error(`Unknown key "${key.toString()}" on where filter ${property}`); - } - const value: unknown = (property as Record)[key]; - - switch (key) { - case 'not': { - operators.push(Not(value)); - break; - } - case 'like': { - operators.push(Like(value)); - break; - } - case 'oneOf': { - if (!Array.isArray(value)) { - throw new Error(`The "oneOf" property of the where filter ${property} needs to be an array.`); - } - operators.push(In(value)); - break; - } - case 'notOneOf': { - if (!Array.isArray(value)) { - throw new Error(`The "notOneOf" property of the where filter ${property} needs to be an array.`); - } - operators.push(Not(In(value))); - break; - } - case 'after': { - operators.push(MoreThan(value)); - break; - } - case 'before': { - operators.push(LessThan(value)); - break; - } - case 'greaterThan': { - operators.push(MoreThan(value)); - break; - } - case 'greaterThanEquals': { - operators.push(MoreThanOrEqual(value)); - break; - } - case 'lesserThan': { - operators.push(LessThan(value)); - break; - } - case 'lesserThanEquals': { - operators.push(LessThanOrEqual(value)); - break; - } - case 'iLike': { - operators.push(ILike(value)); - break; - } - case 'equals': { - operators.push(Equal(value)); - break; - } - case 'where': { - // eslint-disable-next-line jsdoc/require-jsdoc - const whereFilter: { where: Where> } = property as { where: Where> }; - const isJson: boolean = propertyMetadata.type === 'object'; - if (isJson) { - const nestedLiteral: ToFindOptionsWhere> = whereFilterToFindOptionsWhere( - whereFilter.where, - (propertyMetadata as ObjectPropertyMetadata).cls() as Newable - ); - return Raw( - alias => `${alias} @> :json`, - { json: nestedLiteral } - ) as FindOperator; - } - // nestedProperties is guaranteed here since ONE_TO_ONE/MANY_TO_ONE always sets it - assert(nestedProperties != undefined); - return singleWhereFilterToFindOptionsWhere( - whereFilter.where as WhereFilter>, - nestedProperties - ) as unknown as FindOperator; - } - case 'includes': { - if (!Array.isArray(value)) { - throw new Error(`The "includes" property of the where filter ${property} needs to be an array.`); - } - operators.push(ArrayContains(value)); - break; - } - case 'isIncludedIn': { - if (!Array.isArray(value)) { - throw new Error(`The "isIncludedIn" property of the where filter ${property} needs to be an array.`); - } - operators.push(ArrayContainedBy(value)); - break; - } - } - } - - if (operators.length === 1) { - return operators[0] as FindOperator; - } - - return And(...operators) as FindOperator; -} - -// eslint-disable-next-line jsdoc/require-returns -/** - * Checks if the given key is a where filter key. - * @param key - The key to check. - */ -function isWhereFilterKey(key: unknown): key is WhereFilterKeys { - return whereFilterKeySet.has(key as WhereFilterKeys); -} \ No newline at end of file diff --git a/src/data-source/models/where/where-filter.model.ts b/src/data-source/models/where/where-filter.model.ts index 2871dea..fa7a596 100644 --- a/src/data-source/models/where/where-filter.model.ts +++ b/src/data-source/models/where/where-filter.model.ts @@ -1,4 +1,4 @@ -import { ArrayWhereFilter } from './array-where-filter.model'; +import { ArrayWhereFilter, ObjectArrayWhereFilter } from './array-where-filter.model'; import { BooleanWhereFilter } from './boolean-where-filter.model'; import { DateWhereFilter } from './date-where-filter.model'; import { NumberWhereFilter } from './number-where-filter.model'; @@ -28,9 +28,12 @@ export type WhereFilterProperty = T extends bigint ? NumberWhereFilter : T extends boolean ? BooleanWhereFilter - // eslint-disable-next-line typescript/no-explicit-any - : T extends any[] - ? ArrayWhereFilter + : T extends (infer ItemType)[] + ? ItemType extends Date + ? ArrayWhereFilter + : ItemType extends object + ? ObjectArrayWhereFilter + : ArrayWhereFilter : T extends Date ? DateWhereFilter : T extends object diff --git a/src/data-source/nested-where-filter.test.ts b/src/data-source/nested-where-filter.test.ts new file mode 100644 index 0000000..a22bf10 --- /dev/null +++ b/src/data-source/nested-where-filter.test.ts @@ -0,0 +1,344 @@ +/* eslint-disable unicorn/no-null */ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'; + +import { createTestDataSource, defaultTestServerEntities } from '../__testing__/test-server/create-test-data-source.function'; +import { startTestServer, StartedTestServer } from '../__testing__/test-server/start-test-server.function'; +import { WhereFilter } from '../data-source/models/where/where-filter.model'; +import { Repository } from '../data-source/repository'; +import { repositoryTokenFor } from '../di/decorators/inject-repository.decorator'; +import { inject } from '../di/inject.function'; +import { BaseEntity } from '../entity/base-entity.model'; +import { Entity } from '../entity/decorators/entity.decorator'; +import { Property } from '../entity/decorators/property.decorator'; + +// ---------- Entity definitions for deeply nested structure ---------- +class Address { + @Property.string() + street!: string; + + @Property.string() + city!: string; + + @Property.string({ required: false }) + type?: string; // 'home', 'work', etc. +} + +class PersonalData { + @Property.string() + favoriteColor!: string; + + @Property.number() + height!: number; + + @Property.array({ items: { type: 'object', cls: () => Address }, required: false }) + addresses: Address[] | undefined | null; +} + +@Entity() +class Customer extends BaseEntity { + @Property.string() + name!: string; + + @Property.object({ cls: () => PersonalData, required: false }) + personalData: PersonalData | undefined | null; + + @Property.string({ required: false }) + notes: string | null | undefined; +} + +class Tag { + @Property.string() + key!: string; + + @Property.string() + value!: string; +} + +class Item { + @Property.string() + name!: string; + + @Property.array({ items: { type: 'object', cls: () => Tag }, required: false }) + tags: Tag[] | undefined | null; +} + +@Entity() +class Container extends BaseEntity { + @Property.string() + title!: string; + + @Property.array({ items: { type: 'object', cls: () => Item }, required: false }) + items: Item[] | undefined | null; +} + +let server: StartedTestServer; +let customerRepo: Repository; + +beforeAll(async () => { + server = await startTestServer({ + dataSources: [ + createTestDataSource({ + entities: [...defaultTestServerEntities, Customer, Container] + }) + ] + }); + customerRepo = inject(repositoryTokenFor(Customer)); +}, 15000); + +afterAll(async () => { + await server.shutdown(); +}); + +beforeEach(async () => { + await customerRepo.deleteAll({}); +}); + +// Seed helpers +async function seedCustomers(...data: Partial[]): Promise { + return Promise.all(data.map(d => customerRepo.create(d))); +} + +describe('Where filters – deeply nested (object → array → object)', () => { + let alice: Customer; + let bob: Customer; + + beforeEach(async () => { + [alice, bob] = await seedCustomers( + { + name: 'Alice', + personalData: { + favoriteColor: 'blue', + height: 165, + addresses: [ + { street: '123 Main St', city: 'Springfield', type: 'home' }, + { street: '456 Work Ave', city: 'Springfield', type: 'work' } + ] + } + }, + { + name: 'Bob', + personalData: { + favoriteColor: 'red', + height: 180, + // eslint-disable-next-line cspell/spellchecker + addresses: [{ street: '789 Oak Rd', city: 'Shelbyville', type: 'home' }] + } + } + ); + }); + + // ===== Top-level simple field ===== + it('top-level string equals', async () => { + const res: Customer[] = await customerRepo.findAll({ where: { name: 'Alice' } }); + expect(res.map(c => c.id)).toEqual([alice.id]); + }); + + // ===== Nested object (PersonalData) ===== + it('nested object field equals', async () => { + const res: Customer[] = await customerRepo.findAll({ + where: { personalData: { where: { favoriteColor: 'blue' } } } + }); + expect(res.map(c => c.id)).toEqual([alice.id]); + }); + + it('nested object field not equal', async () => { + const res: Customer[] = await customerRepo.findAll({ + where: { personalData: { where: { favoriteColor: { not: 'blue' } } } } + }); + expect(res.map(c => c.id)).toEqual([bob.id]); + }); + + it('nested object field oneOf', async () => { + const res: Customer[] = await customerRepo.findAll({ + where: { personalData: { where: { favoriteColor: { oneOf: ['blue', 'green'] } } } } + }); + expect(res.map(c => c.id)).toEqual([alice.id]); + }); + + it('nested object field greaterThan on number', async () => { + const res: Customer[] = await customerRepo.findAll({ + where: { personalData: { where: { height: { greaterThan: 170 } } } } + }); + expect(res.map(c => c.id)).toEqual([bob.id]); + }); + + // ===== Array of objects (Addresses) exact match ===== + it('exact array match (shorthand)', async () => { + // Works due to recent fix: plain array = exact match via @> & <@ + const res: Customer[] = await customerRepo.findAll({ + where: { + personalData: { + where: { + addresses: [ + { street: '123 Main St', city: 'Springfield', type: 'home' }, + { street: '456 Work Ave', city: 'Springfield', type: 'work' } + ] + } + } + } + }); + expect(res.map(c => c.id)).toEqual([alice.id]); + }); + + // ===== Array of objects: includes (contains) – requires feature, skip for now ===== + it('array includes (contains all specified objects)', async () => { + // TODO: implement partial object match in includes for object arrays + const res: Customer[] = await customerRepo.findAll({ + where: { + personalData: { + where: { + addresses: { includes: [{ street: '123 Main St', city: 'Springfield' }] } + } + } + } + }); + expect(res.map(c => c.id)).toEqual([alice.id]); + }); + + // ===== Array of objects: nested where on items (if supported) ===== + it('array item where filter', async () => { + // TODO: implement 'where' on array items to filter by item properties + const res: Customer[] = await customerRepo.findAll({ + where: { + personalData: { + where: { + addresses: { where: { street: '456 Work Ave' } } + } + } + } + }); + expect(res.map(c => c.id)).toEqual([alice.id]); + }); + + // ===== Array size / null ===== + it('array is null', async () => { + await customerRepo.updateById(alice.id, { personalData: { ...alice.personalData, addresses: null } }); + const res: Customer[] = await customerRepo.findAll({ + where: { personalData: { where: { addresses: null } } } + }); + expect(res.map(c => c.id)).toEqual([alice.id]); + }); + + // ===== Entire nested object is null ===== + it('nested object is null', async () => { + await customerRepo.updateById(bob.id, { personalData: null }); + const res: Customer[] = await customerRepo.findAll({ + where: { personalData: { is: null } } + }); + expect(res.map(c => c.id)).toEqual([bob.id]); + }); + + // ===== OR combination with nested filters ===== + it('OR with nested object conditions', async () => { + const filters: WhereFilter[] = [ + { personalData: { where: { favoriteColor: 'blue' } } }, + { personalData: { where: { height: 180 } } } + ]; + const res: Customer[] = await customerRepo.findAll({ where: filters }); + expect(res.map(c => c.id).sort()).toEqual([alice.id, bob.id].sort()); + }); + + // ===== Array of objects with nested array of objects ===== + describe('Container with Item → Tag (array → object → array)', () => { + let container: Container; + let container2: Container; + let containerRepo: Repository; + + beforeAll(() => { + containerRepo = inject(repositoryTokenFor(Container)); + }); + + beforeEach(async () => { + await containerRepo.deleteAll({}); + [container, container2] = await Promise.all([ + containerRepo.create({ + title: 'Container A', + items: [ + { + name: 'Item 1', + tags: [ + { key: 'color', value: 'red' }, + { key: 'size', value: 'large' } + ] + }, + { + name: 'Item 2', + tags: [{ key: 'color', value: 'blue' }] + } + ] + }), + containerRepo.create({ + title: 'Container B', + items: [ + { + name: 'Item 3', + tags: [{ key: 'color', value: 'green' }] + } + ] + }) + ]); + }); + + it('filters by nested array of objects using where on array', async () => { + // Find containers that have an item with a tag where key = 'size' + const res: Container[] = await containerRepo.findAll({ + where: { + items: { + where: { + tags: { + where: { key: 'size' } + } + } + } + } + }); + expect(res.map(c => c.id)).toEqual([container.id]); + }); + + it('filters by nested array of objects using includes', async () => { + // Find containers that have an item that includes both specified tags (exact object match) + const res: Container[] = await containerRepo.findAll({ + where: { + items: { + where: { + tags: { + includes: [{ key: 'color', value: 'red' }, { key: 'size', value: 'large' }] + } + } + } + } + }); + expect(res.map(c => c.id)).toEqual([container.id]); + }); + + it('filters by nested array of objects using isIncludedIn', async () => { + // Container A's Item 2 tags is a subset of the given list + const res: Container[] = await containerRepo.findAll({ + where: { + items: { + where: { + tags: { + isIncludedIn: [{ key: 'color', value: 'blue' }, { key: 'extra', value: 'something' }] + } + } + } + } + }); + expect(res.map(c => c.id)).toEqual([container.id]); + }); + + it('filters by exact array match on nested array', async () => { + // Find containers where an item has exactly the tags [{ key: 'color', value: 'green' }] + const res: Container[] = await containerRepo.findAll({ + where: { + items: { + where: { + tags: [{ key: 'color', value: 'green' }] + } + } + } + }); + expect(res.map(c => c.id)).toEqual([container2.id]); + }); + }); +}); \ No newline at end of file diff --git a/src/data-source/query-failed.error.ts b/src/data-source/query-failed.error.ts index 691b9c4..13b4300 100644 --- a/src/data-source/query-failed.error.ts +++ b/src/data-source/query-failed.error.ts @@ -25,8 +25,45 @@ function buildErrorMessage(error: TOQueryFailedError): string { query = `${parts[0]}\nVALUES ${parts[1]}`; } + let message: string = error.message; + if ( + message.includes('duplicate key value violates unique constraint') + && 'detail' in error.driverError + && typeof error.driverError.detail === 'string' + ) { + const key: string = error.driverError.detail.split('Key (').at(1) + ?.split(')=') + .at(0) ?? ''; + message = message.replace( + 'duplicate key value violates unique constraint', + `duplicate value for property "${key}" violates unique constraint` + ); + } + if ( + message.includes('violates foreign key constraint') + && 'detail' in error.driverError + && typeof error.driverError.detail === 'string' + ) { + const detail: string = error.driverError.detail; + // Example: "Key (id)=(a1b2c3d4-...) is still referenced from table "user_groups_group"." + const keyMatch: RegExpMatchArray | null = detail.match(/Key \((.+?)\)=\((.+?)\)/); + const tableMatch: RegExpMatchArray | null = detail.match(/table "(.+?)"/); + if (keyMatch && tableMatch) { + const key: string = keyMatch[1]; + const value: string = keyMatch[2]; + const referencingTable: string = tableMatch[1]; + message = message.replace( + 'violates foreign key constraint', + [ + 'violates foreign key constraint:', + `cannot delete or update because ${referencingTable}.${key} still references this row (value = ${value})` + ].join('\n') + ); + } + } + return [ - error.message, + message, 'SQL:', query ].join('\n'); diff --git a/src/data-source/repository-relation-pitfalls.test.ts b/src/data-source/repository-relation-pitfalls.test.ts new file mode 100644 index 0000000..4349358 --- /dev/null +++ b/src/data-source/repository-relation-pitfalls.test.ts @@ -0,0 +1,232 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'; +import { type Relation } from 'typeorm'; + +import { Repository } from './repository'; +import { createTestDataSource, defaultTestServerEntities } from '../__testing__/test-server/create-test-data-source.function'; +import { startTestServer, StartedTestServer } from '../__testing__/test-server/start-test-server.function'; +import { repositoryTokenFor } from '../di/decorators/inject-repository.decorator'; +import { inject } from '../di/inject.function'; +import { BaseEntity } from '../entity/base-entity.model'; +import { Entity } from '../entity/decorators/entity.decorator'; +import { Property } from '../entity/decorators/property.decorator'; + +// ==================== ENTITIES ==================== + +@Entity() +class Parent extends BaseEntity { + @Property.string() + name!: string; + + @Property.oneToMany({ target: () => Child, inverseSide: 'parent' }) + children!: Child[]; +} + +@Entity() +class Child extends BaseEntity { + @Property.string() + name!: string; + + @Property.manyToOne({ target: () => Parent, inverseSide: 'children', joinColumn: 'parentId' }) + parent!: Parent; + + @Property.string({ format: 'uuid' }) + parentId!: string; +} + +@Entity() +class Author extends BaseEntity { + @Property.string() + name!: string; + + // Non-owning side (joinTable: false) + @Property.manyToMany({ target: () => Book, inverseSide: 'authors', joinTable: false }) + books!: Book[]; +} + +@Entity() +class Book extends BaseEntity { // redeclare to add inverse (real code would be in one place) + @Property.string() + title!: string; + + // Owning side (joinTable: true) + @Property.manyToMany({ target: () => Author, inverseSide: 'books', joinTable: true }) + authors!: Author[]; +} + +@Entity() +class Reader extends BaseEntity { + @Property.string() + name!: string; + + @Property.hasOne({ target: () => Bookmark, inverseSide: 'reader' }) + bookmark!: Relation; +} + +@Entity() +class Bookmark extends BaseEntity { + @Property.string() + page!: string; + + @Property.belongsToOne({ target: () => Reader, inverseSide: 'bookmark', joinColumn: 'readerId' }) + reader!: Reader; + + @Property.string({ format: 'uuid' }) + readerId!: string; +} + +// ==================== TEST ==================== + +let server: StartedTestServer; +let authorRepo: Repository; +let bookRepo: Repository; +let readerRepo: Repository; +let bookmarkRepo: Repository; +let parentRepo: Repository; +let childRepo: Repository; + +beforeAll(async () => { + server = await startTestServer({ + dataSources: [ + createTestDataSource({ + entities: [...defaultTestServerEntities, Author, Book, Reader, Bookmark, Parent, Child] + }) + ] + }); + authorRepo = inject(repositoryTokenFor(Author)); + bookRepo = inject(repositoryTokenFor(Book)); + readerRepo = inject(repositoryTokenFor(Reader)); + bookmarkRepo = inject(repositoryTokenFor(Bookmark)); + parentRepo = inject(repositoryTokenFor(Parent)); + childRepo = inject(repositoryTokenFor(Child)); +}, 15000); + +afterAll(async () => { + await server.shutdown(); +}); + +beforeEach(async () => { + await bookmarkRepo.deleteAll({}); + await readerRepo.deleteAll({}); + await bookRepo.deleteAll({}); + await authorRepo.deleteAll({}); + await parentRepo.deleteAll({}); + await childRepo.deleteAll({}); +}); + +// ==================== TESTS ==================== + +describe('Repository relation pitfalls', () => { + + describe('many-to-many assignment from non-owning side', () => { + it('should NOT silently ignore assignment on inverse side', async () => { + const author: Author = await authorRepo.create({ name: 'Jane' }); + const book1: Book = await bookRepo.create({ title: 'Book 1' }); + const book2: Book = await bookRepo.create({ title: 'Book 2' }); + + // Assign books from the NON-OWNING side (Author.books) + await authorRepo.updateById(author.id, { books: [book1, book2] }); + + // Re-fetch with relations to see if the junction table was updated + const fetchedAuthor: Author = await authorRepo.findById(author.id, { relations: ['books'] }); + expect(fetchedAuthor.books.length).toBe(2); // ❌ will FAIL currently – proves the bug + }); + }); + + describe('updateAll – existing many-to-many relations data leak', () => { + it('should replace old relations, not leave them dangling', async () => { + const author: Author = await authorRepo.create({ name: 'Mark' }); + const book1: Book = await bookRepo.create({ title: 'Old' }); + const book2: Book = await bookRepo.create({ title: 'New' }); + + // Assign initial books (owning side, works correctly) + await bookRepo.updateById(book1.id, { authors: [author] }); + await bookRepo.updateById(book2.id, { authors: [author] }); + + // Verify author has both books + const before: Author = await authorRepo.findById(author.id, { relations: ['books'] }); + expect(before.books.length).toBe(2); + + // Now use updateAll to set only book2 (should remove book1) + await authorRepo.updateAll({ id: author.id }, { books: [book2] }); + + const after: Author = await authorRepo.findById(author.id, { relations: ['books'] }); + expect(after.books.length).toBe(1); // ❌ will FAIL if data leak exists + expect(after.books[0].id).toBe(book2.id); + }); + }); + + describe('hasOne – assignment on inverse side ignored', () => { + it('should not silently ignore assignment on hasOne side', async () => { + const reader: Reader = await readerRepo.create({ name: 'Alice' }); + const bookmark: Bookmark = await bookmarkRepo.create({ page: 'p42', readerId: reader.id }); + + // Attempt to set reader.bookmark from the non-owning side + await readerRepo.updateById(reader.id, { bookmark: bookmark }); + + const fetched: Reader = await readerRepo.findById(reader.id, { relations: ['bookmark'] }); + expect(fetched.bookmark).not.toBeNull(); // ❌ will FAIL currently – proves the bug + expect(fetched.bookmark.id).toBe(bookmark.id); + }); + }); + + describe('deleting owning side (Book) should cascade to junction table', () => { + it('removes the junction rows, author sees empty books', async () => { + const author: Author = await authorRepo.create({ name: 'Orwell' }); + const book: Book = await bookRepo.create({ title: '1984', authors: [author] }); + + await bookRepo.deleteById(book.id); + + const fetchedAuthor: Author = await authorRepo.findById(author.id, { relations: ['books'] }); + expect(fetchedAuthor.books.length).toBe(0); // junction rows removed + }); + }); + + describe('deleting inverse side (Author) without manual detach', () => { + it('should either succeed (clean cascade) or throw FK violation (known limitation)', async () => { + const author: Author = await authorRepo.create({ name: 'Hemingway' }); + const book: Book = await bookRepo.create({ title: 'Old Man', authors: [author] }); + + // Deleting the author — TypeORM cleans up the junction table automatically + await authorRepo.deleteById(author.id); + + // The book still exists … + const fetchedBook: Book = await bookRepo.findById(book.id, { relations: ['authors'] }); + expect(fetchedBook).toBeDefined(); + // … but its authors array is now empty (junction rows deleted) + expect(fetchedBook.authors.length).toBe(0); + }); + }); + + describe('one-to-many / many-to-one deletion', () => { + let parent: Parent; + let child1: Child; + let child2: Child; + + beforeEach(async () => { + parent = await parentRepo.create({ name: 'Parent' }); + child1 = await childRepo.create({ name: 'Child 1', parentId: parent.id }); + child2 = await childRepo.create({ name: 'Child 2', parentId: parent.id }); + }); + + it('deleting the parent cascades and removes children', async () => { + await parentRepo.deleteById(parent.id); + + // Both children should be gone + const found1: Child | undefined = await childRepo.findOne({ where: { id: child1.id } }, false); + const found2: Child | undefined = await childRepo.findOne({ where: { id: child2.id } }, false); + expect(found1).toBeUndefined(); + expect(found2).toBeUndefined(); + }); + + it('deleting a child does NOT delete the parent or other children', async () => { + await childRepo.deleteById(child1.id); + + const parentAfter: Parent = await parentRepo.findById(parent.id, { relations: ['children'] }); + expect(parentAfter.children).toHaveLength(1); + expect(parentAfter.children[0].id).toBe(child2.id); + + const deletedChild: Child | undefined = await childRepo.findOne({ where: { id: child1.id } }, false); + expect(deletedChild).toBeUndefined(); + }); + }); +}); \ No newline at end of file diff --git a/src/data-source/repository.test.ts b/src/data-source/repository.test.ts index 23c4cdd..bfc73e7 100644 --- a/src/data-source/repository.test.ts +++ b/src/data-source/repository.test.ts @@ -1,60 +1,297 @@ -import { beforeAll, afterAll, describe, it, expect } from '@jest/globals'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'; +import { type Relation } from 'typeorm'; import { Repository } from './repository'; import { createTestDataSource, defaultTestServerEntities } from '../__testing__/test-server/create-test-data-source.function'; -import { StartedTestServer, startTestServer } from '../__testing__/test-server/start-test-server.function'; +import { startTestServer, StartedTestServer } from '../__testing__/test-server/start-test-server.function'; import { repositoryTokenFor } from '../di/decorators/inject-repository.decorator'; import { inject } from '../di/inject.function'; import { BaseEntity } from '../entity/base-entity.model'; import { Entity } from '../entity/decorators/entity.decorator'; import { Property } from '../entity/decorators/property.decorator'; -import { OmitStrict } from '../types/omit-strict.type'; + +// ==================== ENTITIES ==================== + +@Entity() +class Company extends BaseEntity { + @Property.string() + name!: string; + + @Property.oneToMany({ target: () => User, inverseSide: 'company' }) + employees!: User[]; +} + +@Entity() +class Profile extends BaseEntity { + @Property.string() + bio!: string; + + @Property.hasOne({ target: () => User, inverseSide: 'profile' }) + user!: Relation; +} @Entity() -class VisitStats extends BaseEntity { - @Property.number() - count!: number; +class Tag extends BaseEntity { + @Property.string() + name!: string; - @Property.number() - countFirstVisit!: number; + @Property.manyToMany({ target: () => Post, inverseSide: 'tags', joinTable: false }) + posts!: Post[]; +} +@Entity() +class Group extends BaseEntity { @Property.string() - targetSite!: string; + name!: string; - @Property.string({ required: false }) - referrer: string | undefined; + @Property.manyToMany({ target: () => User, inverseSide: 'groups', joinTable: false }) + members!: User[]; +} +@Entity() +class Post extends BaseEntity { @Property.string() - domain!: string; + title!: string; + + @Property.manyToOne({ target: () => User, inverseSide: 'posts', joinColumn: 'userId' }) + author!: Relation; + + @Property.oneToMany({ target: () => Comment, inverseSide: 'post' }) + comments!: Relation; + + @Property.string({ format: 'uuid' }) + userId!: string; + + @Property.manyToMany({ target: () => Tag, inverseSide: 'posts', joinTable: true }) + tags!: Tag[]; +} + +@Entity() +class Comment extends BaseEntity { + @Property.string() + text!: string; + + @Property.manyToOne({ target: () => Post, inverseSide: 'comments', joinColumn: 'postId' }) + post!: Post; + + @Property.string({ format: 'uuid' }) + postId!: string; +} + +@Entity() +class User extends BaseEntity { + @Property.string() + name!: string; + + @Property.manyToOne({ target: () => Company, inverseSide: 'employees', joinColumn: 'companyId' }) + company!: Company; + + @Property.string({ format: 'uuid', required: false }) + companyId!: string; + + @Property.oneToMany({ target: () => Post, inverseSide: 'author' }) + posts!: Post[]; + + @Property.belongsToOne({ target: () => Profile, inverseSide: 'user', joinColumn: 'profileId' }) + profile!: Profile; + + @Property.string({ format: 'uuid', required: false }) + profileId!: string; - @Property.date() - date!: Date; + @Property.manyToMany({ target: () => Group, inverseSide: 'members', joinTable: true }) + groups!: Group[]; } -describe('repository', () => { - let server: StartedTestServer; +// ==================== TEST SUITE ==================== - beforeAll(async () => { - server = await startTestServer({ - dataSources: [createTestDataSource({ entities: [...defaultTestServerEntities, VisitStats] })] +let server: StartedTestServer; +let userRepo: Repository; +let companyRepo: Repository; +let profileRepo: Repository; +let postRepo: Repository; +let tagRepo: Repository; +let groupRepo: Repository; +let commentRepo: Repository; + +beforeAll(async () => { + server = await startTestServer({ + dataSources: [createTestDataSource({ entities: [...defaultTestServerEntities, Company, Profile, Tag, Group, Post, Comment, User] })] + }); + userRepo = inject(repositoryTokenFor(User)); + companyRepo = inject(repositoryTokenFor(Company)); + profileRepo = inject(repositoryTokenFor(Profile)); + postRepo = inject(repositoryTokenFor(Post)); + tagRepo = inject(repositoryTokenFor(Tag)); + groupRepo = inject(repositoryTokenFor(Group)); + commentRepo = inject(repositoryTokenFor(Comment)); +}, 15000); + +afterAll(async () => { + await server.shutdown(); +}); + +beforeEach(async () => { + // Clean all data in reverse dependency order + await commentRepo.deleteAll({}); + await postRepo.deleteAll({}); + await tagRepo.deleteAll({}); + await userRepo.deleteAll({}); + await groupRepo.deleteAll({}); + await profileRepo.deleteAll({}); + await companyRepo.deleteAll({}); +}); + +// ==================== TESTS ==================== + +describe('Repository – create with relations', () => { + it('create with many-to-one (company)', async () => { + const company: Company = await companyRepo.create({ name: 'Acme' }); + const user: User = await userRepo.create({ + name: 'Alice', + company: company, + companyId: company.id }); - }, 15000); - - afterAll(async () => { - await server?.shutdown(); - }); - - it('create', async () => { - const repo: Repository = inject(repositoryTokenFor(VisitStats)); - const visitStats: OmitStrict = { - count: 1, - countFirstVisit: 0, - targetSite: '/test', - referrer: 'google.de', - date: new Date(), - domain: 'localhost' - }; - await repo.create(visitStats); - expect((await repo.findAll()).length).toBe(1); + const fetched: User = await userRepo.findById(user.id); + expect(fetched.companyId).toBe(company.id); + }); + + it('create with one-to-many (posts)', async () => { + const user: User = await userRepo.create({ name: 'Bob' }); + await postRepo.create({ title: 'Post 1', author: user, userId: user.id }); + await postRepo.create({ title: 'Post 2', author: user, userId: user.id }); + const fetchedUser: User = await userRepo.findById(user.id, { relations: ['posts'] }); + expect(fetchedUser.posts).toHaveLength(2); + }); + + it('create with many-to-many (tags on post)', async () => { + const user: User = await userRepo.create({ name: 'Carol' }); + const tag1: Tag = await tagRepo.create({ name: 'tech' }); + const tag2: Tag = await tagRepo.create({ name: 'news' }); + const post: Post = await postRepo.create({ + title: 'Article', + author: user, + userId: user.id, + tags: [tag1, tag2] // many-to-many via relation array + }); + const fetchedPost: Post = await postRepo.findById(post.id, { relations: ['tags'] }); + expect(fetchedPost.tags).toHaveLength(2); + }); + + it('create with has-one (profile)', async () => { + const profile: Profile = await profileRepo.create({ bio: 'Hello' }); + const user: User = await userRepo.create({ + name: 'Dan', + profile: profile, + profileId: profile.id + }); + const fetched: User = await userRepo.findById(user.id, { relations: ['profile'] }); + expect(fetched.profile).toBeDefined(); + expect(fetched.profile.bio).toBe('Hello'); + }); + + it('create with nested relations', async () => { + // User -> Post -> Comment + const user: User = await userRepo.create({ name: 'Eve' }); + const post: Post = await postRepo.create({ title: 'Nested', author: user, userId: user.id }); + const comment: Comment = await commentRepo.create({ text: 'Nice!', post: post, postId: post.id }); + const fetchedComment: Comment = await commentRepo.findById(comment.id, { relations: { post: { author: true } } }); + expect(fetchedComment.post).toBeDefined(); + expect(fetchedComment.post.author).toBeDefined(); + }); +}); + +describe('Repository – update relations', () => { + it('updateById – change many-to-one', async () => { + const company1: Company = await companyRepo.create({ name: 'A' }); + const company2: Company = await companyRepo.create({ name: 'B' }); + const user: User = await userRepo.create({ name: 'Frank', company: company1, companyId: company1.id }); + await userRepo.updateById(user.id, { company: company2, companyId: company2.id }); + const updated: User = await userRepo.findById(user.id); + expect(updated.companyId).toBe(company2.id); + }); + + it('updateById – modify many-to-many array', async () => { + const user: User = await userRepo.create({ name: 'Grace' }); + const group1: Group = await groupRepo.create({ name: 'Admin' }); + const group2: Group = await groupRepo.create({ name: 'Editor' }); + // Set initial groups via create (known working path) + await userRepo.updateById(user.id, { groups: [group1] }); + // Now update to add group2 + await userRepo.updateById(user.id, { groups: [group1, group2] }); + const fetched: User = await userRepo.findById(user.id, { relations: ['groups'] }); + expect(fetched.groups).toHaveLength(2); + }); + + it('updateAll – change many-to-one for multiple entities', async () => { + const companyA: Company = await companyRepo.create({ name: 'Alpha' }); + const companyB: Company = await companyRepo.create({ name: 'Beta' }); + await userRepo.create({ name: 'User1', company: companyA, companyId: companyA.id }); + await userRepo.create({ name: 'User2', company: companyA, companyId: companyA.id }); + await userRepo.updateAll({ companyId: companyA.id }, { company: companyB, companyId: companyB.id }); + const users: User[] = await userRepo.findAll({ where: { companyId: companyB.id } }); + expect(users).toHaveLength(2); + }); + + it('updateAll – modify many-to-many arrays', async () => { + const group: Group = await groupRepo.create({ name: 'Everyone' }); + const user1: User = await userRepo.create({ name: 'Huey' }); + const user2: User = await userRepo.create({ name: 'Dewey' }); + await userRepo.updateAll({ id: user1.id }, { groups: [group] }); + await userRepo.updateAll({ id: user2.id }, { groups: [group] }); + const fetched: User[] = await userRepo.findAll({ where: { groups: { includes: [group] } } }); + expect(fetched).toHaveLength(2); + }); +}); + +describe('Repository – delete with relations', () => { + it('deleteById – with cascade on one-to-many', async () => { + const user: User = await userRepo.create({ name: 'Ivy' }); + const post: Post = await postRepo.create({ title: 'To be deleted', author: user, userId: user.id }); + // Deleting post should not delete user, but we'll check cascade configuration later + await postRepo.deleteById(post.id); + const found: Post | undefined = await postRepo.findOne({ where: { id: post.id } }, false); + expect(found).toBeUndefined(); + const userStill: User = await userRepo.findById(user.id); + expect(userStill).toBeDefined(); + }); + + it('deleteAll – many-to-many relations are detached, not deleted', async () => { + const group: Group = await groupRepo.create({ name: 'Temp' }); + const user: User = await userRepo.create({ name: 'Jack', groups: [group] }); + await userRepo.deleteAll({ id: user.id }); + const foundUser: User | undefined = await userRepo.findOne({ where: { id: user.id } }, false); + expect(foundUser).toBeUndefined(); + const foundGroup: Group = await groupRepo.findById(group.id); + expect(foundGroup).toBeDefined(); // group not deleted + }); +}); + +describe('Repository – querying relations', () => { + it('findById with nested relations', async () => { + const user: User = await userRepo.create({ name: 'Kate' }); + const post: Post = await postRepo.create({ title: 'Post', author: user, userId: user.id }); + const comment: Comment = await commentRepo.create({ text: 'Yep', post: post, postId: post.id }); + const fetched: Comment = await commentRepo.findById(comment.id, { relations: { post: { author: true } } }); + expect(fetched.post.title).toBe('Post'); + expect(fetched.post.author.name).toBe('Kate'); + }); + + it('findAll with where filter on relation property', async () => { + const company: Company = await companyRepo.create({ name: 'Target' }); + const user: User = await userRepo.create({ name: 'Liam', company: company, companyId: company.id }); + const results: User[] = await userRepo.findAll({ where: { company: { where: { name: 'Target' } } } }); + expect(results.map(u => u.id)).toEqual([user.id]); + }); +}); + +describe('Repository – combined operations', () => { + it('createAll with multiple entities and relations', async () => { + const company: Company = await companyRepo.create({ name: 'MultiCorp' }); + const users: User[] = await userRepo.createAll([ + { name: 'Moe', company: company, companyId: company.id }, + { name: 'Larry', company: company, companyId: company.id }, + { name: 'Curly', company: company, companyId: company.id } + ]); + expect(users).toHaveLength(3); }); }); \ No newline at end of file diff --git a/src/data-source/repository.ts b/src/data-source/repository.ts index 227f808..1ceed12 100644 --- a/src/data-source/repository.ts +++ b/src/data-source/repository.ts @@ -1,4 +1,4 @@ -import { Repository as TORepository, FindOptionsWhere, EntityManager, QueryFailedError as TOQueryFailedError, DeepPartial as ToDeepPartial } from 'typeorm'; +import { Repository as TORepository, FindOptionsWhere, EntityManager, QueryFailedError as TOQueryFailedError, DeepPartial as ToDeepPartial, FindOptionsOrder, FindOptionsRelations } from 'typeorm'; import { BaseEntity } from '../entity/base-entity.model'; import { LoggerInterface } from '../logging/logger.interface'; @@ -18,12 +18,16 @@ import { FindByIdOptions } from './models/options/find-by-id-options.model'; import { FindOneOptions } from './models/options/find-one-options.model'; import { UpdateAllOptions } from './models/options/update-all-options.model'; import { UpdateByIdOptions } from './models/options/update-by-id-options.model'; -import { whereFilterToFindOptionsWhere } from './models/where/where-filter-to-find-options-where.function'; import { Where } from './models/where/where-filter.model'; import { QueryFailedError } from './query-failed.error'; import { Transaction } from './transaction/transaction.model'; +import { EntityMetadata } from '../entity/decorators/entity.decorator'; import { NotFoundError } from '../error-handling/errors/not-found.error'; import { ModelRegistry } from '../global/model-registry/model.registry'; +import { MetadataUtilities } from '../utilities/metadata.utilities'; +import { type TypeOrmBaseDataSource } from './data-sources/typeorm-base-data-source.model'; +import { DataSourceOptions } from './models/data-source-options.model'; +import { EntityMetadataMissingError } from '../entity/entity-metadata-missing.error'; /** * A repository that handles data source related things for its entity. @@ -35,6 +39,13 @@ export class Repository< > { private readonly typeOrmRepository: TORepository; + private readonly _dataSource: TypeOrmBaseDataSource; + + /** + * The metadata of entity that this repository manages. + */ + protected readonly entityMetadata: EntityMetadata; + // eslint-disable-next-line jsdoc/require-returns /** * The data source that this repository is connected to. @@ -47,12 +58,21 @@ export class Repository< protected readonly entityClass: Newable, repo: TORepository | Repository, protected readonly logger: LoggerInterface, - private readonly _dataSource: DataSourceInterface, + dataSource: DataSourceInterface, private readonly beforeSave: BeforeSaveHook, private readonly beforeReturn: BeforeReturnHook ) { this.typeOrmRepository = repo instanceof Repository ? repo.typeOrmRepository : repo; ModelRegistry.get(this.entityClass); + const metadata: EntityMetadata | undefined = MetadataUtilities.getEntityMetadata(this.entityClass); + if (!metadata) { + throw new EntityMetadataMissingError(this.entityClass); + } + if (!('whereFilterToFindOptionsWhere' in dataSource)) { + throw new Error('Zibri\'s default repositories only work with TypeOrmBaseDataSource'); + } + this._dataSource = dataSource as TypeOrmBaseDataSource; + this.entityMetadata = metadata; } private getManager(transaction: Transaction | undefined): EntityManager { @@ -63,7 +83,7 @@ export class Repository< if (!where) { return undefined; } - return whereFilterToFindOptionsWhere(where, this.entityClass); + return this._dataSource.whereFilterToFindOptionsWhere(where, this.entityClass); } /** @@ -156,13 +176,21 @@ export class Repository< required: B = true as B ): Promise { const where: FindOptionsWhere | FindOptionsWhere[] | undefined = this.resolveFindOptionsWhere(options.where); + // eslint-disable-next-line typescript/no-explicit-any + const relations: (keyof T)[] | FindOptionsRelations | undefined = options.relations ?? this.entityMetadata.defaultRelations; const manager: EntityManager = this.getManager(options?.transaction); let res: T | null; try { res = await manager.findOne( this.entityClass, - { ...options, relations: options.relations as string[], where, transaction: undefined } + { + order: this.entityMetadata.defaultOrder as FindOptionsOrder | undefined, + ...options, + relations: relations as FindOptionsRelations | string[] | undefined, + where, + transaction: undefined + } ); } catch (error) { @@ -189,10 +217,19 @@ export class Repository< async findAll(options?: FindAllOptions): Promise { const manager: EntityManager = this.getManager(options?.transaction); const where: FindOptionsWhere | FindOptionsWhere[] | undefined = this.resolveFindOptionsWhere(options?.where); + // eslint-disable-next-line typescript/no-explicit-any + const relations: (keyof T)[] | FindOptionsRelations | undefined = options?.relations ?? this.entityMetadata.defaultRelations; + try { const res: T[] = await manager.find( this.entityClass, - { ...options, where, relations: options?.relations as string[], transaction: undefined } + { + order: this.entityMetadata.defaultOrder as FindOptionsOrder | undefined, + ...options, + where, + relations: relations as FindOptionsRelations | string[] | undefined, + transaction: undefined + } ); await Promise.all(res.map(r => this.beforeReturn(r, this.entityClass))); return res; @@ -244,18 +281,16 @@ export class Repository< * @returns The updated entity. */ async updateById(id: T['id'], data: UpdateData, options?: UpdateByIdOptions): Promise { - if (data.id != undefined && options?.allowId != true) { + if (data.id != undefined) { await this.logger.warn('Found an id on the update data, it will be ignored.'); - delete data.id; } const manager: EntityManager = this.getManager(options?.transaction); data.id = id; await this.beforeSave(data, false, this.entityClass); try { - const res: T = await manager.save(this.entityClass, data as ToDeepPartial); - await this.beforeReturn(res, this.entityClass); - return res; + await manager.save(this.entityClass, data as ToDeepPartial, { reload: false }); + return await this.findById(id, options); } catch (error) { if (error instanceof TOQueryFailedError) { @@ -277,7 +312,7 @@ export class Repository< data: UpdateData, options?: UpdateAllOptions ): Promise { - if (data.id != undefined && options?.allowId != true) { + if (data.id != undefined) { await this.logger.warn('Found an id on the update data, it will be ignored.'); delete data.id; } @@ -287,9 +322,8 @@ export class Repository< const manager: EntityManager = this.getManager(options?.transaction); try { - const res: T[] = await manager.save(this.entityClass, toUpdate as ToDeepPartial[]); - await Promise.all(res.map(r => this.beforeReturn(r, this.entityClass))); - return res; + await manager.save(this.entityClass, toUpdate as ToDeepPartial[]); + return await this.findAll({ where, ...options }); } catch (error) { if (error instanceof TOQueryFailedError) { @@ -330,7 +364,7 @@ export class Repository< */ async deleteAll( where: Where, - options?: DeleteAllOptions + options?: DeleteAllOptions ): Promise { const toDelete: T[] = await this.findAll({ where, ...options }); await Promise.all(toDelete.map(r => this.beforeSave(r, false, this.entityClass))); diff --git a/src/data-source/transaction/transaction.test.ts b/src/data-source/transaction/transaction.test.ts index 097df53..1345eb5 100644 --- a/src/data-source/transaction/transaction.test.ts +++ b/src/data-source/transaction/transaction.test.ts @@ -10,7 +10,7 @@ import { Entity } from '../../entity/decorators/entity.decorator'; import { Property } from '../../entity/decorators/property.decorator'; import { Newable } from '../../types/newable.type'; import { DataSourceInterface } from '../data-sources/data-source.interface'; -import { PostgresDataSource } from '../data-sources/postgres-data-source.model'; +import { PostgresDataSource } from '../data-sources/postgres-typeorm-data-source.model'; @Entity() class Item { diff --git a/src/data-source/where-filter.test.ts b/src/data-source/where-filter.test.ts new file mode 100644 index 0000000..5c5a026 --- /dev/null +++ b/src/data-source/where-filter.test.ts @@ -0,0 +1,479 @@ +/* eslint-disable unicorn/no-null */ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'; + +import { createTestDataSource, defaultTestServerEntities } from '../__testing__/test-server/create-test-data-source.function'; +import { startTestServer, StartedTestServer } from '../__testing__/test-server/start-test-server.function'; +import { WhereFilter } from '../data-source/models/where/where-filter.model'; +import { Repository } from '../data-source/repository'; +import { repositoryTokenFor } from '../di/decorators/inject-repository.decorator'; +import { inject } from '../di/inject.function'; +import { BaseEntity } from '../entity/base-entity.model'; +import { Entity } from '../entity/decorators/entity.decorator'; +import { Property } from '../entity/decorators/property.decorator'; +import { DeepPartial } from '../types/deep-partial.type'; + +// ---------- Test entities ---------- + +@Entity() +class Category extends BaseEntity { + @Property.string() + name!: string; + + @Property.oneToMany({ target: () => Product, inverseSide: 'category' }) + products!: Product[]; +} + +@Entity() +class Review extends BaseEntity { // used as an object type for array-of-objects + @Property.number() + rating!: number; + + @Property.string() + comment!: string; +} + +class Metadata { // plain object embedded inside Product + @Property.string() + color!: string; + + @Property.number() + weight!: number; +} + +@Entity() +class Product extends BaseEntity { + @Property.string({ required: false }) + name: string | undefined | null; + + @Property.number({ required: false }) + price: number | undefined | null; + + @Property.boolean({ required: false }) + inStock: boolean | undefined | null; + + @Property.date({ default: () => new Date(), required: false }) + createdAt: Date | undefined | null; + + @Property.array({ items: { type: 'string' }, required: false }) + tags: string[] | undefined | null; + + @Property.object({ cls: () => Metadata, required: false }) + metadata: Metadata | undefined | null; + + @Property.manyToOne({ target: () => Category, inverseSide: 'products', joinColumn: 'categoryId' }) + category!: Category; + + @Property.string({ format: 'uuid' }) + categoryId!: string; + + @Property.array({ items: { type: 'object', cls: () => Review }, required: false }) + reviews: Review[] | undefined | null; +} + +let server: StartedTestServer; +let productRepo: Repository; +let categoryRepo: Repository; + +beforeAll(async () => { + server = await startTestServer({ + dataSources: [ + createTestDataSource({ + entities: [...defaultTestServerEntities, Product, Category, Review] + }) + ] + }); + productRepo = inject(repositoryTokenFor(Product)); + categoryRepo = inject(repositoryTokenFor(Category)); +}, 15000); + +afterAll(async () => { + await server.shutdown(); +}); + +beforeEach(async () => { + await productRepo.deleteAll({}); + await categoryRepo.deleteAll({}); +}); + +// ---------- Helper to create test data ---------- +async function seedProducts(...products: DeepPartial[]): Promise { + for (const p of products) { + if (p.category) { + const cat: Category = await categoryRepo.create(p.category); + p.categoryId = cat.id; + } + } + const defaultCategory: Category = (await categoryRepo.findAllPaginated(1, 1)).items.at(0) ?? await categoryRepo.create({ name: 'default' }); + return Promise.all(products.map(p => productRepo.create({ tags: [], reviews: [], inStock: true, categoryId: defaultCategory.id, ...p }))); +} + +// ---------- Tests ---------- +describe('Where filters', () => { + + // =================== STRING =================== + describe('string', () => { + let prodA: Product; + let prodB: Product; + + beforeEach(async () => { + [prodA, prodB] = await seedProducts( + { name: 'Alpha', price: 10 }, + { name: 'Beta', price: 20 } + ); + }); + + it('equals', async () => { + const res: Product[] = await productRepo.findAll({ where: { name: 'Alpha' } }); + expect(res.map(p => p.id)).toEqual([prodA.id]); + }); + + it('not', async () => { + const res: Product[] = await productRepo.findAll({ where: { name: { not: 'Alpha' } } }); + expect(res.map(p => p.id).sort()).toEqual([prodB.id].sort()); + }); + + it('oneOf', async () => { + const res: Product[] = await productRepo.findAll({ where: { name: { oneOf: ['Alpha', 'Beta'] } } }); + expect(res.map(p => p.id).sort()).toEqual([prodA.id, prodB.id].sort()); + }); + + it('notOneOf', async () => { + const res: Product[] = await productRepo.findAll({ where: { name: { notOneOf: ['Alpha'] } } }); + expect(res.map(p => p.id)).toEqual([prodB.id]); + }); + + it('like', async () => { + const res: Product[] = await productRepo.findAll({ where: { name: { like: 'Al%' } } }); + expect(res.map(p => p.id)).toEqual([prodA.id]); + }); + + it('iLike', async () => { + const res: Product[] = await productRepo.findAll({ where: { name: { iLike: 'al%' } } }); + expect(res.map(p => p.id)).toEqual([prodA.id]); + }); + + it('null', async () => { + await productRepo.updateById(prodA.id, { name: null }); // set to null + const res: Product[] = await productRepo.findAll({ where: { name: null } }); + expect(res.map(p => p.id)).toEqual([prodA.id]); + }); + }); + + // =================== NUMBER =================== + describe('number', () => { + let cheap: Product; + let mid: Product; + let expensive: Product; + + beforeEach(async () => { + [cheap, mid, expensive] = await seedProducts( + { name: 'c', price: 10 }, + { name: 'm', price: 50 }, + { name: 'e', price: 100 } + ); + }); + + it('equals', async () => { + const res: Product[] = await productRepo.findAll({ where: { price: 50 } }); + expect(res.map(p => p.id)).toEqual([mid.id]); + }); + + it('not', async () => { + const res: Product[] = await productRepo.findAll({ where: { price: { not: 50 } } }); + expect(res.map(p => p.id).sort()).toEqual([cheap.id, expensive.id].sort()); + }); + + it('oneOf', async () => { + const res: Product[] = await productRepo.findAll({ where: { price: { oneOf: [10, 100] } } }); + expect(res.map(p => p.id).sort()).toEqual([cheap.id, expensive.id].sort()); + }); + + it('notOneOf', async () => { + const res: Product[] = await productRepo.findAll({ where: { price: { notOneOf: [10, 100] } } }); + expect(res.map(p => p.id)).toEqual([mid.id]); + }); + + it('greaterThan', async () => { + const res: Product[] = await productRepo.findAll({ where: { price: { greaterThan: 50 } } }); + expect(res.map(p => p.id)).toEqual([expensive.id]); + }); + + it('greaterThanEquals', async () => { + const res: Product[] = await productRepo.findAll({ where: { price: { greaterThanEquals: 50 } } }); + expect(res.map(p => p.id).sort()).toEqual([mid.id, expensive.id].sort()); + }); + + it('lesserThan', async () => { + const res: Product[] = await productRepo.findAll({ where: { price: { lesserThan: 50 } } }); + expect(res.map(p => p.id)).toEqual([cheap.id]); + }); + + it('lesserThanEquals', async () => { + const res: Product[] = await productRepo.findAll({ where: { price: { lesserThanEquals: 50 } } }); + expect(res.map(p => p.id).sort()).toEqual([cheap.id, mid.id].sort()); + }); + + it('null', async () => { + await productRepo.updateById(cheap.id, { price: null }); + const res: Product[] = await productRepo.findAll({ where: { price: null } }); + expect(res.map(p => p.id)).toEqual([cheap.id]); + }); + }); + + // =================== BOOLEAN =================== + describe('boolean', () => { + let inStock: Product; + let outOfStock: Product; + + beforeEach(async () => { + [inStock, outOfStock] = await seedProducts( + { name: 'a', price: 1, inStock: true }, + { name: 'b', price: 2, inStock: false } + ); + }); + + it('true', async () => { + const res: Product[] = await productRepo.findAll({ where: { inStock: true } }); + expect(res.map(p => p.id)).toEqual([inStock.id]); + }); + + it('false', async () => { + const res: Product[] = await productRepo.findAll({ where: { inStock: false } }); + expect(res.map(p => p.id)).toEqual([outOfStock.id]); + }); + + it('null', async () => { + await productRepo.updateById(inStock.id, { inStock: null }); + const res: Product[] = await productRepo.findAll({ where: { inStock: null } }); + expect(res.map(p => p.id)).toEqual([inStock.id]); + }); + }); + + // =================== DATE =================== + describe('date', () => { + let early: Product; + let late: Product; + const earlyCreatedAt: Date = new Date('2025-01-01'); + const lateCreatedAt: Date = new Date('2026-01-01'); + + beforeEach(async () => { + [early, late] = await seedProducts( + { name: 'early', price: 1, createdAt: earlyCreatedAt }, + { name: 'late', price: 2, createdAt: lateCreatedAt } + ); + }); + + it('equals', async () => { + const res: Product[] = await productRepo.findAll({ where: { createdAt: early.createdAt } }); + expect(res.map(p => p.id)).toEqual([early.id]); + }); + + it('not', async () => { + const res: Product[] = await productRepo.findAll({ where: { createdAt: { not: early.createdAt } } }); + expect(res.map(p => p.id)).toEqual([late.id]); + }); + + it('oneOf', async () => { + const res: Product[] = await productRepo.findAll({ where: { createdAt: { oneOf: [earlyCreatedAt, lateCreatedAt] } } }); + expect(res.map(p => p.id).sort()).toEqual([early.id, late.id].sort()); + }); + + it('notOneOf', async () => { + const res: Product[] = await productRepo.findAll({ where: { createdAt: { notOneOf: [earlyCreatedAt] } } }); + expect(res.map(p => p.id)).toEqual([late.id]); + }); + + it('after', async () => { + const res: Product[] = await productRepo.findAll({ where: { createdAt: { after: earlyCreatedAt } } }); + expect(res.map(p => p.id)).toEqual([late.id]); + }); + + it('before', async () => { + const res: Product[] = await productRepo.findAll({ where: { createdAt: { before: lateCreatedAt } } }); + expect(res.map(p => p.id)).toEqual([early.id]); + }); + + it('null', async () => { + await productRepo.updateById(early.id, { createdAt: null }); + const res: Product[] = await productRepo.findAll({ where: { createdAt: null } }); + expect(res.map(p => p.id)).toEqual([early.id]); + }); + }); + + // =================== ARRAY (of strings) =================== + describe('array (string)', () => { + let prodA: Product; + + beforeEach(async () => { + [prodA] = await seedProducts( + { name: 'a', price: 1, tags: ['tag1', 'tag2'] }, + { name: 'b', price: 2, tags: ['tag3'] } + ); + }); + + it('equals (exact array)', async () => { + const res: Product[] = await productRepo.findAll({ where: { tags: ['tag1', 'tag2'] } }); + expect(res.map(p => p.id)).toEqual([prodA.id]); + }); + + it('includes (contains all specified items)', async () => { + const res: Product[] = await productRepo.findAll({ where: { tags: { includes: ['tag1'] } } }); + expect(res.map(p => p.id)).toEqual([prodA.id]); + }); + + it('isIncludedIn (all items of the property are subset of given list)', async () => { + const res: Product[] = await productRepo.findAll({ where: { tags: { isIncludedIn: ['tag1', 'tag2', 'extra'] } } }); + expect(res.map(p => p.id)).toEqual([prodA.id]); // prodA's tags are subset + // prodB has ['tag3'] which is not subset of ['tag1','tag2','extra'] -> not returned + }); + + it('null', async () => { + await productRepo.updateById(prodA.id, { tags: null }); + const res: Product[] = await productRepo.findAll({ where: { tags: null } }); + expect(res.map(p => p.id)).toEqual([prodA.id]); + }); + }); + + // =================== OBJECT (embedded) =================== + describe('object (Metadata)', () => { + let prodRed: Product; + let prodBlue: Product; + + beforeEach(async () => { + [prodRed, prodBlue] = await seedProducts( + { name: 'red', price: 1, metadata: { color: 'red', weight: 100 } }, + { name: 'blue', price: 2, metadata: { color: 'blue', weight: 200 } } + ); + }); + + it('equals (exact match)', async () => { + const res: Product[] = await productRepo.findAll({ where: { metadata: { is: { color: 'red', weight: 100 } } } }); + expect(res.map(p => p.id)).toEqual([prodRed.id]); + }); + + it('where (nested filter)', async () => { + const res: Product[] = await productRepo.findAll({ where: { metadata: { where: { color: 'red' } } } }); + // Should treat the nested where as filtering on the JSON's properties + // That depends on how TypeORM handles JSON columns; we assume the framework maps it correctly. + // In a strict behavioral test, we just validate the result. + expect(res.map(p => p.id)).toEqual([prodRed.id]); + }); + + it('not', async () => { + const res: Product[] = await productRepo.findAll({ where: { metadata: { not: { color: 'red', weight: 100 } } } }); + expect(res.map(p => p.id)).toEqual([prodBlue.id]); + }); + + it('oneOf', async () => { + const res: Product[] = await productRepo.findAll({ where: { metadata: { oneOf: [{ color: 'red', weight: 100 }, { color: 'blue', weight: 200 }] } } }); + expect(res.map(p => p.id).sort()).toEqual([prodRed.id, prodBlue.id].sort()); + }); + + it('notOneOf', async () => { + const res: Product[] = await productRepo.findAll({ where: { metadata: { notOneOf: [{ color: 'red', weight: 100 }] } } }); + expect(res.map(p => p.id)).toEqual([prodBlue.id]); + }); + + it('null', async () => { + await productRepo.updateById(prodRed.id, { metadata: null }); + const res: Product[] = await productRepo.findAll({ where: { metadata: { is: null } } }); + expect(res.map(p => p.id)).toEqual([prodRed.id]); + }); + }); + + // =================== RELATION (Category) =================== + describe('relation (Category)', () => { + let catFood: Category; + let catToys: Category; + let prodFood: Product; + + beforeEach(async () => { + catFood = await categoryRepo.create({ name: 'Food' }); + catToys = await categoryRepo.create({ name: 'Toys' }); + [prodFood] = await seedProducts( + { name: 'Kibble', price: 5, category: catFood }, + { name: 'Ball', price: 3, category: catToys } + ); + }); + + it('filter by relation property (where on nested object)', async () => { + const res: Product[] = await productRepo.findAll({ where: { category: { where: { name: 'Food' } } } }); + expect(res.map(p => p.id)).toEqual([prodFood.id]); + }); + + it('filter by relation id (equals with id)', async () => { + // `equals` on a relation should filter by the primary key of the related entity + const res: Product[] = await productRepo.findAll({ where: { category: { is: catFood } } }); + expect(res.map(p => p.id)).toEqual([prodFood.id]); + }); + + // TODO + // it('null (no category)', async () => { + // await productRepo.updateById(prodFood.id, { category: null }); + // const res: Product[] = await productRepo.findAll({ where: { category: null } }); + // expect(res.map(p => p.id)).toEqual([prodFood.id]); + // }); + }); + + // =================== ARRAY OF OBJECTS (Review[]) =================== + describe('array of objects', () => { + let prodA: Product; + let prodB: Product; + + beforeEach(async () => { + [prodA, prodB] = await seedProducts( + { name: 'a', price: 1, reviews: [{ rating: 5, comment: 'Great' }, { rating: 4, comment: 'Good' }] }, + { name: 'b', price: 2, reviews: [{ rating: 2, comment: 'Bad' }] } + ); + }); + + it('includes (contains all specified objects)', async () => { + const res: Product[] = await productRepo.findAll({ where: { reviews: { where: [{ rating: 5, comment: 'Great' }] } } }); + expect(res.map(p => p.id)).toEqual([prodA.id]); + }); + + it('isIncludedIn (all items of the array are subset of given list)', async () => { + const res: Product[] = await productRepo.findAll({ where: { reviews: { where: [{ rating: 5, comment: 'Great' }, { rating: 4, comment: 'Good' }, { rating: 2, comment: 'Bad' }] } } }); + expect(res.map(p => p.id).sort()).toEqual([prodA.id, prodB.id].sort()); + }); + + it('null', async () => { + await productRepo.updateById(prodA.id, { reviews: null }); + const res: Product[] = await productRepo.findAll({ where: { reviews: null } }); + expect(res.map(p => p.id)).toEqual([prodA.id]); + }); + }); + + // =================== OR (array of filters) =================== + describe('OR combination', () => { + let prodA: Product; + let prodB: Product; + let prodC: Product; + + beforeEach(async () => { + [prodA, prodB, prodC] = await seedProducts( + { name: 'A', price: 1 }, + { name: 'B', price: 2 }, + { name: 'C', price: 3 } + ); + }); + + it('returns entities matching any of the filters', async () => { + const filters: WhereFilter[] = [ + { name: 'A' }, + { price: 3 } + ]; + const res: Product[] = await productRepo.findAll({ where: filters }); + expect(res.map(p => p.id).sort()).toEqual([prodA.id, prodC.id].sort()); + }); + + it('multiple complex filters', async () => { + const filters: WhereFilter[] = [ + { name: { like: 'A' }, price: 1 }, + { name: { like: 'B' }, price: { greaterThan: 1 } } + ]; + const res: Product[] = await productRepo.findAll({ where: filters }); + expect(res.map(p => p.id).sort()).toEqual([prodA.id, prodB.id].sort()); + }); + }); +}); \ No newline at end of file diff --git a/src/di/decorators/inject-repository.decorator.ts b/src/di/decorators/inject-repository.decorator.ts index fd6defa..563b088 100644 --- a/src/di/decorators/inject-repository.decorator.ts +++ b/src/di/decorators/inject-repository.decorator.ts @@ -1,3 +1,4 @@ +import { RepositoryTypeForEntity } from '../../data-source/data-sources/data-source.interface'; import { Repository } from '../../data-source/repository'; import { BaseEntity } from '../../entity/base-entity.model'; import { Newable } from '../../types/newable.type'; @@ -12,10 +13,10 @@ const allRepositoryTokens: Record>> = {}; * @param entity - The entity class to resolve the repository token for. * @returns The DI token. */ -export function repositoryTokenFor>(entity: T): DiToken>> { +export function repositoryTokenFor>(entity: T): DiToken>> { const key: string = `Repository<${entity.name}>`; allRepositoryTokens[key] ??= new InjectionToken(key); - return allRepositoryTokens[key] as unknown as DiToken>>; + return allRepositoryTokens[key] as DiToken>>; } /** diff --git a/src/di/default/zibri-di-tokens.default.ts b/src/di/default/zibri-di-tokens.default.ts index f2b255c..e09ae32 100644 --- a/src/di/default/zibri-di-tokens.default.ts +++ b/src/di/default/zibri-di-tokens.default.ts @@ -11,6 +11,7 @@ import { PasswordResetEmailTemplate } from '../../auth/strategies/jwt/jwt-auth.c import { UserServiceInterface } from '../../auth/user/user-service.interface'; import { BackupServiceInterface } from '../../backup/backup-service.interface'; import { CacheServiceInterface } from '../../caching/cache-service.interface'; +import { CacheContext } from '../../context/cache/cache.context'; import { HttpRequestContext } from '../../context/request/http-request.context'; import { WebsocketRequestContext } from '../../context/request/websocket-request.context'; import { CronServiceInterface } from '../../cron/cron-service.interface'; @@ -24,7 +25,6 @@ import { FormatDateFn } from '../../localization/formatting/format-date-fn.model import { FormatPercentFn } from '../../localization/formatting/format-percent-fn.model'; import { FormatPriceFn } from '../../localization/formatting/format-price-fn.model'; import { LocalizeOptionsInput, LocalizeOptions } from '../../localization/models/localize-options.model'; -import { LogCacheContext } from '../../logging/log-context.model'; import { LogLevel } from '../../logging/log-level.enum'; import { LoggerInterface } from '../../logging/logger.interface'; import { LoggerTransport, BaseLoggerTransportConfig } from '../../logging/transport/logger-transport.model'; @@ -118,5 +118,5 @@ export const ZIBRI_DI_TOKENS = { // dynamic/context based tokens CURRENT_REQUEST_CONTEXT: ziToken('zi.current_request_context'), DEFAULT_CSP_OPTIONS: ziToken('zi.default_csp_options'), - CURRENT_CACHE_CONTEXT: ziToken('zi.current_cache_context') + CURRENT_CACHE_CONTEXT: ziToken('zi.current_cache_context') } as const satisfies TokenRecord; \ No newline at end of file diff --git a/src/email/email.service.test.ts b/src/email/email.service.test.ts new file mode 100644 index 0000000..4b292dd --- /dev/null +++ b/src/email/email.service.test.ts @@ -0,0 +1,382 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import SMTPTransport from 'nodemailer/lib/smtp-transport'; + +import { EmailService } from './email.service'; +import { CreateEmailData, QueueEmailData } from './models/create-email-data.model'; +import { EmailPriority } from './models/email-priority.enum'; +import { EmailStatus } from './models/email-status.enum'; +import { Email } from './models/email.model'; +import { defaultTestServerProviders } from '../__testing__/test-server/providers'; +import { startTestServer, StartedTestServer } from '../__testing__/test-server/start-test-server.function'; +import { Repository } from '../data-source/repository'; +import { EmailConfig } from './models/email-config.model'; +import { InjectRepository, repositoryTokenFor } from '../di/decorators/inject-repository.decorator'; +import { Inject } from '../di/decorators/inject.decorator'; +import { Injectable } from '../di/decorators/injectable.decorator'; +import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; +import { inject } from '../di/inject.function'; +import { type LoggerInterface } from '../logging/logger.interface'; + +// ---------- Test email service (mocks the transporter) ---------- + +// eslint-disable-next-line typescript/typedef +const mockSendMail = jest.fn<() => Promise>(); + +const testConfig: EmailConfig = { + host: 'smtp.example.com', + port: 587, + maxEmailsPerHour: 1000, + defaultSender: 'noreply@example.com', + pool: false, + auth: { user: 'user', pass: 'pass' } +}; + +@Injectable({ register: 'onUse' }) +class TestEmailService extends EmailService { + constructor( + @Inject(ZIBRI_DI_TOKENS.LOGGER) logger: LoggerInterface, + @InjectRepository(Email) emailRepository: Repository + ) { + super(logger, emailRepository, testConfig); + } + + protected override async send(email: Email): Promise { + await this.validateAttachments(email.attachments); + const res: SMTPTransport.SentMessageInfo = await mockSendMail(); + const status: EmailStatus = res.rejected.length ? EmailStatus.FAILED : EmailStatus.SENT; + + if (status === EmailStatus.FAILED) { + const rejectedRecipients: string[] = res.rejected.map((e) => typeof e === 'string' ? e : e.address); + await this['logger'].warn(`mail to ${rejectedRecipients.join(', ')} was rejected`); + } + + if (email.persist) { + await this['emailRepository'].updateById(email.id, { status }); + return; + } + + await this['emailRepository'].deleteById(email.id); + } +} + +// ---------- Helpers ---------- + +function sentMessageInfo(overrides: Partial = {}): SMTPTransport.SentMessageInfo { + return { + accepted: ['recipient@example.com'], + rejected: [], + pending: [], + response: '250 OK', + envelope: { from: 'noreply@example.com', to: ['recipient@example.com'] }, + messageId: 'test-message-id', + ...overrides + } as SMTPTransport.SentMessageInfo; +} + +function makeQueueData(overrides: Partial = {}): QueueEmailData { + return { + html: '

Hello

', + subject: 'Test subject', + recipients: ['recipient@example.com'], + ...overrides + } as QueueEmailData; +} + +// ---------- Test setup ---------- + +let server: StartedTestServer; +let emailService: TestEmailService; +let emailRepo: Repository; + +beforeAll(async () => { + server = await startTestServer({ + providers: [ + ...defaultTestServerProviders, + { token: ZIBRI_DI_TOKENS.EMAIL_CONFIG, useValue: testConfig }, + { token: ZIBRI_DI_TOKENS.EMAIL_SERVICE, useClass: TestEmailService } + ] + }); + await server.start(); + + emailService = inject(TestEmailService); + emailRepo = inject(repositoryTokenFor(Email)); +}, 15000); + +afterAll(async () => { + await server.shutdown(); +}); + +beforeEach(async () => { + await emailRepo.deleteAll({}); + mockSendMail.mockReset(); + mockSendMail.mockResolvedValue(sentMessageInfo()); +}); + +// ---------- Tests ---------- + +describe('EmailService.queue', () => { + it('creates an email with QUEUED status', async () => { + await emailService.queue(makeQueueData()); + + const emails: Email[] = await emailRepo.findAll({}); + expect(emails).toHaveLength(1); + expect(emails[0].status).toBe(EmailStatus.QUEUED); + }); + + it('defaults to NORMAL priority', async () => { + await emailService.queue(makeQueueData()); + + const emails: Email[] = await emailRepo.findAll({}); + expect(emails[0].priority).toBe(EmailPriority.NORMAL); + }); + + it('respects provided priority', async () => { + await emailService.queue(makeQueueData({ priority: EmailPriority.HIGH })); + + const emails: Email[] = await emailRepo.findAll({}); + expect(emails[0].priority).toBe(EmailPriority.HIGH); + }); + + it('defaults to defaultSender from config when no sender provided', async () => { + await emailService.queue(makeQueueData()); + + const emails: Email[] = await emailRepo.findAll({}); + expect(emails[0].sender).toBe(testConfig.defaultSender); + }); + + it('respects provided sender', async () => { + await emailService.queue(makeQueueData({ sender: 'custom@example.com' })); + + const emails: Email[] = await emailRepo.findAll({}); + expect(emails[0].sender).toBe('custom@example.com'); + }); + + it('defaults persist to false', async () => { + await emailService.queue(makeQueueData()); + + const emails: Email[] = await emailRepo.findAll({}); + expect(emails[0].persist).toBe(false); + }); + + it('respects provided persist flag', async () => { + await emailService.queue(makeQueueData({ persist: true })); + + const emails: Email[] = await emailRepo.findAll({}); + expect(emails[0].persist).toBe(true); + }); +}); + +describe('EmailService.sendQueuedEmails', () => { + afterEach(() => void jest.restoreAllMocks()); + + it('returns false and sends nothing when no emails are queued', async () => { + const result: boolean = await emailService.sendQueuedEmails(); + + expect(result).toBe(false); + expect(mockSendMail).not.toHaveBeenCalled(); + }); + + it('returns true after sending emails (indicating there may be more)', async () => { + await emailService.queue(makeQueueData()); + + const result: boolean = await emailService.sendQueuedEmails(); + + expect(result).toBe(true); + expect(mockSendMail).toHaveBeenCalledTimes(1); + }); + + it('deletes email after sending when persist is false', async () => { + await emailService.queue(makeQueueData({ persist: false })); + + await emailService.sendQueuedEmails(); + + const emails: Email[] = await emailRepo.findAll({}); + expect(emails).toHaveLength(0); + }); + + it('updates email status to SENT when persist is true', async () => { + await emailService.queue(makeQueueData({ persist: true })); + + await emailService.sendQueuedEmails(); + + const emails: Email[] = await emailRepo.findAll({}); + expect(emails).toHaveLength(1); + expect(emails[0].status).toBe(EmailStatus.SENT); + }); + + it('updates email status to FAILED when the send is rejected', async () => { + mockSendMail.mockResolvedValue(sentMessageInfo({ + rejected: ['recipient@example.com'], + accepted: [] + })); + await emailService.queue(makeQueueData({ persist: true })); + + await emailService.sendQueuedEmails(); + + const emails: Email[] = await emailRepo.findAll({}); + expect(emails[0].status).toBe(EmailStatus.FAILED); + }); + + it('sends HIGH priority emails before NORMAL and LOW', async () => { + // eslint-disable-next-line typescript/require-await + mockSendMail.mockImplementation(async () => { + return sentMessageInfo(); + }); + + // Queue in reverse priority order to ensure ordering is by priority, not insertion + await emailService.queue(makeQueueData({ priority: EmailPriority.LOW, subject: 'low' })); + await emailService.queue(makeQueueData({ priority: EmailPriority.NORMAL, subject: 'normal' })); + await emailService.queue(makeQueueData({ priority: EmailPriority.HIGH, subject: 'high' })); + + // Capture send order by intercepting the underlying send call + const calls: Email[] = []; + jest.spyOn(emailService as unknown as { send: (e: Email) => Promise }, 'send') + // eslint-disable-next-line typescript/require-await + .mockImplementation(async (email: Email) => { + calls.push(email); + }); + + await emailService.sendQueuedEmails(); + + expect(calls[0].priority).toBe(EmailPriority.HIGH); + expect(calls[1].priority).toBe(EmailPriority.NORMAL); + expect(calls[2].priority).toBe(EmailPriority.LOW); + }); + + it('sends at most 10 emails per batch across all priorities', async () => { + // Need all three priorities to fill 10 slots: HIGH(8) + NORMAL(1 reserved) + LOW(1 reserved) + for (let i: number = 0; i < 10; i++) { + await emailService.queue(makeQueueData({ priority: EmailPriority.HIGH, subject: `High ${i}` })); + } + for (let i: number = 0; i < 5; i++) { + await emailService.queue(makeQueueData({ priority: EmailPriority.NORMAL, subject: `Normal ${i}` })); + } + for (let i: number = 0; i < 5; i++) { + await emailService.queue(makeQueueData({ priority: EmailPriority.LOW, subject: `Low ${i}` })); + } + + await emailService.sendQueuedEmails(); + + // 8 high + 1 normal + 1 low = 10 (normal is capped at 10 - 1 - highCount) + expect(mockSendMail).toHaveBeenCalledTimes(10); + }); + + it('sends at most 8 HIGH priority emails per batch', async () => { + for (let i: number = 0; i < 10; i++) { + await emailService.queue(makeQueueData({ priority: EmailPriority.HIGH, subject: `High ${i}` })); + } + + await emailService.sendQueuedEmails(); + + // HIGH is capped at 8; no NORMAL or LOW queued, so total = 8 + expect(mockSendMail).toHaveBeenCalledTimes(8); + }); + + it('fills remaining slots with NORMAL and LOW after HIGH', async () => { + for (let i: number = 0; i < 8; i++) { + await emailService.queue(makeQueueData({ priority: EmailPriority.HIGH, subject: `High ${i}` })); + } + for (let i: number = 0; i < 5; i++) { + await emailService.queue(makeQueueData({ priority: EmailPriority.NORMAL, subject: `Normal ${i}` })); + } + for (let i: number = 0; i < 5; i++) { + await emailService.queue(makeQueueData({ priority: EmailPriority.LOW, subject: `Low ${i}` })); + } + + await emailService.sendQueuedEmails(); + + // 8 high + 1 normal (10 - 1 - 8 = 1) + 1 low (10 - 8 - 1 = 1) = 10 + expect(mockSendMail).toHaveBeenCalledTimes(10); + }); +}); + +// describe('EmailService rate limiting', () => { +// // A separate server with a very low rate limit so we can exhaust it with real sends +// const rateLimitConfig: EmailConfig = { ...testConfig, maxEmailsPerHour: 3 }; + +// let rateLimitServer: StartedTestServer; +// let rateLimitEmailService: TestEmailService; +// let rateLimitEmailRepo: Repository; + +// beforeAll(async () => { +// rateLimitServer = await startTestServer({ +// dataSources: [ +// createTestDataSource({ +// entities: [...defaultTestServerEntities, Email] +// }) +// ], +// controllers: [], +// cronJobs: [], +// providers: [ +// ...defaultTestServerProviders, +// { token: ZIBRI_DI_TOKENS.EMAIL_CONFIG, useValue: rateLimitConfig }, +// { token: ZIBRI_DI_TOKENS.EMAIL_SERVICE, useClass: TestEmailService } +// ] +// }); +// await rateLimitServer.start(); +// rateLimitEmailService = inject(TestEmailService); +// rateLimitEmailRepo = inject(repositoryTokenFor(Email)); +// }, 15000); + +// afterAll(async () => { +// await rateLimitServer.shutdown(); +// }); + +// beforeEach(async () => { +// await rateLimitEmailRepo.deleteAll({}); +// mockSendMail.mockReset(); +// mockSendMail.mockResolvedValue(sentMessageInfo()); +// }); + +// it('returns false and sends nothing when the rate limit is exhausted', async () => { +// // Exhaust the limit: queue and send 3 emails (maxEmailsPerHour: 3) +// for (let i = 0; i < 3; i++) { +// await rateLimitEmailService.queue(makeQueueData({ subject: `Email ${i}` })); +// } +// await rateLimitEmailService.sendQueuedEmails(); +// expect(mockSendMail).toHaveBeenCalledTimes(3); + +// // Queue one more — rate limiter should now block it +// await rateLimitEmailService.queue(makeQueueData({ subject: 'One too many' })); +// mockSendMail.mockReset(); + +// const result = await rateLimitEmailService.sendQueuedEmails(); + +// expect(result).toBe(false); +// expect(mockSendMail).not.toHaveBeenCalled(); +// }); +// }); + +describe('EmailService attachment validation', () => { + it('throws when an attachment path does not exist', async () => { + await emailService.queue(makeQueueData({ + persist: true, + attachments: [{ filename: 'missing.pdf', path: '/nonexistent/missing.pdf' as never }] + })); + mockSendMail.mockResolvedValue(sentMessageInfo()); + + await expect(emailService.sendQueuedEmails()).rejects.toThrow('does not exist'); + }); + + it('does not throw when attachments is undefined', async () => { + await emailService.queue(makeQueueData({ persist: true })); + + await expect(emailService.sendQueuedEmails()).resolves.not.toThrow(); + }); + + it('does not throw when attachments is an empty array', async () => { + await emailService.queue(makeQueueData({ persist: true, attachments: [] })); + + await expect(emailService.sendQueuedEmails()).resolves.not.toThrow(); + }); + + it('does not throw when attachment path exists', async () => { + // __filename is always a real path + await emailService.queue(makeQueueData({ + persist: true, + attachments: [{ filename: 'test.ts', path: __filename as never }] + })); + + await expect(emailService.sendQueuedEmails()).resolves.not.toThrow(); + }); +}); \ No newline at end of file diff --git a/src/email/email.service.ts b/src/email/email.service.ts index 43bafa9..4f8e745 100644 --- a/src/email/email.service.ts +++ b/src/email/email.service.ts @@ -11,7 +11,6 @@ import { InjectRepository } from '../di/decorators/inject-repository.decorator'; import { Inject } from '../di/decorators/inject.decorator'; import { Injectable } from '../di/decorators/injectable.decorator'; import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; -import { inject } from '../di/inject.function'; import { type LoggerInterface } from '../logging/logger.interface'; import { RateLimiter } from '../rate-limiting/rate-limiter'; import { FsUtilities } from '../utilities/fs.utilities'; @@ -44,9 +43,10 @@ export class EmailService implements EmailServiceInterface, OnAppInit, OnAppShut @Inject(ZIBRI_DI_TOKENS.LOGGER) private readonly logger: LoggerInterface, @InjectRepository(Email) - private readonly emailRepository: Repository + private readonly emailRepository: Repository, + @Inject(ZIBRI_DI_TOKENS.EMAIL_CONFIG) + config: EmailConfigInput | undefined ) { - const config: EmailConfigInput | undefined = inject(ZIBRI_DI_TOKENS.EMAIL_CONFIG); if (!config) { throw new Error('no email config was provided for the token "ZIBRI_DI_TOKENS.MAIL_CONFIG"'); } @@ -158,8 +158,7 @@ export class EmailService implements EmailServiceInterface, OnAppInit, OnAppShut status: EmailStatus.QUEUED, priority }, - take: amount, - order: { createdAt: 'ASC' } + take: amount }); } } \ No newline at end of file diff --git a/src/email/models/email.model.ts b/src/email/models/email.model.ts index 3de64f0..a2822a6 100644 --- a/src/email/models/email.model.ts +++ b/src/email/models/email.model.ts @@ -8,7 +8,7 @@ import { Property } from '../../entity/decorators/property.decorator'; /** * Definition of a Email. */ -@Entity() +@Entity({ defaultOrder: { createdAt: 'ASC' } }) export class Email extends BaseEntity { /** * The createdAt date. Is set to now by default. diff --git a/src/entity/decorators/entity.decorator.ts b/src/entity/decorators/entity.decorator.ts index 86afc83..f62a987 100644 --- a/src/entity/decorators/entity.decorator.ts +++ b/src/entity/decorators/entity.decorator.ts @@ -1,5 +1,8 @@ +import { FindOptionsOrder, FindOptionsOrderValue, FindOptionsRelations } from 'typeorm'; + import { GlobalRegistry } from '../../global/global-registry'; import { Newable } from '../../types/newable.type'; +import { OmitStrict } from '../../types/omit-strict.type'; import { MetadataUtilities } from '../../utilities/metadata.utilities'; import { toSnakeCase } from '../../utilities/to-snake-case.function'; import { type BaseEntity } from '../base-entity.model'; @@ -15,19 +18,55 @@ export type EntityMetadata = { /** * Whether or not this entity is allowed to exist without belonging to a data source. */ - allowOrphan: boolean + allowOrphan: boolean, + /** + * Default ordering to apply to results when nothing has been specified. + */ + // eslint-disable-next-line typescript/no-explicit-any + defaultOrder?: FindOptionsOrder, + /** + * Default relations to include in results when nothing has been specified. + */ + // eslint-disable-next-line typescript/no-explicit-any + defaultRelations?: FindOptionsRelations +}; + +// eslint-disable-next-line jsdoc/require-jsdoc +type EntityMetadataInput< + TOrderKey extends string = never, + TRelationKey extends string = never +> = OmitStrict, 'defaultOrder' | 'defaultRelations'> & { + /** + * Default ordering to apply to results when nothing has been specified. + */ + defaultOrder?: { [K in TOrderKey]?: FindOptionsOrderValue }, + /** + * Default relations to include in results when nothing has been specified. + */ + // eslint-disable-next-line typescript/no-explicit-any + defaultRelations?: { [K in TRelationKey]?: FindOptionsRelations | boolean } }; +// eslint-disable-next-line jsdoc/require-jsdoc, typescript/no-explicit-any +type ConstrainKeys = [TKey] extends [never] ? object : Record; + +// eslint-disable-next-line jsdoc/require-returns /** * Marks an entity. * @param options - Configuration options for the entity. */ -export function Entity(options: Partial = {}): ClassDecorator { - const { tableName, allowOrphan = false } = options; - return target => { +export function Entity( + options: EntityMetadataInput = {} +) { + const { tableName, defaultOrder, defaultRelations, allowOrphan = false } = options; + return & ConstrainKeys>( + target: Newable + ): void => { const metadata: EntityMetadata = { tableName: tableName ?? toSnakeCase(target.name), - allowOrphan + allowOrphan, + defaultOrder, + defaultRelations }; MetadataUtilities.setEntityMetadata(target as unknown as Newable, metadata); GlobalRegistry.entityClasses.push(target as unknown as Newable); diff --git a/src/entity/decorators/property.decorator.ts b/src/entity/decorators/property.decorator.ts index d68827a..fb5d656 100644 --- a/src/entity/decorators/property.decorator.ts +++ b/src/entity/decorators/property.decorator.ts @@ -5,16 +5,16 @@ import { MetadataUtilities } from '../../utilities/metadata.utilities'; import { AnyObject } from '../any-object.model'; import type { BaseEntity } from '../base-entity.model'; import { ArrayPropertyMetadata, ArrayPropertyMetadataInput, ArrayPropertyItemMetadataInput, ArrayPropertyItemMetadata } from '../models/array-property-metadata.model'; -import type { WithDefaultMetadata } from '../models/base-property-metadata.model'; +import { BelongsToOnePropertyMetadata, BelongsToOnePropertyMetadataInput } from '../models/belongs-to-one-property-metadata.model'; import { BooleanPropertyMetadata, BooleanPropertyMetadataInput } from '../models/boolean-property-metadata.model'; import { DatePropertyMetadata, DatePropertyMetadataInput } from '../models/date-property-metadata.model'; import type { FilePropertyMetadata, FilePropertyMetadataInput } from '../models/file-property-metadata.model'; +import { HasOnePropertyMetadata, HasOnePropertyMetadataInput } from '../models/has-one-property-metadata.model'; import { ManyToManyPropertyMetadata, ManyToManyPropertyMetadataInput } from '../models/many-to-many-property-metadata.model'; import { ManyToOnePropertyMetadata, ManyToOnePropertyMetadataInput } from '../models/many-to-one-property-metadata.model'; import { NumberPropertyMetadata, NumberPropertyMetadataInput } from '../models/number-property-metadata.model'; import { ObjectPropertyMetadata, ObjectPropertyMetadataInput } from '../models/object-property-metadata.model'; import { OneToManyPropertyMetadata, OneToManyPropertyMetadataInput } from '../models/one-to-many-property-metadata.model'; -import { OneToOnePropertyMetadata, OneToOnePropertyMetadataInput, HasOnePropertyMetadataInput, BelongsToOnePropertyMetadataInput } from '../models/one-to-one-property-metadata.model'; import { Relation } from '../models/relation.enum'; import { StringPropertyMetadata, StringPropertyMetadataInput } from '../models/string-property-metadata.model'; import { UnknownPropertyMetadata, UnknownPropertyMetadataInput } from '../models/unknown-property-metadata.model'; @@ -38,7 +38,8 @@ export type PropertyMetadata = StringPropertyMetadata */ export type RelationMetadata = ManyToOnePropertyMetadata | OneToManyPropertyMetadata - | OneToOnePropertyMetadata + | HasOnePropertyMetadata + | BelongsToOnePropertyMetadata | ManyToManyPropertyMetadata; /** @@ -54,15 +55,14 @@ export type PropertyMetadataInput = StringPropertyMetadataInput = ManyToOnePropertyMetadataInput - | OneToManyPropertyMetadataInput - | OneToOnePropertyMetadataInput - | HasOnePropertyMetadataInput - | BelongsToOnePropertyMetadataInput - | ManyToManyPropertyMetadataInput; +// /** +// * The metadata input to define a relation property. +// */ +// export type RelationMetadataInput = ManyToOnePropertyMetadataInput +// | OneToManyPropertyMetadataInput +// | HasOnePropertyMetadataInput +// | BelongsToOnePropertyMetadataInput +// | ManyToManyPropertyMetadataInput; /** * Bundles decorators for properties. @@ -99,7 +99,7 @@ export namespace Property { hash: false, ...data }; - return applyData(fullMetadata, data); + return applyData(fullMetadata); } /** @@ -122,7 +122,7 @@ export namespace Property { format: undefined, ...data }; - return applyData(fullMetadata, data); + return applyData(fullMetadata); } /** @@ -139,7 +139,7 @@ export namespace Property { exclude: false, ...data }; - return applyData(fullMetadata, data); + return applyData(fullMetadata); } /** @@ -158,7 +158,7 @@ export namespace Property { excludeFromChangeSets: typeof data?.exclude === 'boolean' ? data.exclude : false, ...data }; - return applyData(fullMetadata, data); + return applyData(fullMetadata); } /** @@ -175,7 +175,7 @@ export namespace Property { allowAdditionalProperties: false, ...data }; - return applyData(fullMetadata, data); + return applyData(fullMetadata); } /** @@ -249,24 +249,35 @@ export namespace Property { excludeFromChangeSets: typeof data?.exclude === 'boolean' ? data.exclude : false, ...data }; - return applyData(fullMetadata, data); + return applyData(fullMetadata); } + // eslint-disable-next-line jsdoc/require-returns /** * Defines a many to one property. * @param metadata - Additional data to specify the property. */ - export function manyToOne(metadata: ManyToOnePropertyMetadataInput): PropertyDecorator { + export function manyToOne( + metadata: ManyToOnePropertyMetadataInput + ) { const fullMetadata: ManyToOnePropertyMetadata = { required: true, type: Relation.MANY_TO_ONE, cascade: [], description: undefined, + joinColumn: undefined, exclude: false, excludeFromChangeSets: typeof metadata?.exclude === 'boolean' ? metadata.exclude : false, ...metadata }; - return applyData(fullMetadata as PropertyMetadata, metadata); + + // eslint-disable-next-line typescript/no-explicit-any + return >>( + target: TCurrentEntity, + propertyKey: string | symbol + ): void => { + applyData(fullMetadata as PropertyMetadata)(target, propertyKey); + }; } /** @@ -283,7 +294,7 @@ export namespace Property { excludeFromChangeSets: typeof metadata?.exclude === 'boolean' ? metadata.exclude : false, ...metadata }; - return applyData(fullMetadata as PropertyMetadata, metadata); + return applyData(fullMetadata as PropertyMetadata); } /** @@ -291,35 +302,44 @@ export namespace Property { * @param metadata - Additional data to specify the property. */ export function hasOne(metadata: HasOnePropertyMetadataInput): PropertyDecorator { - const fullMetadata: OneToOnePropertyMetadata = { + const fullMetadata: HasOnePropertyMetadata = { required: true, - type: Relation.ONE_TO_ONE, + type: Relation.HAS_ONE, cascade: ['remove', 'insert', 'update'], - joinColumn: false, description: undefined, exclude: false, excludeFromChangeSets: typeof metadata?.exclude === 'boolean' ? metadata.exclude : false, ...metadata }; - return applyData(fullMetadata as PropertyMetadata, metadata); + return applyData(fullMetadata as PropertyMetadata); } + // eslint-disable-next-line jsdoc/require-returns /** * Defines a belongs to one property. * @param metadata - Additional data to specify the property. */ - export function belongsToOne(metadata: BelongsToOnePropertyMetadataInput): PropertyDecorator { - const fullMetadata: OneToOnePropertyMetadata = { + export function belongsToOne( + metadata: BelongsToOnePropertyMetadataInput + ) { + const fullMetadata: BelongsToOnePropertyMetadata = { required: true, - type: Relation.ONE_TO_ONE, + type: Relation.BELONGS_TO_ONE, cascade: [], - joinColumn: true, + joinColumn: undefined, description: undefined, exclude: false, excludeFromChangeSets: typeof metadata?.exclude === 'boolean' ? metadata.exclude : false, ...metadata }; - return applyData(fullMetadata as PropertyMetadata, metadata); + + // eslint-disable-next-line typescript/no-explicit-any + return >>( + target: TCurrentEntity, + propertyKey: string | symbol + ): void => { + applyData(fullMetadata as PropertyMetadata)(target, propertyKey); + }; } /** @@ -330,23 +350,21 @@ export namespace Property { const fullMetadata: ManyToManyPropertyMetadata = { required: true, type: Relation.MANY_TO_MANY, - cascade: [], + cascade: metadata.joinTable === true ? ['remove'] : [], description: undefined, + joinTable: undefined, persistence: true, exclude: false, excludeFromChangeSets: typeof metadata?.exclude === 'boolean' ? metadata.exclude : false, ...metadata }; - return applyData(fullMetadata as PropertyMetadata, metadata); + return applyData(fullMetadata as PropertyMetadata); } } // eslint-disable-next-line jsdoc/require-jsdoc -function applyData(data: PropertyMetadata, inputData: PropertyMetadataInput | undefined): PropertyDecorator { +function applyData(data: PropertyMetadata): PropertyDecorator { return (target, key) => { - if (inputData?.required != undefined && (inputData as WithDefaultMetadata).default != undefined) { - warn(`${target.constructor.name}.${key.toString()}: setting "required" won't have any effect, because "default" is also set.`); - } if ('primary' in data && data.primary && data.exclude !== false) { throw new Error(`${target.constructor.name}.${key.toString()}: Cannot mark a primary key with "exclude."`); } diff --git a/src/entity/entity-metadata-missing.error.ts b/src/entity/entity-metadata-missing.error.ts new file mode 100644 index 0000000..27b4c3e --- /dev/null +++ b/src/entity/entity-metadata-missing.error.ts @@ -0,0 +1,15 @@ +import { BaseEntity } from './base-entity.model'; +import { Newable } from '../types/newable.type'; + +/** + * An error that gets thrown when entity metadata that is required is missing. + */ +export class EntityMetadataMissingError extends Error { + constructor(entity: Newable, usage: string = '') { + super([ + `Could not find entity metadata for ${entity.name} ${usage}`, + 'Did you forget to decorate it with @Entity?' + ].join('\n')); + this.name = 'EntityMetadataMissingError'; + } +} \ No newline at end of file diff --git a/src/entity/models/base-relation-metadata.model.ts b/src/entity/models/base-relation-metadata.model.ts index cfa7463..3e32cb4 100644 --- a/src/entity/models/base-relation-metadata.model.ts +++ b/src/entity/models/base-relation-metadata.model.ts @@ -16,7 +16,7 @@ export type BaseRelationMetadata = BasePropertyMetadata target: () => Newable, /** * The name of the inverse property on the target, - * e.g. 'user' if Posts has `@ManyToOne(() => User, 'post')`. + * e.g. 'user' if Posts has `@Property.manyToOne(() => User, 'post')`. */ inverseSide: keyof T }; \ No newline at end of file diff --git a/src/entity/models/belongs-to-one-property-metadata.model.ts b/src/entity/models/belongs-to-one-property-metadata.model.ts new file mode 100644 index 0000000..376abdd --- /dev/null +++ b/src/entity/models/belongs-to-one-property-metadata.model.ts @@ -0,0 +1,34 @@ +import { OmitStrict } from '../../types/omit-strict.type'; +import { BaseEntity } from '../base-entity.model'; +import { BaseRelationMetadata } from './base-relation-metadata.model'; +import { Relation } from './relation.enum'; + +/** + * Metadata for belongs to one properties. + */ +export type BelongsToOnePropertyMetadata = BaseRelationMetadata & { + /** + * The type of the property. + */ + type: Relation.BELONGS_TO_ONE, + /** + * The column on the current entity that holds the foreign key. + */ + joinColumn: string | undefined +}; + +/** + * Input Metadata for belongs to one properties. + */ +export type BelongsToOnePropertyMetadataInput< + T extends BaseEntity, + TJoinKey extends string +> = Partial, 'type'>> + & Pick, 'target' | 'inverseSide'> + & { + /** + * The column on the current entity that holds the foreign key. + * Must be an existing key on the decorated class. + */ + joinColumn?: TJoinKey + }; \ No newline at end of file diff --git a/src/entity/models/has-one-property-metadata.model.ts b/src/entity/models/has-one-property-metadata.model.ts new file mode 100644 index 0000000..1642369 --- /dev/null +++ b/src/entity/models/has-one-property-metadata.model.ts @@ -0,0 +1,20 @@ +import { OmitStrict } from '../../types/omit-strict.type'; +import { BaseEntity } from '../base-entity.model'; +import { BaseRelationMetadata } from './base-relation-metadata.model'; +import { Relation } from './relation.enum'; + +/** + * Metadata for has one properties. + */ +export type HasOnePropertyMetadata = BaseRelationMetadata & { + /** + * The type of the property. + */ + type: Relation.HAS_ONE +}; + +/** + * Input Metadata for has one properties. + */ +export type HasOnePropertyMetadataInput = Partial, 'type'>> + & Pick, 'target' | 'inverseSide'>; \ No newline at end of file diff --git a/src/entity/models/many-to-many-property-metadata.model.ts b/src/entity/models/many-to-many-property-metadata.model.ts index 2959c43..da3ef09 100644 --- a/src/entity/models/many-to-many-property-metadata.model.ts +++ b/src/entity/models/many-to-many-property-metadata.model.ts @@ -14,14 +14,14 @@ export type ManyToManyPropertyMetadata = BaseRelationMetad /** * Whether or not this entity should own the join table. */ - joinTable: boolean, + joinTable: boolean | undefined, /** * Indicates if persistence is enabled for the relation. * By default its enabled, but if you want to avoid any changes * in the relation to be reflected in the data source you can disable it. * If its disabled you can only change a relation from inverse side * of a relation or using relation query builder functionality. - * This is useful for performance optimization since its disabling avoid + * This is useful for performance optimization since its disabling avoids * multiple extra queries during entity save. */ persistence: boolean @@ -31,4 +31,4 @@ export type ManyToManyPropertyMetadata = BaseRelationMetad * Input Metadata for many to many properties. */ export type ManyToManyPropertyMetadataInput = Partial, 'type'>> - & Pick, 'target' | 'joinTable' | 'inverseSide'>; \ No newline at end of file + & Pick, 'target' | 'inverseSide'>; \ No newline at end of file diff --git a/src/entity/models/many-to-one-property-metadata.model.ts b/src/entity/models/many-to-one-property-metadata.model.ts index 956cb8e..9d83256 100644 --- a/src/entity/models/many-to-one-property-metadata.model.ts +++ b/src/entity/models/many-to-one-property-metadata.model.ts @@ -10,11 +10,25 @@ export type ManyToOnePropertyMetadata = BaseRelationMetada /** * The type of the property. */ - type: Relation.MANY_TO_ONE + type: Relation.MANY_TO_ONE, + /** + * The column on the current entity that holds the foreign key. + */ + joinColumn: string | undefined }; /** * Input Metadata for many to one properties. */ -export type ManyToOnePropertyMetadataInput = Partial, 'type'>> - & Pick, 'target' | 'inverseSide'>; \ No newline at end of file +export type ManyToOnePropertyMetadataInput< + T extends BaseEntity, + TJoinKey extends string +> = Partial, 'type' | 'joinColumn'>> + & Pick, 'target' | 'inverseSide'> + & { + /** + * The column on the current entity that holds the foreign key. + * Must be an existing key on the decorated class. + */ + joinColumn?: TJoinKey + }; \ No newline at end of file diff --git a/src/entity/models/one-to-one-property-metadata.model.ts b/src/entity/models/one-to-one-property-metadata.model.ts deleted file mode 100644 index 91b30cd..0000000 --- a/src/entity/models/one-to-one-property-metadata.model.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { BaseRelationMetadata } from './base-relation-metadata.model'; -import { Relation } from './relation.enum'; -import { OmitStrict } from '../../types/omit-strict.type'; -import { type BaseEntity } from '../base-entity.model'; - -/** - * Metadata for one to one properties. - */ -export type OneToOnePropertyMetadata = BaseRelationMetadata & { - /** - * The type of the property. - */ - type: Relation.ONE_TO_ONE, - /** - * Whether or not this entity has a join column. - */ - joinColumn: boolean -}; - -/** - * Input Metadata for one to one properties. - */ -export type OneToOnePropertyMetadataInput = Partial, 'type'>> - & Pick, 'target' | 'inverseSide' | 'cascade'>; - -/** - * Input Metadata for has one properties. - */ -export type HasOnePropertyMetadataInput = OmitStrict, 'cascade' | 'joinColumn'> - & Partial, 'cascade'>>; - -/** - * Input Metadata for belongs to one properties. - */ -export type BelongsToOnePropertyMetadataInput = HasOnePropertyMetadataInput; \ No newline at end of file diff --git a/src/entity/models/relation.enum.ts b/src/entity/models/relation.enum.ts index 59034e1..362ead9 100644 --- a/src/entity/models/relation.enum.ts +++ b/src/entity/models/relation.enum.ts @@ -2,7 +2,8 @@ * All possible relations. */ export enum Relation { - ONE_TO_ONE = 'one-to-one', + HAS_ONE = 'has-one', + BELONGS_TO_ONE = 'belongs-to-one', ONE_TO_MANY = 'one-to-many', MANY_TO_ONE = 'many-to-one', MANY_TO_MANY = 'many-to-many' diff --git a/src/entity/partial-class.model.ts b/src/entity/partial-class.model.ts index 434c8ee..df628a5 100644 --- a/src/entity/partial-class.model.ts +++ b/src/entity/partial-class.model.ts @@ -17,7 +17,7 @@ export function PartialClass( const original: Record = MetadataUtilities.getModelProperties(Base); const partialMeta: Record = {}; for (const [prop, meta] of ObjectUtilities.entries(original)) { - partialMeta[prop] = 'required' in meta ? { ...meta, required: false } : meta; + partialMeta[prop] = { ...meta, required: false }; } MetadataUtilities.setModelProperties(PartialClass, partialMeta); diff --git a/src/event/event-cleanup.cron-job.ts b/src/event/event-cleanup.cron-job.ts index 0d27a96..273bee5 100644 --- a/src/event/event-cleanup.cron-job.ts +++ b/src/event/event-cleanup.cron-job.ts @@ -28,12 +28,10 @@ export class EventCleanupCronJob extends CronJob { await this.logger.info('cleans up past events'); try { - // const yesterday: Date = new Date(Date.now() - Ms.DAY); - const events: Event[] = (await this.repository.findAll({ - where: { status: EventStatus.FINISHED }, - relations: ['eventSubscriberRuns'] - })).filter(e => Date.now() > new Date(e.cleanupAt).getTime()); - const res: Event[] = await this.repository.deleteAll({ id: { oneOf: events.map(e => e.id) } }); + const res: Event[] = await this.repository.deleteAll({ + status: EventStatus.FINISHED, + cleanupAt: { before: new Date() } + }); await this.logger.info(`removed ${res.length} events`); } catch { diff --git a/src/event/event-subscriber-run.model.ts b/src/event/event-subscriber-run.model.ts index b59cd8a..0888600 100644 --- a/src/event/event-subscriber-run.model.ts +++ b/src/event/event-subscriber-run.model.ts @@ -2,7 +2,7 @@ import { Event } from './event.model'; import { BaseEntity } from '../entity/base-entity.model'; import { Entity } from '../entity/decorators/entity.decorator'; import { Property } from '../entity/decorators/property.decorator'; -import { OmitClass } from '../entity/omit-class.model'; +import { OmitStrict } from '../types/omit-strict.type'; /** * Data of a event subscriber run. @@ -17,8 +17,13 @@ export class EventSubscriberRun extends BaseEntity { /** * The event that triggered this run. */ - @Property.manyToOne({ target: () => Event, inverseSide: 'eventSubscriberRuns' }) + @Property.manyToOne({ target: () => Event, joinColumn: 'eventId', inverseSide: 'eventSubscriberRuns' }) event!: Event; + /** + * The id of the event that this run belongs to. + */ + @Property.string({ format: 'uuid' }) + eventId!: string; /** * The id of the subscriber. */ @@ -34,10 +39,4 @@ export class EventSubscriberRun extends BaseEntity { /** * The data needed to create a new event subscriber run. */ -export class EventSubscriberRunCreateData extends OmitClass(EventSubscriberRun, ['id', 'event', 'createdAt']) { - /** - * The event that triggered this run. - */ - @Property.manyToOne({ target: () => Event, inverseSide: 'eventSubscriberRuns' }) - event!: Event; -} \ No newline at end of file +export type EventSubscriberRunCreateData = OmitStrict, 'id' | 'eventId' | 'createdAt'>; \ No newline at end of file diff --git a/src/event/event.model.ts b/src/event/event.model.ts index bed8e60..0982307 100644 --- a/src/event/event.model.ts +++ b/src/event/event.model.ts @@ -15,7 +15,7 @@ export enum EventStatus { /** * Definition for an event. */ -@Entity({ allowOrphan: true }) +@Entity({ allowOrphan: true, defaultOrder: { createdAt: 'ASC' } }) export class Event extends BaseEntity { /** * The timestamp at which the event has been created. diff --git a/src/event/event.service.ts b/src/event/event.service.ts index 0fd713c..3fc30e0 100644 --- a/src/event/event.service.ts +++ b/src/event/event.service.ts @@ -78,8 +78,7 @@ implements EventServiceInterface, OnAppInit, OnAppStart, AfterAppShutdo async onAppStart(): Promise { const events: Event[] = await this.eventRepository.findAll({ where: { status: { not: EventStatus.FINISHED } }, - relations: ['eventSubscriberRuns'], - order: { createdAt: 'ASC' } + relations: ['eventSubscriberRuns'] }); for (const event of events) { @@ -222,7 +221,11 @@ implements EventServiceInterface, OnAppInit, OnAppStart, AfterAppShutdo await this.logger.error(new EventProcessingError(event, options.subscriberId, error)); } - await this.eventSubscriberRunRepository.create({ event, subscriberId: options.subscriberId, error }); + await this.eventSubscriberRunRepository.create({ + event, + subscriberId: options.subscriberId, + error + }); if (await this.eventHasUnfinishedSubscriptions(event)) { return; } diff --git a/src/global/model-registry/default-descriptor.ts b/src/global/model-registry/default-descriptor.ts index 863f37a..6e47e8b 100644 --- a/src/global/model-registry/default-descriptor.ts +++ b/src/global/model-registry/default-descriptor.ts @@ -42,7 +42,8 @@ export class DefaultDescriptor { case Relation.MANY_TO_MANY: case Relation.ONE_TO_MANY: case Relation.MANY_TO_ONE: - case Relation.ONE_TO_ONE: { + case Relation.HAS_ONE: + case Relation.BELONGS_TO_ONE: { const nested: DefaultDescriptor = ModelRegistry.get(metadata.target()).defaultDescriptor; if (nested.hasAnything()) { this.nestedKeys.set(key, nested); diff --git a/src/global/model-registry/encryption-descriptor.ts b/src/global/model-registry/encryption-descriptor.ts index b8abb98..bed9d05 100644 --- a/src/global/model-registry/encryption-descriptor.ts +++ b/src/global/model-registry/encryption-descriptor.ts @@ -40,7 +40,8 @@ export class EncryptionDescriptor { switch (metadata.type) { case Relation.MANY_TO_ONE: - case Relation.ONE_TO_ONE: + case Relation.HAS_ONE: + case Relation.BELONGS_TO_ONE: case Relation.MANY_TO_MANY: case Relation.ONE_TO_MANY: { const nested: EncryptionDescriptor = ModelRegistry.get(metadata.target()).encryptionDescriptor; diff --git a/src/global/model-registry/exclude-descriptor.ts b/src/global/model-registry/exclude-descriptor.ts index ab5dc20..d0e113e 100644 --- a/src/global/model-registry/exclude-descriptor.ts +++ b/src/global/model-registry/exclude-descriptor.ts @@ -38,7 +38,8 @@ export class ExcludeDescriptor { switch (metadata.type) { case Relation.MANY_TO_ONE: - case Relation.ONE_TO_ONE: + case Relation.HAS_ONE: + case Relation.BELONGS_TO_ONE: case Relation.MANY_TO_MANY: case Relation.ONE_TO_MANY: { const nested: ExcludeDescriptor = ModelRegistry.get(metadata.target()).excludeDescriptor; diff --git a/src/global/model-registry/hash-descriptor.ts b/src/global/model-registry/hash-descriptor.ts index 57a6916..38ed6ee 100644 --- a/src/global/model-registry/hash-descriptor.ts +++ b/src/global/model-registry/hash-descriptor.ts @@ -40,7 +40,8 @@ export class HashDescriptor { switch (metadata.type) { case Relation.MANY_TO_ONE: - case Relation.ONE_TO_ONE: + case Relation.HAS_ONE: + case Relation.BELONGS_TO_ONE: case Relation.MANY_TO_MANY: case Relation.ONE_TO_MANY: { const nested: HashDescriptor = ModelRegistry.get(metadata.target()).hashDescriptor; diff --git a/src/index.ts b/src/index.ts index abdd9a0..8b25753 100644 --- a/src/index.ts +++ b/src/index.ts @@ -134,10 +134,13 @@ export * from './routing/models/crud-controller.model'; // context export * from './context/als.utilities'; export * from './context/base-context'; + export * from './context/request/http-request.context'; export * from './context/request/websocket-request.context'; export * from './context/request/request-context-token.model'; +export * from './context/cache/cache.context'; + // error handling export * from './error-handling/error-handler'; export * from './error-handling/error-handler.model'; @@ -201,6 +204,7 @@ export * from './entity/partial-class.model'; export * from './entity/pick-class.model'; export * from './entity/any-object.model'; export * from './entity/base-entity.model'; +export * from './entity/entity-metadata-missing.error'; export * from './entity/decorators/entity.decorator'; export * from './entity/decorators/property.decorator'; @@ -213,7 +217,8 @@ export * from './entity/models/date-property-metadata.model'; export * from './entity/models/boolean-property-metadata.model'; export * from './entity/models/many-to-one-property-metadata.model'; export * from './entity/models/one-to-many-property-metadata.model'; -export * from './entity/models/one-to-one-property-metadata.model'; +export * from './entity/models/belongs-to-one-property-metadata.model'; +export * from './entity/models/has-one-property-metadata.model'; export * from './entity/models/many-to-many-property-metadata.model'; export * from './entity/models/relation.enum'; export * from './entity/models/unknown-property-metadata.model'; @@ -264,7 +269,13 @@ export * from './data-source/query-failed.error'; export * from './data-source/decorators/data-source.decorator'; export * from './data-source/data-sources/data-source.interface'; -export * from './data-source/data-sources/postgres-data-source.model'; +export * from './data-source/data-sources/postgres-typeorm-data-source.model'; +export * from './data-source/data-sources/typeorm-base-data-source.model'; +export * from './data-source/data-sources/data-source-initialization.error'; +export * from './data-source/data-sources/sql-data-source.interface'; + +export * from './data-source/data-sources/where-converter/typeorm-where-filter.converter'; +export * from './data-source/data-sources/where-converter/postgres-typeorm-where-filter.converter'; export * from './data-source/transaction/transaction.model'; @@ -284,13 +295,14 @@ export * from './data-source/models/options/update-all-options.model'; export * from './data-source/models/options/update-by-id-options.model'; export * from './data-source/models/options/count-options.model'; -export * from './data-source/models/where/where-filter.model'; export * from './data-source/models/where/array-where-filter.model'; export * from './data-source/models/where/boolean-where-filter.model'; export * from './data-source/models/where/date-where-filter.model'; export * from './data-source/models/where/number-where-filter.model'; export * from './data-source/models/where/object-where-filter.model'; export * from './data-source/models/where/string-where-filter.model'; +export * from './data-source/models/where/where-filter-keys.model'; +export * from './data-source/models/where/where-filter.model'; export * from './data-source/migration/migration.model'; export * from './data-source/migration/migration-entity.model'; diff --git a/src/logging/log-context.model.ts b/src/logging/log-context.model.ts index 349fb4b..4d5db3f 100644 --- a/src/logging/log-context.model.ts +++ b/src/logging/log-context.model.ts @@ -1,5 +1,5 @@ import { LoggedError } from './logged-error.model'; -import { CacheOperation } from '../caching/cache/cache-operation.enum'; +import { CacheContext } from '../context/cache/cache.context'; import { Property } from '../entity/decorators/property.decorator'; import { HttpMethod } from '../http/http-method.enum'; import { HttpStatus } from '../http/http-status.enum'; @@ -48,37 +48,6 @@ export class LogRequestContext { durationInMs?: number; } -/** - * Context information about a cache that triggered a log. - */ -export class LogCacheContext { - /** - * The name of the cache. - */ - @Property.string() - cache!: string; - /** - * The cache operation currently running. - */ - @Property.string({ enum: CacheOperation }) - operation!: CacheOperation; - /** - * The key of the value in the cache. - */ - @Property.unknown({ required: false }) - key?: unknown; - /** - * Whether or not the cache has been hit. - */ - @Property.boolean({ required: false }) - hit?: boolean; - /** - * The duration that the original function took. - */ - @Property.number({ required: false }) - durationInMs?: number; -} - /** * Context for the log, like the request, the id of the user if applicable and the stack trace. */ @@ -101,8 +70,8 @@ export class LogContext { /** * Context information about the cache that triggered the log. */ - @Property.array({ items: { type: 'object', cls: () => LogCacheContext }, required: false }) - cache?: LogCacheContext[]; + @Property.array({ items: { type: 'object', cls: () => CacheContext }, required: false }) + cache?: CacheContext[]; /** * An error associated to this log. */ diff --git a/src/open-api/open-api.service.ts b/src/open-api/open-api.service.ts index 8a8f569..01904d1 100644 --- a/src/open-api/open-api.service.ts +++ b/src/open-api/open-api.service.ts @@ -14,10 +14,11 @@ import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; import { inject } from '../di/inject.function'; import { BaseEntity } from '../entity/base-entity.model'; import { PropertyMetadata } from '../entity/decorators/property.decorator'; +import { BelongsToOnePropertyMetadata } from '../entity/models/belongs-to-one-property-metadata.model'; +import { HasOnePropertyMetadata } from '../entity/models/has-one-property-metadata.model'; import { ManyToManyPropertyMetadata } from '../entity/models/many-to-many-property-metadata.model'; import { ManyToOnePropertyMetadata } from '../entity/models/many-to-one-property-metadata.model'; import { OneToManyPropertyMetadata } from '../entity/models/one-to-many-property-metadata.model'; -import { OneToOnePropertyMetadata } from '../entity/models/one-to-one-property-metadata.model'; import { Relation } from '../entity/models/relation.enum'; import { OmitClass } from '../entity/omit-class.model'; import { GlobalRegistry } from '../global/global-registry'; @@ -449,7 +450,7 @@ export class OpenApiService implements OpenApiServiceInterface, OnAppInit { return undefined; } const propMeta: Record = MetadataUtilities.getModelProperties(response.cls); - const schema: OpenApiSchemaObject = this.buildOpenApiSchemaForProperties(propMeta, response.cls, 'response'); + const schema: OpenApiSchemaObject = this.buildOpenApiSchemaForProperties(propMeta, response.cls, 'response', new Set()); if (response.isArray === true) { return { [MimeType.JSON]: { schema: { type: 'array', items: schema } } }; @@ -491,7 +492,7 @@ export class OpenApiService implements OpenApiServiceInterface, OnAppInit { continue; } const propMeta: Record = MetadataUtilities.getModelProperties(response.cls); - const schema: OpenApiSchemaObject = this.buildOpenApiSchemaForProperties(propMeta, response.cls, 'response'); + const schema: OpenApiSchemaObject = this.buildOpenApiSchemaForProperties(propMeta, response.cls, 'response', new Set()); if (response.isArray === true) { schemas.push({ type: 'array', items: schema }); continue; @@ -544,7 +545,7 @@ export class OpenApiService implements OpenApiServiceInterface, OnAppInit { return undefined; } const propMeta: Record = MetadataUtilities.getModelProperties(metadata.modelClass); - const schema: OpenApiSchemaObject = this.buildOpenApiSchemaForProperties(propMeta, metadata.modelClass, 'request'); + const schema: OpenApiSchemaObject = this.buildOpenApiSchemaForProperties(propMeta, metadata.modelClass, 'request', new Set()); return { required: typeof metadata.required === 'boolean' ? metadata.required : undefined, description: metadata.description, @@ -556,8 +557,17 @@ export class OpenApiService implements OpenApiServiceInterface, OnAppInit { private buildOpenApiSchemaForProperties( propMeta: Record, entity: Newable, - context: 'request' | 'response' + context: 'request' | 'response', + visited: Set> ): OpenApiSchemaObject { + // ---- cycle guard ---- + if (visited.has(entity)) { + // Return a stub schema (or a ref if you later extract components). + // Returning an empty object is safe, but a description helps debugging. + return { type: 'object', description: 'Circular reference omitted' }; + } + visited.add(entity); + const properties: Record = {}; const required: string[] = []; @@ -613,17 +623,18 @@ export class OpenApiService implements OpenApiServiceInterface, OnAppInit { case 'object': { const objectPropMeta: Record = MetadataUtilities.getModelProperties(meta.cls()); properties[key] = { - ...this.buildOpenApiSchemaForProperties(objectPropMeta, entity, context), + ...this.buildOpenApiSchemaForProperties(objectPropMeta, entity, context, visited), description: meta.description }; continue; } - case Relation.ONE_TO_ONE: + case Relation.HAS_ONE: + case Relation.BELONGS_TO_ONE: case Relation.MANY_TO_ONE: { const targetClass: Newable = this.getTargetClassForRelation(meta, entity); const objectPropMeta: Record = MetadataUtilities.getModelProperties(targetClass); properties[key] = { - ...this.buildOpenApiSchemaForProperties(objectPropMeta, entity, context), + ...this.buildOpenApiSchemaForProperties(objectPropMeta, entity, context, visited), description: meta.description }; continue; @@ -645,7 +656,8 @@ export class OpenApiService implements OpenApiServiceInterface, OnAppInit { } }, entity, - context + context, + visited ); properties[key] = { type: 'array', @@ -658,7 +670,12 @@ export class OpenApiService implements OpenApiServiceInterface, OnAppInit { if (meta.items.type === 'object') { entity = meta.items.cls(); } - const items: OpenApiSchemaObject = this.buildOpenApiSchemaForProperties({ items: meta.items }, entity, context); + const items: OpenApiSchemaObject = this.buildOpenApiSchemaForProperties( + { items: meta.items }, + entity, + context, + visited + ); properties[key] = { type: 'array', description: meta.description, @@ -692,7 +709,8 @@ export class OpenApiService implements OpenApiServiceInterface, OnAppInit { meta: OneToManyPropertyMetadata | ManyToManyPropertyMetadata | ManyToOnePropertyMetadata - | OneToOnePropertyMetadata, + | BelongsToOnePropertyMetadata + | HasOnePropertyMetadata, entity: Newable ): Newable { const fullTargetClass: Newable = meta.target(); @@ -702,7 +720,8 @@ export class OpenApiService implements OpenApiServiceInterface, OnAppInit { for (const key in properties) { const property: PropertyMetadata = properties[key]; if ( - property.type !== Relation.ONE_TO_ONE + property.type !== Relation.BELONGS_TO_ONE + && property.type !== Relation.HAS_ONE && property.type !== Relation.ONE_TO_MANY && property.type !== Relation.MANY_TO_ONE && property.type !== Relation.MANY_TO_MANY @@ -771,7 +790,7 @@ export class OpenApiService implements OpenApiServiceInterface, OnAppInit { const propMeta: Record = MetadataUtilities.getModelProperties(meta.cls()); return { description: meta.description, - ...this.buildOpenApiSchemaForProperties(propMeta, meta.cls(), 'request') + ...this.buildOpenApiSchemaForProperties(propMeta, meta.cls(), 'request', new Set()) }; } case 'array': { diff --git a/src/parsing/form-data/form-data.body-parser.ts b/src/parsing/form-data/form-data.body-parser.ts index 4d7b353..3b30f6b 100644 --- a/src/parsing/form-data/form-data.body-parser.ts +++ b/src/parsing/form-data/form-data.body-parser.ts @@ -172,7 +172,8 @@ export class FormDataBodyParser implements BodyParserInterface, OnAppInit { res[key] = parseDate(value); break; } - case Relation.ONE_TO_ONE: + case Relation.HAS_ONE: + case Relation.BELONGS_TO_ONE: case Relation.ONE_TO_MANY: case Relation.MANY_TO_ONE: case Relation.MANY_TO_MANY: diff --git a/src/parsing/functions/parse-boolean.function.ts b/src/parsing/functions/parse-boolean.function.ts index 204bf93..1c3f51e 100644 --- a/src/parsing/functions/parse-boolean.function.ts +++ b/src/parsing/functions/parse-boolean.function.ts @@ -1,4 +1,3 @@ - // eslint-disable-next-line jsdoc/require-jsdoc export function parseBoolean(rawValue: unknown): unknown { if (typeof rawValue === 'boolean') { diff --git a/src/parsing/functions/parse-object.function.ts b/src/parsing/functions/parse-object.function.ts index ce261fb..4f314f3 100644 --- a/src/parsing/functions/parse-object.function.ts +++ b/src/parsing/functions/parse-object.function.ts @@ -62,7 +62,8 @@ export function parseObject( res[propertyKey] = parseArray(res[propertyKey], m); break; } - case Relation.ONE_TO_ONE: + case Relation.HAS_ONE: + case Relation.BELONGS_TO_ONE: case Relation.ONE_TO_MANY: case Relation.MANY_TO_ONE: case Relation.MANY_TO_MANY: diff --git a/src/parsing/parser.ts b/src/parsing/parser.ts index 4e4b083..bdb3ea8 100644 --- a/src/parsing/parser.ts +++ b/src/parsing/parser.ts @@ -100,7 +100,8 @@ export class Parser implements ParserInterface, OnAppInit { // eslint-disable-next-line jsdoc/require-jsdoc parseQueryParam(req: HttpRequest | WebsocketRequest, metadata: QueryParamMetadata): unknown { const rawValue: string | undefined = req.query?.[metadata.name]; - return this.queryParamParseFunctions[metadata.type](rawValue, metadata); + const res: unknown = this.queryParamParseFunctions[metadata.type](rawValue, metadata); + return res; } // eslint-disable-next-line jsdoc/require-jsdoc diff --git a/src/routing/decorators/body.decorator.ts b/src/routing/decorators/body.decorator.ts index 5b68f43..bec4c5e 100644 --- a/src/routing/decorators/body.decorator.ts +++ b/src/routing/decorators/body.decorator.ts @@ -135,7 +135,8 @@ function resolveMaxSize(bytes: BigNumber, properties: Record, HeaderMetaObject extends Record >(route: RouteConfiguration): RequestHandler { - const handler: RequestHandler = (async ( - request: HttpRequest, - res: HttpResponse, - next: NextFunction - ) => { + const handler: RequestHandler = (async (request: HttpRequest, res, next) => { + Object.defineProperty(request, 'params', { + value: { ...request.params }, + writable: true, + configurable: true, + enumerable: true + }); + Object.defineProperty(request, 'query', { + value: { ...request.query }, + writable: true, + configurable: true, + enumerable: true + }); + Object.defineProperty(request, 'headers', { + value: { ...request.headers }, + writable: true, + configurable: true, + enumerable: true + }); + const context: HttpRequestContext = new HttpRequestContext(request, res, undefined, undefined); await AlsUtilities.runWithHttpRequestContext(context, async () => { try { @@ -289,9 +304,28 @@ export class Router implements RouterInterface, OnAppInit, OnAppStart { if (!responses.length) { await this.logger.warn(`No responses defined on route ${controllerClass.name}.${route.controllerMethod}`); } - const handler: RequestHandler = (async (req: HttpRequest, res: HttpResponse, next: NextFunction) => { + const handler: RequestHandler = (async (request: HttpRequest, res, next) => { + Object.defineProperty(request, 'params', { + value: { ...request.params }, + writable: true, + configurable: true, + enumerable: true + }); + Object.defineProperty(request, 'query', { + value: { ...request.query }, + writable: true, + configurable: true, + enumerable: true + }); + Object.defineProperty(request, 'headers', { + value: { ...request.headers }, + writable: true, + configurable: true, + enumerable: true + }); + const context: HttpRequestContext = new HttpRequestContext( - req, + request, res, controllerClass, route.controllerMethod diff --git a/src/utilities/metadata-injection-keys.enum.ts b/src/utilities/metadata-injection-keys.enum.ts index 24359fd..e51c235 100644 --- a/src/utilities/metadata-injection-keys.enum.ts +++ b/src/utilities/metadata-injection-keys.enum.ts @@ -5,6 +5,7 @@ export enum MetadataInjectionKeys { FILE_LOCATION = 'file:location', // eslint-disable-next-line cspell/spellchecker PARAM_TYPES = 'design:paramtypes', + TYPE = 'design:type', DI_TOKEN = 'di:token', DI_INJECT_PARAM_TOKENS = 'di:inject_param_tokens', DI_INJECT_PARAM_OPTIONS = 'di:inject_param_options', diff --git a/src/utilities/typeorm.utilities.ts b/src/utilities/typeorm.utilities.ts new file mode 100644 index 0000000..17c85ac --- /dev/null +++ b/src/utilities/typeorm.utilities.ts @@ -0,0 +1,75 @@ +import { DataSource, EntityTarget, EntityMetadata as TOEntityMetadata } from 'typeorm'; +import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata.js'; + +import { ColumnType } from '../data-source/models/column-type.model'; +import { Transaction } from '../data-source/transaction/transaction.model'; +import { BaseEntity } from '../entity/base-entity.model'; + +/** + * Utilities for dealing with typeorm. + */ +export abstract class TypeOrmUtilities { + /** + * Gets the metadata for a typeorm column. + * @param target - The entity. + * @param propertyName - The name of the property to get the column metadata for. + * @param transaction - The transaction to use to get the column metadata. + * @returns The typeorm column metadata. + * @throws When the provided propertyName could not be found as a column. + */ + static getColumnMetadata( + target: EntityTarget, + propertyName: keyof T | string & {}, + transaction: Transaction + ): ColumnMetadata { + const metadata: TOEntityMetadata = this.getEntityMetadata(target, transaction); + const column: ColumnMetadata | undefined = metadata.columns.find( + (col) => col.propertyName === propertyName + ); + + if (!column) { + throw new Error( + `Column ${propertyName.toString()} not found in model` + ); + } + + return column; + } + + /** + * Gets the typeorm metadata for a given entity. + * @param target - The target entity. + * @param transaction - The transaction to run this command with. + * @returns The typeorm metadata. + */ + static getEntityMetadata(target: EntityTarget, transaction: Transaction): TOEntityMetadata { + return transaction.queryRunner.connection.getMetadata(target); + } + + // eslint-disable-next-line jsdoc/require-param + /** + * Normalizes the type of the given column using the provided dataSource. + * @returns The normalized column type as a string. + * @throws When no dataSource has been provided. + */ + static normalizeColumnType( + dataSource: DataSource | undefined, + column: { + // eslint-disable-next-line jsdoc/require-jsdoc + type?: ColumnType | string & {}, + // eslint-disable-next-line jsdoc/require-jsdoc + length?: number | string, + // eslint-disable-next-line jsdoc/require-jsdoc + precision?: number | null, + // eslint-disable-next-line jsdoc/require-jsdoc + scale?: number, + // eslint-disable-next-line jsdoc/require-jsdoc + isArray?: boolean + } + ): string { + if (!dataSource) { + throw new Error('The data source needs to be initialized before it can be used.'); + } + return dataSource.driver.normalizeType(column); + } +} \ No newline at end of file diff --git a/src/validation/validation-problem.model.ts b/src/validation/validation-problem.model.ts index 8b7d7a8..dca126d 100644 --- a/src/validation/validation-problem.model.ts +++ b/src/validation/validation-problem.model.ts @@ -1,10 +1,11 @@ import { BaseEntity } from '../entity/base-entity.model'; import { RelationMetadata } from '../entity/decorators/property.decorator'; +import { BelongsToOnePropertyMetadata } from '../entity/models/belongs-to-one-property-metadata.model'; import { FileSize } from '../entity/models/file-property-metadata.model'; +import { HasOnePropertyMetadata } from '../entity/models/has-one-property-metadata.model'; import { ManyToManyPropertyMetadata } from '../entity/models/many-to-many-property-metadata.model'; import { ManyToOnePropertyMetadata } from '../entity/models/many-to-one-property-metadata.model'; import { OneToManyPropertyMetadata } from '../entity/models/one-to-many-property-metadata.model'; -import { OneToOnePropertyMetadata } from '../entity/models/one-to-one-property-metadata.model'; import { Relation } from '../entity/models/relation.enum'; import { MimeType } from '../http/mime-type.enum'; @@ -82,15 +83,12 @@ export class RelationsNotAllowedValidationProblem implements ValidationProblem { private getExample(metadata: RelationMetadata, relationKey: string): string { switch (metadata.type) { - case Relation.MANY_TO_ONE: { - return this.getObjectExample(metadata, relationKey); - } - case Relation.ONE_TO_MANY: { - return this.getArrayExample(metadata, relationKey); - } - case Relation.ONE_TO_ONE: { + case Relation.MANY_TO_ONE: + case Relation.HAS_ONE: + case Relation.BELONGS_TO_ONE: { return this.getObjectExample(metadata, relationKey); } + case Relation.ONE_TO_MANY: case Relation.MANY_TO_MANY: { return this.getArrayExample(metadata, relationKey); } @@ -110,7 +108,7 @@ export class RelationsNotAllowedValidationProblem implements ValidationProblem { } private getObjectExample( - metadata: OneToOnePropertyMetadata | ManyToOnePropertyMetadata, + metadata: BelongsToOnePropertyMetadata | HasOnePropertyMetadata | ManyToOnePropertyMetadata, relationKey: string ): string { return [ diff --git a/src/validation/validation.service.ts b/src/validation/validation.service.ts index 62e62f6..65ad88a 100644 --- a/src/validation/validation.service.ts +++ b/src/validation/validation.service.ts @@ -10,6 +10,7 @@ import { Injectable } from '../di/decorators/injectable.decorator'; import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; import { inject } from '../di/inject.function'; import { PropertyMetadata, Property, RelationMetadata } from '../entity/decorators/property.decorator'; +import { Relation } from '../entity/models/relation.enum'; import { ValidationError } from '../error-handling/errors/validation.error'; import { MimeType } from '../http/mime-type.enum'; import { FormData } from '../parsing/form-data/form-data.model'; @@ -224,10 +225,11 @@ export class ValidationService implements ValidationServiceInterface { const fullKey: string = parentKey ? `${parentKey}.${key}` : key; if ( - metadata.type === 'many-to-one' - || metadata.type === 'one-to-many' - || metadata.type === 'one-to-one' - || metadata.type === 'many-to-many' + metadata.type === Relation.MANY_TO_ONE + || metadata.type === Relation.ONE_TO_MANY + || metadata.type === Relation.HAS_ONE + || metadata.type === Relation.BELONGS_TO_ONE + || metadata.type === Relation.MANY_TO_MANY ) { return [new RelationsNotAllowedValidationProblem(fullKey, metadata, key)]; } diff --git a/src/websocket/models/websocket-message.model.ts b/src/websocket/models/websocket-message.model.ts index 36c20d6..43a505d 100644 --- a/src/websocket/models/websocket-message.model.ts +++ b/src/websocket/models/websocket-message.model.ts @@ -21,7 +21,7 @@ export enum WebsocketRecipientType { /** * Definition of a message sent via websocket connection. */ -@Entity() +@Entity({ defaultOrder: { seq: 'ASC' } }) export class WebsocketMessage extends BaseEntity { /** * The date at which the message was created. diff --git a/src/websocket/models/websocket-request.model.ts b/src/websocket/models/websocket-request.model.ts index 38e0298..d56a011 100644 --- a/src/websocket/models/websocket-request.model.ts +++ b/src/websocket/models/websocket-request.model.ts @@ -4,37 +4,45 @@ import { HttpRequest } from '../../http/http-request.model'; import { KnownHeader } from '../../http/known-header.enum'; // eslint-disable-next-line jsdoc/require-jsdoc -class QueryObject implements Record { - [key: string]: string | undefined +class QueryObject implements Record { + [key: string]: unknown } // eslint-disable-next-line jsdoc/require-jsdoc class ParamsObject extends QueryObject {} // eslint-disable-next-line jsdoc/require-jsdoc -class HeadersObject implements Partial> { - [key: string]: string | undefined +class HeadersObject implements Partial> { + [key: string]: unknown } /** * A websocket request sent from a client. */ -export class WebsocketRequest implements Partial> { +export class WebsocketRequest< + T = unknown, + PathParamsObject extends Record = Record, + QueryParamsObject extends Record = Record, + HeaderParamsObject extends Record = Partial> +> implements Partial, + 'headers' | 'body' | 'query' | 'params'> +> { // eslint-disable-next-line jsdoc/require-jsdoc @Property.object({ cls: () => QueryObject, required: false, allowAdditionalProperties: true }) - query: QueryObject | undefined; + query: QueryParamsObject | undefined; // eslint-disable-next-line jsdoc/require-jsdoc @Property.object({ cls: () => HeadersObject, allowAdditionalProperties: true }) - headers!: Partial>; + headers!: HeaderParamsObject; // eslint-disable-next-line jsdoc/require-jsdoc @Property.object({ cls: () => ParamsObject, required: false, allowAdditionalProperties: true }) - params: ParamsObject | undefined; + params: PathParamsObject | undefined; // eslint-disable-next-line jsdoc/require-jsdoc @Property.unknown({ required: false }) - body: unknown | undefined; + body: T | undefined; } /** diff --git a/src/websocket/services/websocket.service.ts b/src/websocket/services/websocket.service.ts index 43e715a..ac5e080 100644 --- a/src/websocket/services/websocket.service.ts +++ b/src/websocket/services/websocket.service.ts @@ -256,8 +256,7 @@ export class WebsocketService implements WebsocketServiceInterface