diff --git a/README.md b/README.md index 383b49b..7d4bfcf 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Import the appropriate entry point for your database engine. The versions listed | MySQL 5.7+, MySQL 5.6+ | `dare/mysql57` | | PostgreSQL 16+ | `dare/postgres16` | | SQLite 3+ | `dare/sqlite3` | +| Microsoft SQL Server 2022+ | `dare/mssql` | ## Example diff --git a/package-lock.json b/package-lock.json index 5c1f2b0..403554b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "husky": "^9.1.7", "is-ci": "^4.1.0", "mocha": "^11.7.5", + "mssql": "^11.0.1", "mysql2": "3.22", "pg": "^8.20.0", "pg-query-stream": "^4.14.0", @@ -117,6 +118,264 @@ "dev": true, "license": "MIT" }, + "node_modules/@azure-rest/core-client": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@azure-rest/core-client/-/core-client-2.6.1.tgz", + "integrity": "sha512-KzI10qnkWTsVS2yRBUdc8NLUJ1rOm+292mYs7Pe9wqAj/jv4bRskVm1l8XkKeVTN0OCQtrU5RG0Yhjbz1Wmg7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", + "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.2.tgz", + "integrity": "sha512-1D2LpsU7y9xrqKjdIbsB7PlrRePw0xsVV8p+AKTlzITrWmscajryfJCdDJB/oGwvDI5HmRo04eMMADB67uwAwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-lro": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz", + "integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-util": "^1.2.0", + "@azure/logger": "^1.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-paging": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.6.2.tgz", + "integrity": "sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.24.0.tgz", + "integrity": "sha512-PpLsoDQ3AMmKZ0VU+0GrmqMxgp/sExjlVm4R+nLWngeoEGAzOIPVifaxKGU5gMv+nWELUoHfvrolWD+ZS/nFJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", + "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", + "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/identity": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.1.tgz", + "integrity": "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^5.5.0", + "@azure/msal-node": "^5.1.0", + "open": "^10.1.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/keyvault-common": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@azure/keyvault-common/-/keyvault-common-2.1.0.tgz", + "integrity": "sha512-aCDidWuKY06LWQ4x7/8TIXK6iRqTaRWRL3t7T+LC+j1b07HtoIsOxP/tU90G4jCSBn5TAyUTCtA4MS/y5Hudaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure-rest/core-client": "^2.3.3", + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.3.0", + "@azure/core-rest-pipeline": "^1.8.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.10.0", + "@azure/logger": "^1.1.4", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/keyvault-keys": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/@azure/keyvault-keys/-/keyvault-keys-4.10.2.tgz", + "integrity": "sha512-VmUSLbXRAbSzDD8grXHGPaknYs0SKr3yuf6U+d4XMpX4XuVYskNqbTTwXce0zR1LyxfTZm9rWEBcvs3vdYwCmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure-rest/core-client": "^2.3.3", + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.9.0", + "@azure/core-lro": "^2.7.2", + "@azure/core-paging": "^1.6.2", + "@azure/core-rest-pipeline": "^1.19.0", + "@azure/core-tracing": "^1.2.0", + "@azure/core-util": "^1.11.0", + "@azure/keyvault-common": "^2.1.0", + "@azure/logger": "^1.1.4", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", + "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.14.0.tgz", + "integrity": "sha512-Dfl7hPZe9/JJwRhFFXHq2z1oHYBuGubmff3kWXOsd1AGgyXlqjNYAWuN/1JL/ZrcZBs8TKMjGSil6Rcc7E8VPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.9.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "16.9.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.9.0.tgz", + "integrity": "sha512-1MWGjqgUCRAYgLmVFZKp7fs3Rg1TFvIMgywY8ze2olNVvLlJoRThuoziWSDJuwwyJI5L4rnLb9Tyt5D9GvSLPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.2.5.tgz", + "integrity": "sha512-RUuewWk9JvWJS5Yiy8/74Lm1rQAWlrU/qg/Bgtk1jIauVRtnb9XKwS5Xg0J+Whwjesq9EVrBIFgQEP8vHxgezA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.9.0", + "jsonwebtoken": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -971,6 +1230,13 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@js-joda/core": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@js-joda/core/-/core-5.7.0.tgz", + "integrity": "sha512-WBu4ULVVxySLLzK1Ppq+OdfP+adRS4ntmDQT915rzDJ++i95gc2jZkM5B6LWEAwN3lGXpfie3yPABozdD3K3Vg==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@octokit/auth-token": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", @@ -1870,6 +2136,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@tediousjs/connection-string": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@tediousjs/connection-string/-/connection-string-0.5.0.tgz", + "integrity": "sha512-7qSgZbincDDDFyRweCIEvZULFAw5iz/DeunhvuxpL31nfntX3P4Yd4HkHBRg9H8CdqY1e5WFN1PZIz/REL9MVQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1909,7 +2182,6 @@ "integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.19.0" } @@ -1920,6 +2192,44 @@ "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "dev": true }, + "node_modules/@types/readable-stream": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.23.tgz", + "integrity": "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.6.tgz", + "integrity": "sha512-jIXhD0eWQ1JA6ln/5Dltyx22UxWNrw0hZmhy2rlv6m6KgF7kplHx3g0fzi09lNmTJQRR91OlemYp3xFnvDK9og==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2077,6 +2387,27 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.9.11", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", @@ -2094,6 +2425,46 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/bl": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz", + "integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.0", + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^4.2.0" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/bl/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/bottleneck": { "version": "2.19.5", "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", @@ -2165,6 +2536,38 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/builtin-modules": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", @@ -2178,6 +2581,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/c8": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/c8/-/c8-11.0.0.tgz", @@ -2437,6 +2856,16 @@ "dev": true, "license": "MIT" }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/comment-parser": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz", @@ -2749,6 +3178,49 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -2805,6 +3277,16 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.267", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", @@ -3787,6 +4269,26 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/execa": { "version": "5.1.1", "dev": true, @@ -4435,6 +4937,27 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4609,6 +5132,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "dev": true, @@ -4636,6 +5175,25 @@ "node": ">=0.10.0" } }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -4705,6 +5263,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isarray": { "version": "1.0.0", "dev": true, @@ -4805,6 +5379,13 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-md4": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", + "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4886,6 +5467,52 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "dev": true, + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4992,6 +5619,34 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", @@ -5014,6 +5669,13 @@ "license": "MIT", "peer": true }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.uniqby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz", @@ -5327,6 +5989,27 @@ "dev": true, "license": "MIT" }, + "node_modules/mssql": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/mssql/-/mssql-11.0.1.tgz", + "integrity": "sha512-KlGNsugoT90enKlR8/G36H0kTxPthDhmtNUCwEHvgRza5Cjpjoj+P2X6eMpFUDN7pFrJZsKadL4x990G8RBE1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tediousjs/connection-string": "^0.5.0", + "commander": "^11.0.0", + "debug": "^4.3.3", + "rfdc": "^1.3.0", + "tarn": "^3.0.2", + "tedious": "^18.2.1" + }, + "bin": { + "mssql": "bin/mssql" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/mysql2": { "version": "3.22.3", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.3.tgz", @@ -5375,6 +6058,13 @@ "node": ">=8.0.0" } }, + "node_modules/native-duplexpair": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz", + "integrity": "sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA==", + "dev": true, + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "dev": true, @@ -7387,6 +8077,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -7929,6 +8638,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "dev": true, @@ -8380,6 +9099,26 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "dev": true, @@ -9001,6 +9740,13 @@ "node": ">= 10.x" } }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/sql-escaper": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz", @@ -9236,6 +9982,51 @@ "node": ">=6" } }, + "node_modules/tarn": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz", + "integrity": "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/tedious": { + "version": "18.6.2", + "resolved": "https://registry.npmjs.org/tedious/-/tedious-18.6.2.tgz", + "integrity": "sha512-g7jC56o3MzLkE3lHkaFe2ZdOVFBahq5bsB60/M4NYUbocw/MCrS89IOEQUFr+ba6pb8ZHczZ/VqCyYeYq0xBAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/core-auth": "^1.7.2", + "@azure/identity": "^4.2.1", + "@azure/keyvault-keys": "^4.4.0", + "@js-joda/core": "^5.6.1", + "@types/node": ">=18", + "bl": "^6.0.11", + "iconv-lite": "^0.6.3", + "js-md4": "^0.3.2", + "native-duplexpair": "^1.0.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tedious/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/temp-dir": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz", @@ -9597,8 +10388,7 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/unicode-emoji-modifier-base": { "version": "1.0.0", @@ -9792,6 +10582,22 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/xtend": { "version": "4.0.2", "dev": true, diff --git a/package.json b/package.json index 3db653a..928ee3d 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,10 @@ "import": "./src/index.js", "types": "./types/src/index.d.ts" }, + "./mssql": { + "import": "./src/mssql.js", + "types": "./types/src/index.d.ts" + }, "./postgres16": { "import": "./src/postgres16.js", "types": "./types/src/index.d.ts" @@ -45,6 +49,7 @@ "test:ci": "npm run lint && c8 node --test test/specs/**/*.spec.js && (c8 report --reporter=text-lcov | coveralls)", "test:integration": "bash ./test/integration/run.sh", "test:integration:sqlite": "DB_ENGINE=sqlite:3 bash ./test/integration/run.sh", + "test:integration:mssql": "DB_ENGINE=mssql:2022 bash ./test/integration/run.sh", "spec": "c8 node --test 'test/specs/**/*.spec.js'", "lint": "eslint ./ && npx prettier --check . && npm run check-types", "prettier": "prettier --write --ignore-unknown .", @@ -90,6 +95,7 @@ "husky": "^9.1.7", "is-ci": "^4.1.0", "mocha": "^11.7.5", + "mssql": "^11.0.1", "mysql2": "3.22", "pg": "^8.20.0", "pg-query-stream": "^4.14.0", diff --git a/src/format/reducer_conditions.js b/src/format/reducer_conditions.js index f4572e8..33003fe 100644 --- a/src/format/reducer_conditions.js +++ b/src/format/reducer_conditions.js @@ -452,11 +452,9 @@ function json_contains({ const conds = []; if (typeof value !== 'object' || value === null || Array.isArray(value)) { - const separator = dareInstance.sql_json_extract_operator; - return [ { - sql: SQL`${sql_field}${raw(separator)}${path}`, + sql: dareInstance.sql_json_extract({sql_field, path}), value, operators, }, diff --git a/src/get.js b/src/get.js index c6aa198..3c83978 100644 --- a/src/get.js +++ b/src/get.js @@ -126,10 +126,13 @@ export default function buildQuery(opts, dareInstance) { return field; } + const expression = + typeof field.expression === 'string' + ? field.expression + : dareInstance.sql_expression_literal(field.expression); + return raw( - `${field.expression}${ - field.label ? ` AS "${field.label}"` : '' - }` + `${expression}${field.label ? ` AS "${field.label}"` : ''}` ); }); } @@ -190,6 +193,7 @@ export default function buildQuery(opts, dareInstance) { start: opts.start, alias, has_sub_queries, + dareInstance, }; } @@ -206,6 +210,7 @@ export default function buildQuery(opts, dareInstance) { * @param {Array} opts.sql_orderby - Order by fields * @param {number} [opts.limit] - Limit the number of results * @param {number} [opts.start] - Offset for the results + * @param {import('./index.js').default} opts.dareInstance - Dare instance, used to resolve engine specific SQL * @returns {Sql} - The SQL statement */ export function generateSQLSelect({ @@ -219,6 +224,7 @@ export function generateSQLSelect({ sql_orderby, limit, start, + dareInstance, }) { return SQL` ${sql_cte ? SQL`WITH ${sql_cte}` : empty} @@ -228,8 +234,7 @@ export function generateSQLSelect({ ${optionalJoin(sql_filter, ' AND ', 'WHERE ')} ${optionalJoin(sql_groupby, ',', 'GROUP BY ')} ${optionalJoin(sql_orderby, ',', 'ORDER BY ')} - ${limit ? SQL`LIMIT ${raw(String(limit))}` : empty} - ${start ? SQL`OFFSET ${raw(String(start))}` : empty} + ${dareInstance.sql_limit_clause({limit, start, sql_orderby, sql_alias, sql_fields, sql_groupby})} `; } diff --git a/src/index.js b/src/index.js index 278b0ff..317d7ea 100644 --- a/src/index.js +++ b/src/index.js @@ -22,7 +22,7 @@ import response_handler, {responseRowHandler} from './response_handler.js'; /** * @import {Sql} from 'sql-template-tag' * - * @typedef {`${'mysql' | 'postgres' | 'mariadb' | 'sqlite'}:${number}.${number}${string?}` | `sqlite:${number}`} Engine + * @typedef {`${'mysql' | 'postgres' | 'mariadb' | 'sqlite' | 'mssql'}:${number}.${number}${string?}` | `sqlite:${number}` | `mssql:${number}`} Engine * * @typedef {Pick} ModalHandlerExtraProps * @@ -203,6 +203,18 @@ Dare.prototype.sql_json_extract_prefix = '$'; */ Dare.prototype.sql_json_extract_operator = '->'; +/** + * Sql_json_extract - Generates JSON extraction SQL for a JSON field and path + * @param {object} params - Params + * @param {Sql} params.sql_field - SQL field expression + * @param {string} params.path - JSON path + * @returns {Sql} SQL expression + */ +Dare.prototype.sql_json_extract = function sql_json_extract({sql_field, path}) { + const separator = this.sql_json_extract_operator; + return SQL`${sql_field}${raw(separator)}${path}`; +}; + /** * Defaul SQL wildcard character for fulltext searches * @type {string} @@ -307,6 +319,66 @@ Dare.prototype.applyTableAliasOnUpdate = true; */ Dare.prototype.sql_insert_suffix = undefined; +/** + * SQL insert output - Additional SQL to inject directly after the column list of an INSERT statement + * e.g. the OUTPUT clause for MS SQL Server, which is used to return the inserted id + * @type {string | undefined} + */ +Dare.prototype.sql_insert_output = undefined; + +/** + * Whether the engine supports inline upsert clauses on INSERT statements, + * such as ON DUPLICATE KEY UPDATE / ON CONFLICT + * @type {boolean} + */ +Dare.prototype.supportsInlineUpsert = true; + +/** + * Sql_limit_clause - Generates the LIMIT/OFFSET portion of a SELECT statement + * Engines such as MS SQL Server override this to use OFFSET ... FETCH syntax + * @param {object} opts - Options + * @param {number} [opts.limit] - Limit the number of results + * @param {number} [opts.start] - Offset for the results + * @param {Array} [opts.sql_orderby] - The ORDER BY expressions already applied to the statement + * @param {string} [opts.sql_alias] - SQL alias of the table being queried + * @param {Array} [opts.sql_fields] - The SELECT field expressions + * @param {Array} [opts.sql_groupby] - The GROUP BY expressions + * @returns {Sql} SQL fragment + */ +Dare.prototype.sql_limit_clause = function sql_limit_clause({ + limit, + start, + // eslint-disable-next-line no-unused-vars + sql_orderby, + // eslint-disable-next-line no-unused-vars + sql_alias, + // eslint-disable-next-line no-unused-vars + sql_fields, + // eslint-disable-next-line no-unused-vars + sql_groupby, +}) { + return SQL` + ${limit ? SQL`LIMIT ${raw(String(limit))}` : empty} + ${start ? SQL`OFFSET ${raw(String(start))}` : empty} + `; +}; + +/** + * Sql_expression_literal - Convert scalar expression values into SQL-safe literals for SELECT fields + * Engines may override this, e.g. MSSQL prefers 1/0 over true/false + * @param {any} value - Scalar expression value + * @returns {string} SQL literal expression + */ +Dare.prototype.sql_expression_literal = function sql_expression_literal(value) { + if (value === null) { + return 'null'; + } + if (typeof value === 'boolean') { + return value ? 'true' : 'false'; + } + return String(value); +}; + // Set default table_alias handler Dare.prototype.table_alias_handler = function (name) { return name.replace(/^-/, '').split('$')[0]; @@ -902,6 +974,9 @@ Dare.prototype.post = async function post(table, body, options = {}) { post = [post]; } + /** @type {Array>} */ + const postRows = /** @type {Array>} */ (post); + // If ignore duplicate keys is stated as ignore const sql_exec = req.ignore ? raw('IGNORE') : empty; @@ -1005,6 +1080,23 @@ Dare.prototype.post = async function post(table, body, options = {}) { return a; }); + // Engines without inline upsert support (e.g. MSSQL) emulate duplicate key options in JS + const shouldEmulateUpsert = + !req.query && + !dareInstance.supportsInlineUpsert && + (req.duplicate_keys_update || + req.duplicate_keys?.toString()?.toLowerCase() === 'ignore'); + + if (shouldEmulateUpsert) { + return emulatePostDuplicateKeyHandling({ + dareInstance, + req, + rows: postRows, + modelSchema, + baseOptions: options, + }); + } + // Options let sql_on_duplicate_keys_update = empty; if (req.duplicate_keys_update) { @@ -1034,9 +1126,15 @@ Dare.prototype.post = async function post(table, body, options = {}) { ? raw(dareInstance.sql_insert_suffix) : empty; + // Additional OUTPUT clause for MS SQL Server, injected after the column list to return the inserted id + const sql_insert_output = dareInstance.sql_insert_output + ? raw(dareInstance.sql_insert_output) + : empty; + // Construct a db update const sql = SQL`INSERT ${sql_exec} INTO ${raw(req.sql_table)} (${raw(fields.map(dareInstance.identifierWrapper.bind(dareInstance)).join(','))}) + ${sql_insert_output} ${data.length ? SQL`VALUES ${bulk(data)}` : empty} ${sql_query} ${sql_on_duplicate_keys_update} @@ -1047,6 +1145,180 @@ Dare.prototype.post = async function post(table, body, options = {}) { return dareInstance.after(resp); }; +/** + * Emulate duplicate key options for engines that do not support inline upsert syntax + * @param {object} obj - Object + * @param {Dare} obj.dareInstance - Dare instance + * @param {QueryOptions} obj.req - Formatted request + * @param {Array>} obj.rows - Rows to process + * @param {Schema} obj.modelSchema - Current model schema + * @param {object} obj.baseOptions - Original post options + * @returns {Promise} mysql-like response object + */ +async function emulatePostDuplicateKeyHandling({ + dareInstance, + req, + rows, + modelSchema, + baseOptions, +}) { + let affectedRows = 0; + let insertId; + + async function processRow(row) { + const duplicateFilter = deriveDuplicateFilter({ + req, + row, + modelSchema, + dareInstance, + }); + + const existing = duplicateFilter + ? await dareInstance.get( + req.table, + [dareInstance.rowid], + duplicateFilter, + /** @type {any} */ ({ + single: true, + notfound: undefined, + }) + ) + : undefined; + + if (existing) { + if (insertId === undefined) { + insertId = existing[dareInstance.rowid]; + } + + if (req.duplicate_keys_update) { + const updateBody = {}; + for (const field of req.duplicate_keys_update) { + const value = getFieldValueFromRow({ + row, + field, + modelSchema, + dareInstance, + }); + + if (value !== undefined) { + updateBody[field] = value; + } + } + + if (Object.keys(updateBody).length) { + const patchResp = await dareInstance.patch( + req.table, + duplicateFilter, + updateBody, + { + limit: 1, + notfound: {affectedRows: 0}, + } + ); + + affectedRows += Number(patchResp?.affectedRows || 0); + } + } + + return; + } + + const insertResp = await dareInstance.post(req.table, row, { + ...baseOptions, + duplicate_keys: undefined, + duplicate_keys_update: undefined, + query: undefined, + }); + + if (insertId === undefined && insertResp?.insertId !== undefined) { + insertId = insertResp.insertId; + } + + affectedRows += Number(insertResp?.affectedRows || 0); + } + + await rows.reduce( + (promise, row) => promise.then(() => processRow(row)), + Promise.resolve() + ); + + return { + insertId, + affectedRows, + }; +} + +/** + * Build a filter to locate an existing duplicate row + * @param {object} obj - Object + * @param {QueryOptions} obj.req - Request options + * @param {Record} obj.row - Source row + * @param {Schema} obj.modelSchema - Table schema + * @param {Dare} obj.dareInstance - Dare instance + * @returns {Record | undefined} filter object for get/patch + */ +function deriveDuplicateFilter({req, row, modelSchema, dareInstance}) { + const duplicate_keys = Array.isArray(req.duplicate_keys) + ? req.duplicate_keys + : [dareInstance.rowid]; + + if (!duplicate_keys.length) { + return; + } + + const filter = {}; + + for (const field of duplicate_keys) { + const value = getFieldValueFromRow({ + row, + field, + modelSchema, + dareInstance, + }); + + if (value === undefined) { + return; + } + + filter[field] = value; + } + + return filter; +} + +/** + * Resolve a value from a post row by field name, db alias, or schema alias + * @param {object} obj - Object + * @param {Record} obj.row - Source row + * @param {string} obj.field - Field name + * @param {Schema} obj.modelSchema - Table schema + * @param {Dare} obj.dareInstance - Dare instance + * @returns {any} value + */ +function getFieldValueFromRow({row, field, modelSchema, dareInstance}) { + if (Object.hasOwn(row, field)) { + return row[field]; + } + + const unaliased = unAliasFields(modelSchema, field, dareInstance); + if (Object.hasOwn(row, unaliased)) { + return row[unaliased]; + } + + for (const schemaField in modelSchema) { + if (Object.hasOwn(modelSchema, schemaField)) { + const {alias} = getFieldAttributes( + schemaField, + modelSchema, + dareInstance + ); + if (alias === field && Object.hasOwn(row, schemaField)) { + return row[schemaField]; + } + } + } +} + /** * Dare.del * Delete a record matching condition diff --git a/src/mssql.js b/src/mssql.js new file mode 100644 index 0000000..f4aebb8 --- /dev/null +++ b/src/mssql.js @@ -0,0 +1,289 @@ +import SQL, {raw, join, empty} from 'sql-template-tag'; +import Dare from './index.js'; + +/** + * MSSQLDare + * Extends Dare with Microsoft SQL Server-specific overrides + * + * @param {object} options - Initial options defining the instance + * @returns {import('./index.js').default} instance of MSSQLDare + */ +function MSSQLDare(options = {}) { + const instance = new Dare({ + ...options, + engine: options.engine || 'mssql:2022', + }); + Object.setPrototypeOf(instance, MSSQLDare.prototype); + return instance; +} + +// Inherit from Dare +MSSQLDare.prototype = Object.create(Dare.prototype); +MSSQLDare.prototype.constructor = MSSQLDare; + +/** + * Default engine for MS SQL Server + * @type {string} + */ +MSSQLDare.prototype.engine = 'mssql:2022'; + +/** + * MS SQL Server uses `id` as the rowid + * @type {string} + */ +MSSQLDare.prototype.rowid = 'id'; + +/** + * MS SQL Server uses LIKE (case-insensitivity is collation dependent, default installs are case-insensitive) + * @type {Dare['sql_keyword_like']} + */ +MSSQLDare.prototype.sql_keyword_like = 'LIKE'; + +/** + * Defaul SQL wildcard character for fulltext searches + * @type {Dare['sql_fulltext_wildcard']} + */ +MSSQLDare.prototype.sql_fulltext_wildcard = '%'; + +/** + * MSSQL JSON EXTRACT prefix + * @type {Dare['sql_json_extract_prefix']} + */ +MSSQLDare.prototype.sql_json_extract_prefix = '$'; + +/** + * IdentifierWrapper - MS SQL Server wraps identifiers in square brackets + * @type {Dare['identifierWrapper']} + */ +MSSQLDare.prototype.identifierWrapper = function identifierWrapper(field) { + return ['[', field, ']'].join(''); +}; + +/** + * Sql_limit_clause - MS SQL Server uses OFFSET ... FETCH instead of LIMIT ... OFFSET + * An ORDER BY clause is mandatory when OFFSET is used, so a stable no-op ordering + * is injected when the statement does not already define one. + * @type {Dare['sql_limit_clause']} + */ +MSSQLDare.prototype.sql_limit_clause = function sql_limit_clause({ + limit, + start, + sql_orderby, + sql_alias, + sql_fields, + sql_groupby, +}) { + if (!limit && !start) { + return empty; + } + + // OFFSET ... FETCH requires an ORDER BY, inject a deterministic no-op when absent + const hasOrderBy = + Array.isArray(sql_orderby) && + sql_orderby.some(item => { + if (!item) { + return false; + } + if (typeof item === 'string') { + return item.trim().length > 0; + } + if ('expression' in item && typeof item.expression === 'string') { + return item.expression.trim().length > 0; + } + return true; + }); + + const hasAggregateField = + Array.isArray(sql_fields) && + sql_fields.some(field => { + const expression = + typeof field === 'string' + ? field + : typeof field?.expression === 'string' + ? field.expression + : typeof field?.sql === 'string' + ? field.sql + : ''; + + return /\b(?:count|sum|avg|max|min|string_agg|json_arrayagg|group_concat)\s*\(/i.test( + expression + ); + }); + + const hasGroupBy = Array.isArray(sql_groupby) && sql_groupby.length > 0; + + const fallbackOrderBy = sql_alias + ? hasAggregateField && !hasGroupBy + ? SQL`ORDER BY (SELECT NULL)` + : SQL`ORDER BY ${raw(sql_alias)}.${raw(this.rowid)}` + : SQL`ORDER BY (SELECT NULL)`; + + const orderby = hasOrderBy ? empty : fallbackOrderBy; + + const offset = SQL`OFFSET ${raw(String(start || 0))} ROWS`; + + const fetch = limit + ? SQL`FETCH NEXT ${raw(String(limit))} ROWS ONLY` + : empty; + + return SQL`${orderby} ${offset} ${fetch}`; +}; + +/** + * Sql_expression_literal - MSSQL prefers 1/0 for boolean scalar literals in SELECT clauses + * @type {Dare['sql_expression_literal']} + */ +MSSQLDare.prototype.sql_expression_literal = function sql_expression_literal( + value +) { + if (value === null) { + return 'NULL'; + } + if (typeof value === 'boolean') { + return value ? '1' : '0'; + } + return String(value); +}; + +/** + * Apply limit on DML - MS SQL Server does not support LIMIT on UPDATE/DELETE + * @type {Dare['applyLimitOnDML']} + */ +MSSQLDare.prototype.applyLimitOnDML = false; + +/** + * MS SQL Server does not allow joining onto the table being modified in patch / delete requests + * To work around this, we use subquery joins + * @type {Dare['applySubqueryOnDML']} + */ +MSSQLDare.prototype.applySubqueryOnDML = true; + +/** + * Apply aliases to UPDATE statements - MS SQL Server uses a different UPDATE ... FROM syntax + * @type {Dare['applyAliasesOnUpdate']} + */ +MSSQLDare.prototype.applyAliasesOnUpdate = false; + +/** + * SQL insert output - MS SQL Server returns the inserted id via the OUTPUT clause + * @type {Dare['sql_insert_output']} + */ +MSSQLDare.prototype.sql_insert_output = `OUTPUT INSERTED.id`; + +/** + * Sql_json_array - MSSQL JSON_ARRAY defaults to ABSENT ON NULL, so add NULL ON NULL + * @type {Dare['sql_json_array']} + */ +MSSQLDare.prototype.sql_json_array = function sql_json_array(expressions) { + return `JSON_ARRAY(${expressions.join(',')} NULL ON NULL)`; +}; + +/** + * Sql_json_extract - MSSQL uses JSON_VALUE for scalar extraction + * @type {Dare['sql_json_extract']} + */ +MSSQLDare.prototype.sql_json_extract = function sql_json_extract({ + sql_field, + path, +}) { + return SQL`JSON_VALUE(${sql_field}, ${path})`; +}; + +/** + * Sql_json_arrayagg - MS SQL Server (2022) lacks JSON_ARRAYAGG, build the array using STRING_AGG + * @type {Dare['sql_json_arrayagg']} + */ +MSSQLDare.prototype.sql_json_arrayagg = function sql_json_arrayagg({ + sql_alias, + expression, +}) { + const condition = `CASE WHEN (${sql_alias}.${this.rowid} IS NOT NULL) THEN (${expression}) ELSE NULL END`; + return `CONCAT('[', STRING_AGG(${condition}, ','), ']')`; +}; + +/** + * On Duplicate Keys Update - MS SQL Server has no inline upsert clause (MERGE is a separate statement) + * @type {Dare['onDuplicateKeysUpdate']} + */ +MSSQLDare.prototype.onDuplicateKeysUpdate = function onDuplicateKeysUpdate() { + // Upsert behavior is handled in MSSQLDare.prototype.post + return ''; +}; + +/** + * MS SQL Server does not support MySQL/Postgres inline upsert syntax in INSERT statements + * @type {Dare['supportsInlineUpsert']} + */ +MSSQLDare.prototype.supportsInlineUpsert = false; + +/** + * FulltextSearch - MS SQL Server implementation + * MS SQL Server full-text search requires a configured full-text catalog and index. + * To remain portable, this falls back to a LIKE based search across the provided fields. + * @type {Dare['fulltextSearch']} + */ +MSSQLDare.prototype.fulltextSearch = function fulltextSearch( + sql_field_array, + value, + NOT +) { + // Parse MySQL-style fulltext input into MSSQL LIKE-compatible terms + const terms = String(value) + .split(/\s+/) + .map(term => term.trim()) + .filter(Boolean) + .map(term => term.replace(/^[&+<>~-]+/, '')) + .map(term => term.replace(/^"(?.*)"$/, '$')) + .map(term => term.replace(/[()]/g, '')) + .map(term => term.replace(/\*$/g, '%')) + .map(term => term.replace(/\*/g, '%')) + .filter(Boolean); + + if (!terms.length) { + return SQL`${NOT}(1 = 0)`; + } + + const perTerm = terms.map(term => { + let like = term; + if (!like.startsWith('%')) { + like = `%${like}`; + } + if (!like.endsWith('%')) { + like = `${like}%`; + } + return SQL`(${join( + sql_field_array.map(field => SQL`${field} LIKE ${like}`), + ' OR ' + )})`; + }); + + // Terms are ANDed together, each term can match any target field + return SQL`${NOT}(${join(perTerm, ' AND ')})`; +}; + +/** + * FulltextSignParser - MS SQL Server LIKE fallback does not use the MySQL boolean operators + * @type {Dare['fulltextSignParser']} + */ +MSSQLDare.prototype.fulltextSignParser = function fulltextSignParser(sign) { + return sign.replace(/[+<>~]/g, ''); +}; + +/** + * Pass through value verbatim for JSON formatting, as MS SQL Server handles this natively + * @type {Dare['jsonFormatValue']} + */ +MSSQLDare.prototype.jsonFormatValue = function jsonFormatValue(value) { + if (Array.isArray(value)) { + return value.map(item => this.jsonFormatValue(item)); + } + + if (value === null || value === undefined) { + return null; + } + + // JSON_VALUE in MSSQL returns text, so compare against text forms + return String(value); +}; + +export default MSSQLDare; diff --git a/test/integration/data/schema.mssql.sql b/test/integration/data/schema.mssql.sql new file mode 100644 index 0000000..e547d98 --- /dev/null +++ b/test/integration/data/schema.mssql.sql @@ -0,0 +1,59 @@ +CREATE TABLE country ( + id INT IDENTITY(1,1) NOT NULL, + code CHAR(2) NOT NULL, + CONSTRAINT pk_country PRIMARY KEY (id) +); + +CREATE TABLE users ( + id INT IDENTITY(1,1) NOT NULL, + username NVARCHAR(255) NOT NULL, + first_name NVARCHAR(255) DEFAULT NULL, + last_name NVARCHAR(255) DEFAULT NULL, + ft_index AS CONCAT( + COALESCE(username, ''), + ' ', + COALESCE(first_name, ''), + ' ', + COALESCE(last_name, '') + ), + uuid VARBINARY(16) DEFAULT NULL, + secret NVARCHAR(2048) DEFAULT NULL, + country_id INT DEFAULT NULL, + settings NVARCHAR(MAX) DEFAULT NULL, + CONSTRAINT pk_users PRIMARY KEY (id), + CONSTRAINT unique_username UNIQUE (username) + + -- -- Foreign Keys + -- CONSTRAINT fk_users_country_id FOREIGN KEY (country_id) REFERENCES country (id) ON DELETE CASCADE +); + +CREATE TABLE users_email ( + id INT IDENTITY(1,1) NOT NULL, + user_id INT NOT NULL, + email NVARCHAR(255) NOT NULL, + CONSTRAINT pk_users_email PRIMARY KEY (id), + CONSTRAINT unique_email UNIQUE (email) + + -- -- users + -- CONSTRAINT fk_userEmails_user_id FOREIGN KEY (user_id) REFERENCES users (id) +); + +CREATE TABLE teams ( + id INT IDENTITY(1,1) NOT NULL, + name NVARCHAR(255) NOT NULL, + description NVARCHAR(MAX) NULL DEFAULT NULL, + updated_time DATETIME2 NULL, + CONSTRAINT pk_teams PRIMARY KEY (id), + CONSTRAINT unique_name UNIQUE (name) +); + +CREATE TABLE userTeams ( + id INT IDENTITY(1,1) NOT NULL, + user_id INT NOT NULL, + team_id INT NOT NULL, + CONSTRAINT pk_userTeams PRIMARY KEY (id) + + -- -- Foreign Keys + -- CONSTRAINT fk_userTeams_user_id FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + -- CONSTRAINT fk_userTeams_team_id FOREIGN KEY (team_id) REFERENCES teams (id) ON DELETE CASCADE +); diff --git a/test/integration/helpers/MSSQL.js b/test/integration/helpers/MSSQL.js new file mode 100644 index 0000000..e35b537 --- /dev/null +++ b/test/integration/helpers/MSSQL.js @@ -0,0 +1,190 @@ +import {PassThrough} from 'node:stream'; +import mssql from 'mssql'; +import fs from 'node:fs'; + +const {TEST_DB_DATA_PATH, TEST_DB_SCHEMA_PATH} = process.env; + +const schemaSql = TEST_DB_SCHEMA_PATH + ? fs.readFileSync(TEST_DB_SCHEMA_PATH, 'utf8') + : ''; +const insertDataSql = TEST_DB_DATA_PATH + ? fs.readFileSync(TEST_DB_DATA_PATH, 'utf8') + : ''; + +/** + * Convert sql-template-tag `?` placeholders into MS SQL Server named parameters (@p0, @p1, ...) + * @param {string} sql - SQL with `?` placeholders + * @returns {string} SQL with named parameters + */ +function convertPlaceholders(sql) { + let i = 0; + return sql.replace(/\?/g, () => `@p${i++}`); +} + +/** + * Normalise a value for binding to the MS SQL Server driver + * @param {any} value - Value to normalise + * @returns {any} Normalised value + */ +function normalize(value) { + if (value === undefined) { + return null; + } + if ( + value !== null && + typeof value === 'object' && + !Buffer.isBuffer(value) && + !(value instanceof Date) + ) { + return JSON.stringify(value); + } + return value; +} + +export default class MSSQL { + constructor(credentials) { + this.credentials = credentials; + } + + get baseConfig() { + const {host, port, user, password} = this.credentials; + return { + server: host, + port: +port, + user, + password, + options: { + trustServerCertificate: true, + encrypt: false, + }, + }; + } + + async init() { + const {database} = this.credentials; + + // SQL Server can take a few seconds to start accepting connections, retry the initial connection + let master; + for (let attempt = 0; attempt < 30; attempt++) { + try { + // eslint-disable-next-line no-await-in-loop + master = await new mssql.ConnectionPool({ + ...this.baseConfig, + database: 'master', + }).connect(); + break; + } catch (err) { + if (attempt === 29) { + throw err; + } + // eslint-disable-next-line no-await-in-loop + await new Promise(resolve => setTimeout(resolve, 2000)); + } + } + + // Create the per-process test database + await master + .request() + .batch( + `IF DB_ID('${database}') IS NULL CREATE DATABASE [${database}];` + ); + await master.close(); + + // Connect to the test database + this.conn = await new mssql.ConnectionPool({ + ...this.baseConfig, + database, + }).connect(); + + // Create the schema + if (schemaSql) { + await this.conn.request().batch(schemaSql); + } + + // Extract the table names + const tables = await this.conn.request().query(` + SELECT table_name AS [table] + FROM information_schema.tables + WHERE table_type = 'BASE TABLE' + `); + + this.tables = tables.recordset.map(({table}) => table); + + return this.conn; + } + + async query(request) { + const text = convertPlaceholders( + request.sql || request.text || request + ); + const values = request.values || []; + + const req = this.conn.request(); + values.forEach((value, index) => + req.input(`p${index}`, normalize(value)) + ); + + const result = await req.query(text); + + const command = text.trim().split(/\s+/)[0].toUpperCase(); + + // Return SELECT rows + if (command === 'SELECT' || command === 'WITH') { + return result.recordset || []; + } + + // Else, return a node-mysql'ish response + return { + insertId: result.recordset?.[0]?.id, + affectedRows: result.rowsAffected?.[0] ?? 0, + }; + } + + async resetDbState() { + // Clear the data and reseed the identity columns + for (const table of this.tables) { + // eslint-disable-next-line no-await-in-loop + await this.conn.request().batch( + ` + DECLARE @count INT; + SELECT @count = COUNT(1) FROM [${table}]; + DELETE FROM [${table}]; + IF @count > 0 + DBCC CHECKIDENT('${table}', RESEED, 0); + ` + ); + } + + // Re-insert base data + if (insertDataSql) { + await this.conn.request().batch(insertDataSql); + } + } + + stream(request, streamOptions = {objectMode: true, highWaterMark: 5}) { + const text = convertPlaceholders( + request.sql || request.text || request + ); + const values = request.values || []; + + const resultStream = new PassThrough(streamOptions); + + const req = this.conn.request(); + req.stream = true; + values.forEach((value, index) => + req.input(`p${index}`, normalize(value)) + ); + + req.on('row', row => resultStream.write(row)); + req.on('error', err => resultStream.destroy(err)); + req.on('done', () => resultStream.end()); + + req.query(text); + + return resultStream; + } + + end() { + return this.conn?.close(); + } +} diff --git a/test/integration/helpers/api.js b/test/integration/helpers/api.js index 055a102..ce1c706 100644 --- a/test/integration/helpers/api.js +++ b/test/integration/helpers/api.js @@ -2,6 +2,7 @@ import Dare from '../../../src/index.js'; import PostgresDare from '../../../src/postgres16.js'; import MySQL57Dare from '../../../src/mysql57.js'; import SQLiteDare from '../../../src/sqlite.js'; +import MSSQLDare from '../../../src/mssql.js'; import Debug from 'debug'; import mysql from 'mysql2/promise'; import db from './db.js'; @@ -18,10 +19,12 @@ const DareConstructor = DB_ENGINE?.startsWith('postgres') ? PostgresDare : DB_ENGINE?.startsWith('sqlite') ? SQLiteDare - : DB_ENGINE?.startsWith('mysql:5.7') || - DB_ENGINE?.startsWith('mysql:5.6') - ? MySQL57Dare - : Dare; + : DB_ENGINE?.startsWith('mssql') + ? MSSQLDare + : DB_ENGINE?.startsWith('mysql:5.7') || + DB_ENGINE?.startsWith('mysql:5.6') + ? MySQL57Dare + : Dare; export default function dareInstance() { // Initiate diff --git a/test/integration/helpers/db.js b/test/integration/helpers/db.js index 53ca5ca..3efa60e 100644 --- a/test/integration/helpers/db.js +++ b/test/integration/helpers/db.js @@ -1,6 +1,7 @@ import MySQL from './MySQL.js'; import Postgres from './Postgres.js'; import SQLite from './SQLite.js'; +import MSSQL from './MSSQL.js'; const { DB_ENGINE = 'mysql:5.6', @@ -25,6 +26,8 @@ const dbSettings = { let dbInstance; if (DB_ENGINE.startsWith('sqlite')) { dbInstance = new SQLite(dbSettings); +} else if (DB_ENGINE.startsWith('mssql')) { + dbInstance = new MSSQL(dbSettings); } else if (DB_ENGINE.startsWith('mysql') || DB_ENGINE.startsWith('mariadb')) { dbInstance = new MySQL(dbSettings); } else if (DB_ENGINE.startsWith('postgres')) { diff --git a/test/integration/json.spec.js b/test/integration/json.spec.js index 93958ba..8246cf1 100644 --- a/test/integration/json.spec.js +++ b/test/integration/json.spec.js @@ -178,7 +178,10 @@ describe('Working with JSON DataType', () => { }); it('JSON fields should be patchable with a setFunction definition', async function () { - if (DB_ENGINE?.startsWith('postgres')) { + if ( + DB_ENGINE?.startsWith('postgres') || + DB_ENGINE?.startsWith('mssql') + ) { this.skip(); return; } @@ -186,12 +189,15 @@ describe('Working with JSON DataType', () => { // Update the user settings with a setFunction dare.options.models.users.schema.settings.patch = { setFunction({sql_field, value}) { + // Default to MySQL/MariaDB + let json_merge_name = 'JSON_MERGE_PATCH'; + if (DB_ENGINE?.startsWith('sqlite')) { - return SQL`JSON_PATCH(${raw(sql_field)}, ${value})`; + // SQLite uses JSON_PATCH + json_merge_name = 'JSON_PATCH'; } - // MySQL/MariaDB/Postgres... possibly others, but not SQLite - return SQL`JSON_MERGE_PATCH(${raw(sql_field)}, ${value})`; + return SQL`${raw(json_merge_name)}(${raw(sql_field)}, ${value})`; }, }; diff --git a/test/integration/run.sh b/test/integration/run.sh index fd5904a..58ef7c5 100644 --- a/test/integration/run.sh +++ b/test/integration/run.sh @@ -85,6 +85,33 @@ then exit 1 } +elif [ "$DB_ENGINE_NAME" = "mssql" ] +then + + # SQL Server requires the 'sa' user and a strong password + export DB_USER="sa" + export DB_PASSWORD="Test_pass123" + + # Map the engine version (e.g. mssql:2022) onto the official image tag + DB_ENGINE_VERSION=$(echo $DB_ENGINE | cut -d: -f2) + MSSQL_IMAGE="mcr.microsoft.com/mssql/server:${DB_ENGINE_VERSION:-2022}-latest" + + docker run \ + --name="dare_db" \ + -d \ + -p ${DB_PORT}:1433 \ + --env ACCEPT_EULA="Y" \ + --env MSSQL_SA_PASSWORD="${DB_PASSWORD}" \ + --env MSSQL_PID="Developer" \ + --health-cmd="bash -c 'cat < /dev/null > /dev/tcp/localhost/1433'" \ + --health-interval="4s" \ + --health-timeout="3s" \ + --health-retries=30 \ + $MSSQL_IMAGE || { + echo 'docker run failed' + exit 1 + } + elif [ "$DB_ENGINE_NAME" = "mariadb" ] then diff --git a/test/specs/get.spec.js b/test/specs/get.spec.js index 4ecef66..f507384 100644 --- a/test/specs/get.spec.js +++ b/test/specs/get.spec.js @@ -1,5 +1,7 @@ import assert from 'node:assert'; import Dare from '../../src/index.js'; +import {generateSQLSelect} from '../../src/get.js'; +import {raw} from 'sql-template-tag'; // Test Generic DB functions import sqlEqual from '../lib/sql-equal.js'; @@ -23,6 +25,61 @@ describe('get', () => { assert.strictEqual(typeof dare.get, 'function'); }); + it('should format default boolean expression literals', () => { + assert.strictEqual(dare.sql_expression_literal(false), 'false'); + }); + + it('should generate LIMIT/OFFSET using base sql_limit_clause', () => { + const query = generateSQLSelect({ + sql_fields: [raw('a.id')], + sql_table: 'test', + sql_alias: 'a', + sql_joins: [], + sql_filter: [], + sql_groupby: [], + sql_orderby: [], + limit: 3, + start: 2, + dareInstance: dare, + }); + + sqlEqual(query.sql, 'SELECT a.id FROM test a LIMIT 3 OFFSET 2'); + assert.deepStrictEqual(query.values, []); + }); + + it('should generate OFFSET-only clause using base sql_limit_clause', () => { + const query = generateSQLSelect({ + sql_fields: [raw('a.id')], + sql_table: 'test', + sql_alias: 'a', + sql_joins: [], + sql_filter: [], + sql_groupby: [], + sql_orderby: [], + start: 2, + dareInstance: dare, + }); + + sqlEqual(query.sql, 'SELECT a.id FROM test a OFFSET 2'); + assert.deepStrictEqual(query.values, []); + }); + + it('should generate no limit clause when limit/start are not provided', () => { + const query = generateSQLSelect({ + sql_fields: [raw('a.id')], + sql_table: 'test', + sql_alias: 'a', + sql_joins: [], + sql_filter: [], + sql_groupby: [], + sql_orderby: [], + dareInstance: dare, + }); + + sqlEqual(query.sql, 'SELECT a.id FROM test a'); + assert.deepStrictEqual(query.values, []); + }); + it('should not mutate the request object', async () => { const original = { table: 'test', diff --git a/test/specs/mssql/delete.spec.js b/test/specs/mssql/delete.spec.js new file mode 100644 index 0000000..4d07cb5 --- /dev/null +++ b/test/specs/mssql/delete.spec.js @@ -0,0 +1,52 @@ +import assert from 'node:assert'; +import Dare from '../../../src/mssql.js'; +import {describe, it, beforeEach} from 'node:test'; +import sqlEqual from '../../lib/sql-equal.js'; + +describe('mssql - Delete', () => { + let dare; + + // Mock instance of Dare + beforeEach(() => { + dare = new Dare(); + }); + + it(`should delete with subquery conditions rather than JOINs`, async () => { + dare.options.models = { + tbl: { + schema: { + // Create a reference to tblB + ref_id: ['tblB.id'], + }, + }, + }; + + dare.execute = async ({sql, values}) => { + sqlEqual( + sql, + `DELETE FROM tbl + WHERE tbl.id = ? + AND tbl.ref_id IN ( + SELECT id FROM ( + SELECT a.id FROM tblB a WHERE a.id= ? + ) AS a_tmp + ) + ` + ); + assert.deepStrictEqual(values, [1, 1]); + return {success: true}; + }; + + const test = await dare.del({ + table: 'tbl', + filter: { + id: 1, + tblB: { + id: 1, + }, + }, + }); + + assert.deepStrictEqual(test, {success: true}); + }); +}); diff --git a/test/specs/mssql/fulltext.spec.js b/test/specs/mssql/fulltext.spec.js new file mode 100644 index 0000000..73d2419 --- /dev/null +++ b/test/specs/mssql/fulltext.spec.js @@ -0,0 +1,49 @@ +import assert from 'node:assert'; +import {empty, raw} from 'sql-template-tag'; +import Dare from '../../../src/mssql.js'; +import {describe, it, beforeEach} from 'node:test'; + +describe('mssql - fulltext', () => { + /** @type {any} */ + let dare; + + beforeEach(() => { + dare = new Dare({engine: 'mssql:2022'}); + }); + + it('should fall back to a LIKE search across the provided fields', () => { + const result = dare.fulltextSearch( + [raw('a.username'), raw('a.first_name')], + 'john', + empty + ); + + assert.strictEqual( + result.sql, + '((a.username LIKE ? OR a.first_name LIKE ?))' + ); + assert.deepStrictEqual(result.values, ['%john%', '%john%']); + }); + + it('should strip MySQL boolean operators from the search term', () => { + const result = dare.fulltextSearch( + [raw('a.username')], + '+john*', + empty + ); + + assert.strictEqual(result.sql, '((a.username LIKE ?))'); + assert.deepStrictEqual(result.values, ['%john%']); + }); + + it('should negate the search when a NOT operator is supplied', () => { + const result = dare.fulltextSearch( + [raw('a.username')], + 'john', + raw('NOT ') + ); + + assert.strictEqual(result.sql, 'NOT ((a.username LIKE ?))'); + assert.deepStrictEqual(result.values, ['%john%']); + }); +}); diff --git a/test/specs/mssql/get.spec.js b/test/specs/mssql/get.spec.js new file mode 100644 index 0000000..184dce1 --- /dev/null +++ b/test/specs/mssql/get.spec.js @@ -0,0 +1,58 @@ +import Dare from '../../../src/mssql.js'; + +// Test Generic DB functions +import sqlEqual from '../../lib/sql-equal.js'; +import {describe, it, beforeEach} from 'node:test'; + +const DB_ENGINE = 'mssql:2022'; + +describe('mssql - get', () => { + /** @type {any} */ + let dare; + + beforeEach(() => { + dare = new Dare({engine: DB_ENGINE}); + }); + + it(`${DB_ENGINE} should use OFFSET ... FETCH and inject a no-op ORDER BY when limit is set without ordering`, async () => { + dare.execute = async ({sql}) => { + sqlEqual( + sql, + `SELECT a.id, a.username + FROM users a + ORDER BY a.id + OFFSET 0 ROWS FETCH NEXT 5 ROWS ONLY` + ); + return []; + }; + + return dare.get({ + table: 'users', + fields: ['id', 'username'], + limit: 5, + }); + }); + + it(`${DB_ENGINE} should use the existing ORDER BY with OFFSET ... FETCH`, async () => { + dare.execute = async ({sql}) => { + sqlEqual( + sql, + `SELECT a.id + FROM users a + WHERE a.id = ? + ORDER BY a.username + OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY` + ); + return [{id: 2}]; + }; + + return dare.get({ + table: 'users', + fields: ['id'], + filter: {id: 2}, + limit: 10, + start: 20, + orderby: 'username', + }); + }); +}); diff --git a/test/specs/mssql/mssql.spec.js b/test/specs/mssql/mssql.spec.js new file mode 100644 index 0000000..6b99e67 --- /dev/null +++ b/test/specs/mssql/mssql.spec.js @@ -0,0 +1,179 @@ +import assert from 'node:assert'; +import {empty, raw} from 'sql-template-tag'; +import Dare from '../../../src/mssql.js'; +import {describe, it, beforeEach} from 'node:test'; + +describe('mssql - engine overrides', () => { + /** @type {any} */ + let dare; + + beforeEach(() => { + dare = new Dare({engine: 'mssql:2022'}); + }); + + it('should handle sql_limit_clause with empty and whitespace ORDER BY entries', () => { + const clause = dare.sql_limit_clause({ + limit: 3, + start: 0, + sql_orderby: [null, ' ', {expression: ' '}], + sql_alias: 'a', + sql_fields: [], + sql_groupby: [], + }); + + assert.strictEqual( + clause.sql, + 'ORDER BY a.id OFFSET 0 ROWS FETCH NEXT 3 ROWS ONLY' + ); + assert.deepStrictEqual(clause.values, []); + }); + + it('should skip fallback ORDER BY when ORDER BY expression already exists', () => { + const clause = dare.sql_limit_clause({ + limit: 3, + start: 4, + sql_orderby: [{expression: 'a.username'}], + sql_alias: 'a', + sql_fields: [], + sql_groupby: [], + }); + + assert.strictEqual(clause.sql, ' OFFSET 4 ROWS FETCH NEXT 3 ROWS ONLY'); + assert.deepStrictEqual(clause.values, []); + }); + + it('should use ORDER BY (SELECT NULL) for aggregate-only fields without GROUP BY', () => { + const clause = dare.sql_limit_clause({ + limit: 2, + start: 0, + sql_orderby: [], + sql_alias: 'a', + sql_fields: ['COUNT(*)'], + sql_groupby: [], + }); + + assert.strictEqual( + clause.sql, + 'ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 2 ROWS ONLY' + ); + assert.deepStrictEqual(clause.values, []); + }); + + it('should use ORDER BY (SELECT NULL) when sql_alias is missing', () => { + const clause = dare.sql_limit_clause({ + limit: 2, + start: 1, + sql_orderby: [], + sql_alias: '', + sql_fields: [], + sql_groupby: [], + }); + + assert.strictEqual( + clause.sql, + 'ORDER BY (SELECT NULL) OFFSET 1 ROWS FETCH NEXT 2 ROWS ONLY' + ); + assert.deepStrictEqual(clause.values, []); + }); + + it('should detect aggregate fields from expression and sql properties', () => { + const fromExpression = dare.sql_limit_clause({ + limit: 1, + start: 0, + sql_orderby: [null], + sql_alias: 'a', + sql_fields: [{expression: 'SUM(a.score)'}], + sql_groupby: [], + }); + + assert.strictEqual( + fromExpression.sql, + 'ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY' + ); + + const fromSql = dare.sql_limit_clause({ + limit: 1, + start: 0, + sql_orderby: [undefined], + sql_alias: 'a', + sql_fields: [{sql: 'MAX(a.score)'}], + sql_groupby: [], + }); + + assert.strictEqual( + fromSql.sql, + 'ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY' + ); + }); + + it('should fall back to rowid ordering when non-aggregate field metadata is empty', () => { + const clause = dare.sql_limit_clause({ + limit: undefined, + start: 5, + sql_orderby: [0], + sql_alias: 'a', + sql_fields: [{}], + sql_groupby: [], + }); + + assert.strictEqual(clause.sql, 'ORDER BY a.id OFFSET 5 ROWS '); + assert.deepStrictEqual(clause.values, []); + }); + + it('should format sql_expression_literal values for mssql', () => { + assert.strictEqual(dare.sql_expression_literal(null), 'NULL'); + assert.strictEqual(dare.sql_expression_literal(true), '1'); + assert.strictEqual(dare.sql_expression_literal(false), '0'); + assert.strictEqual(dare.sql_expression_literal(42), '42'); + }); + + it('should provide sql_json_extract using JSON_VALUE', () => { + const expression = dare.sql_json_extract({ + sql_field: raw('a.meta'), + path: '$.flag', + }); + + assert.strictEqual(expression.sql, 'JSON_VALUE(a.meta, ?)'); + assert.deepStrictEqual(expression.values, ['$.flag']); + }); + + it('should return empty onDuplicateKeysUpdate clause', () => { + assert.strictEqual(dare.onDuplicateKeysUpdate(), ''); + }); + + it('should return an always-false condition for empty fulltext input', () => { + const result = dare.fulltextSearch([raw('a.username')], ' ', empty); + + assert.strictEqual(result.sql, '(1 = 0)'); + assert.deepStrictEqual(result.values, []); + }); + + it('should strip mysql fulltext operators in fulltextSignParser', () => { + assert.strictEqual(dare.fulltextSignParser('+><~word'), 'word'); + }); + + it('should normalize JSON formatted values for arrays and scalars', () => { + assert.deepStrictEqual(dare.jsonFormatValue([true, 3, null]), [ + 'true', + '3', + null, + ]); + assert.strictEqual(dare.jsonFormatValue(null), null); + assert.strictEqual(dare.jsonFormatValue(undefined), null); + assert.strictEqual(dare.jsonFormatValue(false), 'false'); + }); + + it('should return an empty limit clause when neither limit nor start is provided', () => { + const clause = dare.sql_limit_clause({ + limit: undefined, + start: undefined, + sql_orderby: [], + sql_alias: 'a', + sql_fields: [], + sql_groupby: [], + }); + + assert.strictEqual(clause.sql, ''); + assert.deepStrictEqual(clause.values, []); + }); +}); diff --git a/test/specs/mssql/patch.spec.js b/test/specs/mssql/patch.spec.js new file mode 100644 index 0000000..d7c0750 --- /dev/null +++ b/test/specs/mssql/patch.spec.js @@ -0,0 +1,40 @@ +import assert from 'node:assert'; +import Dare from '../../../src/mssql.js'; + +// Test Generic DB functions +import sqlEqual from '../../lib/sql-equal.js'; + +import {describe, it, beforeEach} from 'node:test'; + +const id = 1; +const name = 'name'; + +describe('mssql - patch', () => { + /** @type {any} */ + let dare; + + beforeEach(() => { + dare = new Dare(); + + // Should not be called... + dare.execute = () => { + throw new Error('execute called'); + }; + }); + + it(`should use the correct syntax for mssql`, async () => { + const dareInst = dare.use({engine: 'mssql:2022'}); + + dareInst.execute = async ({sql, values}) => { + sqlEqual(sql, 'UPDATE tbl SET [name] = ? WHERE tbl.id = ?'); + assert.deepStrictEqual(values, [name, id]); + return {success: true}; + }; + + return dareInst.patch({ + table: 'tbl', + filter: {id}, + body: {name}, + }); + }); +}); diff --git a/test/specs/mssql/post.spec.js b/test/specs/mssql/post.spec.js new file mode 100644 index 0000000..711061f --- /dev/null +++ b/test/specs/mssql/post.spec.js @@ -0,0 +1,257 @@ +import assert from 'node:assert'; +import Dare from '../../../src/mssql.js'; + +// Test Generic DB functions +import sqlEqual from '../../lib/sql-equal.js'; + +import {describe, it, beforeEach} from 'node:test'; + +const DB_ENGINE = 'mssql:2022'; + +describe('mssql - post', () => { + /** @type {any} */ + let dare; + + beforeEach(() => { + dare = new Dare({engine: DB_ENGINE}); + + // Should not be called... + dare.execute = () => { + throw new Error('execute called'); + }; + }); + + it(`${DB_ENGINE} should wrap identifiers in brackets and use OUTPUT INSERTED.id`, async () => { + dare.execute = async ({sql, values}) => { + sqlEqual( + sql, + 'INSERT INTO test ([id],[name]) OUTPUT INSERTED.id VALUES (?, ?)' + ); + assert.deepStrictEqual(values, [1, 'name']); + return {insertId: 1}; + }; + + return dare.post({ + table: 'test', + body: {id: 1, name: 'name'}, + }); + }); + + it(`${DB_ENGINE} should support duplicate_keys_update through MSSQL-specific compatibility handling`, async () => { + dare.get = async () => ({id: 1}); + dare.patch = async () => ({affectedRows: 1}); + + const resp = await dare.post({ + table: 'test', + body: {id: 1, name: 'new-name'}, + duplicate_keys: ['id'], + duplicate_keys_update: ['name'], + }); + + assert.deepStrictEqual(resp, {insertId: 1, affectedRows: 1}); + }); + + it(`${DB_ENGINE} should treat missing patch affectedRows as 0 during duplicate update emulation`, async () => { + dare.get = async () => ({id: 1}); + dare.patch = async () => ({}); + + const resp = await dare.post({ + table: 'test', + body: {id: 1, name: 'new-name'}, + duplicate_keys: ['id'], + duplicate_keys_update: ['name'], + }); + + assert.deepStrictEqual(resp, {insertId: 1, affectedRows: 0}); + }); + + it(`${DB_ENGINE} should disable inline upsert syntax`, async () => { + assert.strictEqual(dare.supportsInlineUpsert, false); + }); + + it(`${DB_ENGINE} should support duplicate_keys=ignore through MSSQL-specific compatibility handling`, async () => { + let getCalled = 0; + + dare.get = async () => { + getCalled += 1; + return {id: 1}; + }; + + dare.patch = async () => { + throw new Error( + 'patch should not be called for duplicate_keys=ignore' + ); + }; + + const resp = await dare.post({ + table: 'test', + body: {id: 1, name: 'same-name'}, + duplicate_keys: 'ignore', + }); + + assert.strictEqual(getCalled, 1); + assert.deepStrictEqual(resp, {insertId: 1, affectedRows: 0}); + }); + + it(`${DB_ENGINE} should insert when duplicate lookup misses`, async () => { + let getCalled = 0; + + dare.get = async () => { + getCalled += 1; + return undefined; + }; + + dare.execute = async ({sql, values}) => { + sqlEqual( + sql, + 'INSERT INTO test ([id],[name]) OUTPUT INSERTED.id VALUES (?, ?)' + ); + assert.deepStrictEqual(values, [2, 'new-name']); + return {insertId: 2, affectedRows: 1}; + }; + + const resp = await dare.post({ + table: 'test', + body: {id: 2, name: 'new-name'}, + duplicate_keys: ['id'], + duplicate_keys_update: ['name'], + }); + + assert.strictEqual(getCalled, 1); + assert.deepStrictEqual(resp, {insertId: 2, affectedRows: 1}); + }); + + it(`${DB_ENGINE} should treat missing insert affectedRows as 0 during duplicate emulation`, async () => { + dare.get = async () => undefined; + + dare.execute = async ({sql, values}) => { + sqlEqual( + sql, + 'INSERT INTO test ([id],[name]) OUTPUT INSERTED.id VALUES (?, ?)' + ); + assert.deepStrictEqual(values, [22, 'insert-without-affectedRows']); + return {insertId: 22}; + }; + + const resp = await dare.post({ + table: 'test', + body: {id: 22, name: 'insert-without-affectedRows'}, + duplicate_keys: ['id'], + duplicate_keys_update: ['name'], + }); + + assert.deepStrictEqual(resp, {insertId: 22, affectedRows: 0}); + }); + + it(`${DB_ENGINE} should treat duplicate_keys=[] as no duplicate filter and insert`, async () => { + let getCalled = 0; + + dare.get = async () => { + getCalled += 1; + return {id: 3}; + }; + + dare.execute = async ({sql, values}) => { + sqlEqual( + sql, + 'INSERT INTO test ([id],[name]) OUTPUT INSERTED.id VALUES (?, ?)' + ); + assert.deepStrictEqual(values, [3, 'inserted']); + return {insertId: 3, affectedRows: 1}; + }; + + const resp = await dare.post({ + table: 'test', + body: {id: 3, name: 'inserted'}, + duplicate_keys: [], + duplicate_keys_update: ['name'], + }); + + assert.strictEqual(getCalled, 0); + assert.deepStrictEqual(resp, {insertId: 3, affectedRows: 1}); + }); + + it(`${DB_ENGINE} should resolve duplicate key values through schema aliases`, async () => { + dare.options.models = { + test: { + schema: { + externalId: 'id', + }, + }, + }; + + dare.get = async (_table, _fields, filter) => { + assert.deepStrictEqual(filter, {id: 11}); + return {id: 11}; + }; + + dare.patch = async (_table, filter, body) => { + assert.deepStrictEqual(filter, {id: 11}); + assert.deepStrictEqual(body, {name: 'updated'}); + return {affectedRows: 1}; + }; + + const resp = await dare.post({ + table: 'test', + body: {externalId: 11, name: 'updated'}, + duplicate_keys: ['id'], + duplicate_keys_update: ['name'], + }); + + assert.deepStrictEqual(resp, {insertId: 11, affectedRows: 1}); + }); + + it(`${DB_ENGINE} should resolve duplicate key values from unaliased row properties`, async () => { + dare.options.models = { + test: { + schema: { + externalId: 'id', + }, + }, + }; + + dare.get = async (_table, _fields, filter) => { + assert.deepStrictEqual(filter, {externalId: 15}); + return {id: 15}; + }; + + dare.patch = async () => ({affectedRows: 1}); + + const resp = await dare.post({ + table: 'test', + body: {id: 15, name: 'updated-via-unaliased-input'}, + duplicate_keys: ['externalId'], + duplicate_keys_update: ['name'], + }); + + assert.deepStrictEqual(resp, {insertId: 15, affectedRows: 1}); + }); + + it(`${DB_ENGINE} should insert when duplicate key field value is missing from body`, async () => { + let getCalled = 0; + + dare.get = async () => { + getCalled += 1; + return {id: 12}; + }; + + dare.execute = async ({sql, values}) => { + sqlEqual( + sql, + 'INSERT INTO test ([id],[name]) OUTPUT INSERTED.id VALUES (?, ?)' + ); + assert.deepStrictEqual(values, [12, 'from-missing-key-case']); + return {insertId: 12, affectedRows: 1}; + }; + + const resp = await dare.post({ + table: 'test', + body: {id: 12, name: 'from-missing-key-case'}, + duplicate_keys: ['email'], + duplicate_keys_update: ['name'], + }); + + assert.strictEqual(getCalled, 0); + assert.deepStrictEqual(resp, {insertId: 12, affectedRows: 1}); + }); +}); diff --git a/test/specs/mssql/utils_group_concat.spec.js b/test/specs/mssql/utils_group_concat.spec.js new file mode 100644 index 0000000..d6cb7ae --- /dev/null +++ b/test/specs/mssql/utils_group_concat.spec.js @@ -0,0 +1,62 @@ +/* eslint quotes: ["error", "single", { "avoidEscape": true, "allowTemplateLiterals": true }]*/ +import assert from 'node:assert'; + +// Test Generic DB functions +import group_concat from '../../../src/utils/group_concat.js'; +import MSSQLDare from '../../../src/mssql.js'; +import {describe, it, beforeEach} from 'node:test'; + +describe(`utils/group_concat: (mssql)`, () => { + let dareInstance; + + beforeEach(() => { + dareInstance = new MSSQLDare({engine: 'mssql:2022'}); + }); + + it('should reduce an array of fields to a STRING_AGG JSON array statement', async () => { + const gc = group_concat({ + fields: [ + { + expression: 'table.a', + label: 'collection.a', + }, + { + expression: 'table.b', + label: 'collection.b', + }, + ], + address: 'collection.', + sql_alias: 'a', + dareInstance, + }); + + assert.strictEqual( + gc.expression, + `CONCAT('[', STRING_AGG(CASE WHEN (a.id IS NOT NULL) THEN (JSON_ARRAY(table.a,table.b NULL ON NULL)) ELSE NULL END, ','), ']')` + ); + assert.deepStrictEqual(gc.label, 'collection[a,b]'); + }); + + it('should not wrap fields which are marked as aggregating the row', async () => { + const gc = group_concat({ + fields: [ + { + expression: 'table.a', + label: 'a', + agg: true, + }, + { + expression: 'table.b', + label: 'b', + }, + ], + dareInstance, + }); + + assert.strictEqual( + gc.expression, + `JSON_ARRAY(table.a,table.b NULL ON NULL)` + ); + assert.strictEqual(gc.label, 'a,b'); + }); +});