diff --git a/.env.example b/.env.example index 3101dee..4881a20 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,10 @@ PLANETSCALE_PASSWORD=password # Cloudflare Hyperdrive WRANGLER_HYPERDRIVE_LOCAL_CONNECTION_STRING_POSTGRESQL=postgresql://test:test@localhost:5432/db0 WRANGLER_HYPERDRIVE_LOCAL_CONNECTION_STRING_MYSQL=mysql://test:test@localhost:3306/db0 + +# MSSQL +MSSQL_HOST=localhost +MSSQL_DB_NAME=TestDB +MSSQL_PORT=1433 +MSSQL_USERNAME=sa +MSSQL_PASSWORD=MyStrong!Passw0rd \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bff6bca..b557209 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,3 +15,7 @@ jobs: - run: pnpm build - run: pnpm test:types - run: pnpm vitest + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/docker-compose.yaml b/docker-compose.yaml index dd5a711..8934a64 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -16,3 +16,11 @@ services: MYSQL_DATABASE: db0 MYSQL_USER: test MYSQL_PASSWORD: test + mssql: + # https://hub.docker.com/_/microsoft-mssql-server + image: mcr.microsoft.com/mssql/server:2022-latest + network_mode: "host" + environment: + ACCEPT_EULA: "Y" + MSSQL_SA_PASSWORD: "MyStrong!Passw0rd" + MSSQL_PID: "Developer" diff --git a/docs/2.connectors/1.index.md b/docs/2.connectors/1.index.md index f993a90..447ba5e 100644 --- a/docs/2.connectors/1.index.md +++ b/docs/2.connectors/1.index.md @@ -15,6 +15,7 @@ Currently supported connectors: - [PostgreSQL](/connectors/postgresql) - [MySQL](/connectors/mysql) - [SQLite](/connectors/sqlite) +- [MSSQL](/connectors/mssql) ::read-more{to="https://github.com/unjs/db0/issues/32"} See [unjs/db0#32](https://github.com/unjs/db0/issues/32) for the list of upcoming connectors. diff --git a/docs/2.connectors/mssql.md b/docs/2.connectors/mssql.md new file mode 100644 index 0000000..090c96c --- /dev/null +++ b/docs/2.connectors/mssql.md @@ -0,0 +1,30 @@ +--- +icon: devicon-plain:microsoftsqlserver +--- + +# MSSQL + +> Connect DB0 to MSSQL Database using `tedious` + +## Usage + +For this connector, you need to install [`tedious`](https://www.npmjs.com/package/tedious) dependency: + +:pm-install{name="tedious"} + +Use `mssql` connector: + +```js +import { createDatabase } from "db0"; +import mssql from "db0/connectors/mssql"; + +const db = createDatabase( + mssql({ + /* options */ + }), +); +``` + +## Options + +:read-more{to="https://tediousjs.github.io/tedious/api-connection.html#function_newConnection"} diff --git a/package.json b/package.json index af8793b..09e458b 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "pg": "^8.16.3", "prettier": "^3.6.2", "scule": "^1.3.0", + "tedious": "^19.1.3", "typescript": "^5.9.3", "vitest": "^4.0.12", "wrangler": "^4.49.1" @@ -75,7 +76,8 @@ "better-sqlite3": "*", "drizzle-orm": "*", "mysql2": "*", - "sqlite3": "*" + "sqlite3": "*", + "tedious": "*" }, "peerDependenciesMeta": { "@libsql/client": { @@ -90,6 +92,9 @@ "mysql2": { "optional": true }, + "tedious": { + "optional": true + }, "@electric-sql/pglite": { "optional": true }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dff25f4..310330b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,6 +81,9 @@ importers: scule: specifier: ^1.3.0 version: 1.3.0 + tedious: + specifier: ^19.1.3 + version: 19.1.3 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -108,6 +111,74 @@ importers: packages: + '@azure-rest/core-client@2.5.1': + resolution: {integrity: sha512-EHaOXW0RYDKS5CFffnixdyRPak5ytiCtU7uXDcP/uiY+A6jFRwNGzzJBiznkCzvi5EYpY+YWinieqHb0oY916A==} + engines: {node: '>=20.0.0'} + + '@azure/abort-controller@2.1.2': + resolution: {integrity: sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==} + engines: {node: '>=18.0.0'} + + '@azure/core-auth@1.10.1': + resolution: {integrity: sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==} + engines: {node: '>=20.0.0'} + + '@azure/core-client@1.10.1': + resolution: {integrity: sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==} + engines: {node: '>=20.0.0'} + + '@azure/core-http-compat@2.3.1': + resolution: {integrity: sha512-az9BkXND3/d5VgdRRQVkiJb2gOmDU8Qcq4GvjtBmDICNiQ9udFmDk4ZpSB5Qq1OmtDJGlQAfBaS4palFsazQ5g==} + engines: {node: '>=20.0.0'} + + '@azure/core-lro@2.7.2': + resolution: {integrity: sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==} + engines: {node: '>=18.0.0'} + + '@azure/core-paging@1.6.2': + resolution: {integrity: sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==} + engines: {node: '>=18.0.0'} + + '@azure/core-rest-pipeline@1.22.2': + resolution: {integrity: sha512-MzHym+wOi8CLUlKCQu12de0nwcq9k9Kuv43j4Wa++CsCpJwps2eeBQwD2Bu8snkxTtDKDx4GwjuR9E8yC8LNrg==} + engines: {node: '>=20.0.0'} + + '@azure/core-tracing@1.3.1': + resolution: {integrity: sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==} + engines: {node: '>=20.0.0'} + + '@azure/core-util@1.13.1': + resolution: {integrity: sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==} + engines: {node: '>=20.0.0'} + + '@azure/identity@4.13.0': + resolution: {integrity: sha512-uWC0fssc+hs1TGGVkkghiaFkkS7NkTxfnCH+Hdg+yTehTpMcehpok4PgUKKdyCH+9ldu6FhiHRv84Ntqj1vVcw==} + engines: {node: '>=20.0.0'} + + '@azure/keyvault-common@2.0.0': + resolution: {integrity: sha512-wRLVaroQtOqfg60cxkzUkGKrKMsCP6uYXAOomOIysSMyt1/YM0eUn9LqieAWM8DLcU4+07Fio2YGpPeqUbpP9w==} + engines: {node: '>=18.0.0'} + + '@azure/keyvault-keys@4.10.0': + resolution: {integrity: sha512-eDT7iXoBTRZ2n3fLiftuGJFD+yjkiB1GNqzU2KbY1TLYeXeSPVTVgn2eJ5vmRTZ11978jy2Kg2wI7xa9Tyr8ag==} + engines: {node: '>=18.0.0'} + + '@azure/logger@1.3.0': + resolution: {integrity: sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==} + engines: {node: '>=20.0.0'} + + '@azure/msal-browser@4.26.2': + resolution: {integrity: sha512-F2U1mEAFsYGC5xzo1KuWc/Sy3CRglU9Ql46cDUx8x/Y3KnAIr1QAq96cIKCk/ZfnVxlvprXWRjNKoEpgLJXLhg==} + engines: {node: '>=0.8.0'} + + '@azure/msal-common@15.13.2': + resolution: {integrity: sha512-cNwUoCk3FF8VQ7Ln/MdcJVIv3sF73/OT86cRH81ECsydh7F4CNfIo2OAx6Cegtg8Yv75x4506wN4q+Emo6erOA==} + engines: {node: '>=0.8.0'} + + '@azure/msal-node@3.8.3': + resolution: {integrity: sha512-Ul7A4gwmaHzYWj2Z5xBDly/W8JSC1vnKgJ898zPMZr0oSf1ah0tiL15sytjycU/PMhDZAlkWtEL1+MzNMU6uww==} + engines: {node: '>=16'} + '@babel/generator@7.28.5': resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} engines: {node: '>=6.9.0'} @@ -981,6 +1052,9 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@js-joda/core@5.6.5': + resolution: {integrity: sha512-3zwefSMwHpu8iVUW8YYz227sIv6UFqO31p1Bf1ZH/Vom7CmNyUsXjDBlnNzcuhmOL1XfxZ3nvND42kR23XlbcQ==} + '@libsql/client@0.15.15': resolution: {integrity: sha512-twC0hQxPNHPKfeOv3sNT6u2pturQjLcI+CnpTM0SjRpocEGgfiZ7DWKXLNnsothjyJmDqEsBQJ5ztq9Wlu470w==} @@ -1676,6 +1750,9 @@ packages: '@types/react@19.1.13': resolution: {integrity: sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==} + '@types/readable-stream@4.0.22': + resolution: {integrity: sha512-/FFhJpfCLAPwAcN3mFycNUa77ddnr8jTgF5VmSNetaemWB2cIlfCA9t0YTM3JAT0wOcv8D4tjPo7pkDhK3EJIg==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -1741,6 +1818,10 @@ packages: resolution: {integrity: sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typespec/ts-http-runtime@0.3.2': + resolution: {integrity: sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg==} + engines: {node: '>=20.0.0'} + '@vitest/coverage-v8@4.0.12': resolution: {integrity: sha512-d+w9xAFJJz6jyJRU4BUU7MH409Ush7FWKNkxJU+jASKg6WX33YT0zc+YawMR1JesMWt9QRFQY/uAD3BTn23FaA==} peerDependencies: @@ -1782,6 +1863,10 @@ packages: abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1805,6 +1890,10 @@ packages: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + agentkeepalive@4.6.0: resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} engines: {node: '>= 8.0.0'} @@ -1877,6 +1966,9 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + bl@6.1.5: + resolution: {integrity: sha512-XylDt2P3JBttAwLpORq/hOEX9eJzP0r6Voa46C/WVvad8D1J0jW5876txB8FnzKtbdnU6X4Y1vOEvC6PllJrDg==} + blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} @@ -1895,12 +1987,18 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + builtin-modules@5.0.0: resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==} engines: {node: '>=18.20'} @@ -2331,6 +2429,9 @@ packages: oxc-resolver: optional: true + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + electron-to-chromium@1.5.258: resolution: {integrity: sha512-rHUggNV5jKQ0sSdWwlaRDkFc3/rRJIVnOSe9yR4zrR07m3ZxhP4N27Hlg8VeJGGYgFTxK5NqDmWI4DSH72vIJg==} @@ -2482,6 +2583,14 @@ packages: event-emitter@0.3.5: resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + exit-hook@2.2.1: resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} engines: {node: '>=6'} @@ -2657,10 +2766,18 @@ packages: resolution: {integrity: sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==} engines: {node: '>= 6'} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + humanize-ms@1.2.1: resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} @@ -2808,6 +2925,9 @@ packages: js-base64@3.7.8: resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} + js-md4@0.3.2: + resolution: {integrity: sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==} + js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} @@ -2838,6 +2958,16 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + + jwa@1.4.2: + resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} + + jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2864,9 +2994,30 @@ packages: lodash.deburr@4.1.0: resolution: {integrity: sha512-m/M1U1f3ddMCs6Hq2tAsYThTBDaAKFDX3dwDo97GEYzamXi9SqUpjWi/Rrj/gf3X2n8ktwgZrlP1z6E3v/IExQ==} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.throttle@4.1.1: resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} @@ -3027,6 +3178,9 @@ packages: napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + native-duplexpair@1.0.0: + resolution: {integrity: sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA==} + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -3248,6 +3402,10 @@ packages: resolution: {integrity: sha512-nODzvTiYVRGRqAOvE84Vk5JDPyyxsVk0/fbA/bq7RqlnhksGpset09XTxbpvLTIjoaF7K8Z8DG8yHtKGTPSYRw==} engines: {node: '>=20'} + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + promise-inflight@1.0.1: resolution: {integrity: sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==} peerDependencies: @@ -3284,6 +3442,10 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -3429,6 +3591,9 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + sqlite3@5.1.7: resolution: {integrity: sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==} @@ -3496,6 +3661,10 @@ packages: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} + tedious@19.1.3: + resolution: {integrity: sha512-6O6efTeYtcnar3Cqf/ptqJs+U10fYYjp/SHRNm3VGuCTUDys+AUgIbxWbT2kzl4baXAzuy9byV3qCgOimrRfTA==} + engines: {node: '>=18.17'} + timers-ext@0.1.8: resolution: {integrity: sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww==} engines: {node: '>=0.12'} @@ -3591,6 +3760,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + vite@7.2.4: resolution: {integrity: sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3765,6 +3938,151 @@ packages: snapshots: + '@azure-rest/core-client@2.5.1': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.10.1 + '@azure/core-rest-pipeline': 1.22.2 + '@azure/core-tracing': 1.3.1 + '@typespec/ts-http-runtime': 0.3.2 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/abort-controller@2.1.2': + dependencies: + tslib: 2.8.1 + + '@azure/core-auth@1.10.1': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-util': 1.13.1 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-client@1.10.1': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.10.1 + '@azure/core-rest-pipeline': 1.22.2 + '@azure/core-tracing': 1.3.1 + '@azure/core-util': 1.13.1 + '@azure/logger': 1.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-http-compat@2.3.1': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-client': 1.10.1 + '@azure/core-rest-pipeline': 1.22.2 + transitivePeerDependencies: + - supports-color + + '@azure/core-lro@2.7.2': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-util': 1.13.1 + '@azure/logger': 1.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-paging@1.6.2': + dependencies: + tslib: 2.8.1 + + '@azure/core-rest-pipeline@1.22.2': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.10.1 + '@azure/core-tracing': 1.3.1 + '@azure/core-util': 1.13.1 + '@azure/logger': 1.3.0 + '@typespec/ts-http-runtime': 0.3.2 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-tracing@1.3.1': + dependencies: + tslib: 2.8.1 + + '@azure/core-util@1.13.1': + dependencies: + '@azure/abort-controller': 2.1.2 + '@typespec/ts-http-runtime': 0.3.2 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/identity@4.13.0': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.10.1 + '@azure/core-client': 1.10.1 + '@azure/core-rest-pipeline': 1.22.2 + '@azure/core-tracing': 1.3.1 + '@azure/core-util': 1.13.1 + '@azure/logger': 1.3.0 + '@azure/msal-browser': 4.26.2 + '@azure/msal-node': 3.8.3 + open: 10.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/keyvault-common@2.0.0': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.10.1 + '@azure/core-client': 1.10.1 + '@azure/core-rest-pipeline': 1.22.2 + '@azure/core-tracing': 1.3.1 + '@azure/core-util': 1.13.1 + '@azure/logger': 1.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/keyvault-keys@4.10.0': + dependencies: + '@azure-rest/core-client': 2.5.1 + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.10.1 + '@azure/core-http-compat': 2.3.1 + '@azure/core-lro': 2.7.2 + '@azure/core-paging': 1.6.2 + '@azure/core-rest-pipeline': 1.22.2 + '@azure/core-tracing': 1.3.1 + '@azure/core-util': 1.13.1 + '@azure/keyvault-common': 2.0.0 + '@azure/logger': 1.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/logger@1.3.0': + dependencies: + '@typespec/ts-http-runtime': 0.3.2 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/msal-browser@4.26.2': + dependencies: + '@azure/msal-common': 15.13.2 + + '@azure/msal-common@15.13.2': {} + + '@azure/msal-node@3.8.3': + dependencies: + '@azure/msal-common': 15.13.2 + jsonwebtoken: 9.0.2 + uuid: 8.3.2 + '@babel/generator@7.28.5': dependencies: '@babel/parser': 7.28.5 @@ -4309,6 +4627,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@js-joda/core@5.6.5': {} + '@libsql/client@0.15.15': dependencies: '@libsql/core': 0.15.15 @@ -4786,6 +5106,10 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/readable-stream@4.0.22': + dependencies: + '@types/node': 24.10.1 + '@types/unist@2.0.11': {} '@types/ws@8.18.1': @@ -4885,6 +5209,14 @@ snapshots: '@typescript-eslint/types': 8.47.0 eslint-visitor-keys: 4.2.1 + '@typespec/ts-http-runtime@0.3.2': + dependencies: + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + '@vitest/coverage-v8@4.0.12(vitest@4.0.12(@types/node@24.10.1)(jiti@2.6.1)(yaml@2.7.1))': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -4944,6 +5276,10 @@ snapshots: abbrev@1.1.1: optional: true + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -4961,6 +5297,8 @@ snapshots: - supports-color optional: true + agent-base@7.1.4: {} + agentkeepalive@4.6.0: dependencies: humanize-ms: 1.2.1 @@ -5057,6 +5395,13 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + bl@6.1.5: + dependencies: + '@types/readable-stream': 4.0.22 + buffer: 6.0.3 + inherits: 2.0.4 + readable-stream: 4.7.0 + blake3-wasm@2.1.5: {} brace-expansion@1.1.12: @@ -5080,6 +5425,8 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.1.4(browserslist@4.28.0) + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -5087,6 +5434,11 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + builtin-modules@5.0.0: {} bun-types@1.3.2(@types/react@19.1.13): @@ -5379,6 +5731,10 @@ snapshots: dts-resolver@2.1.3: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + electron-to-chromium@1.5.258: {} emoji-regex@8.0.0: @@ -5675,6 +6031,10 @@ snapshots: d: 1.0.2 es5-ext: 0.10.64 + event-target-shim@5.0.1: {} + + events@3.3.0: {} + exit-hook@2.2.1: {} expand-template@2.0.3: {} @@ -5851,6 +6211,13 @@ snapshots: - supports-color optional: true + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 @@ -5859,6 +6226,13 @@ snapshots: - supports-color optional: true + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + humanize-ms@1.2.1: dependencies: ms: 2.1.3 @@ -5982,6 +6356,8 @@ snapshots: js-base64@3.7.8: {} + js-md4@0.3.2: {} + js-tokens@9.0.1: {} js-yaml@4.1.1: @@ -6004,6 +6380,30 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + jsonwebtoken@9.0.2: + dependencies: + jws: 3.2.2 + 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.1.1 + ms: 2.1.3 + semver: 7.7.3 + + jwa@1.4.2: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@3.2.2: + dependencies: + jwa: 1.4.2 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -6038,8 +6438,22 @@ snapshots: lodash.deburr@4.1.0: {} + 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.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash.throttle@4.1.1: {} long@5.3.2: {} @@ -6252,6 +6666,8 @@ snapshots: napi-build-utils@2.0.0: {} + native-duplexpair@1.0.0: {} + natural-compare@1.4.0: {} negotiator@0.6.4: @@ -6555,6 +6971,8 @@ snapshots: pretty-bytes@7.1.0: {} + process@0.11.10: {} + promise-inflight@1.0.1: optional: true @@ -6593,6 +7011,14 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + readdirp@4.1.2: {} regexp-tree@0.1.27: {} @@ -6779,6 +7205,8 @@ snapshots: split2@4.2.0: {} + sprintf-js@1.1.3: {} + sqlite3@5.1.7: dependencies: bindings: 1.5.0 @@ -6860,6 +7288,21 @@ snapshots: mkdirp: 1.0.4 yallist: 4.0.0 + tedious@19.1.3: + dependencies: + '@azure/core-auth': 1.10.1 + '@azure/identity': 4.13.0 + '@azure/keyvault-keys': 4.10.0 + '@js-joda/core': 5.6.5 + '@types/node': 24.10.1 + bl: 6.1.5 + iconv-lite: 0.7.0 + js-md4: 0.3.2 + native-duplexpair: 1.0.0 + sprintf-js: 1.1.3 + transitivePeerDependencies: + - supports-color + timers-ext@0.1.8: dependencies: es5-ext: 0.10.64 @@ -6886,8 +7329,7 @@ snapshots: dependencies: typescript: 5.9.3 - tslib@2.8.1: - optional: true + tslib@2.8.1: {} tunnel-agent@0.6.0: dependencies: @@ -6956,6 +7398,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@8.3.2: {} + vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(yaml@2.7.1): dependencies: esbuild: 0.25.12 diff --git a/scripts/gen-connectors.ts b/scripts/gen-connectors.ts index be734de..8fb6095 100644 --- a/scripts/gen-connectors.ts +++ b/scripts/gen-connectors.ts @@ -50,6 +50,10 @@ const connectors: { optionsTName?: string; }[] = []; +const connectorOptionsNameAliases: Record = { + "mssql": "MSSQL" +}; + for (const entry of connectorEntries) { const pathName = entry.replace(/\.ts$/, ""); const name = pathName.replace(/\/|\\/g, "-"); @@ -67,7 +71,7 @@ for (const entry of connectorEntries) { const names = [...new Set([name, ...alternativeNames])]; - const optionsTName = upperFirst(safeName) + "Options"; + const optionsTName = (connectorOptionsNameAliases[name] || upperFirst(safeName)) + "Options"; connectors.push({ name, diff --git a/src/_connectors.ts b/src/_connectors.ts index e0171c6..e09db47 100644 --- a/src/_connectors.ts +++ b/src/_connectors.ts @@ -9,6 +9,7 @@ import type { ConnectorOptions as LibSQLCoreOptions } from "db0/connectors/libsq import type { ConnectorOptions as LibSQLHttpOptions } from "db0/connectors/libsql/http"; import type { ConnectorOptions as LibSQLNodeOptions } from "db0/connectors/libsql/node"; import type { ConnectorOptions as LibSQLWebOptions } from "db0/connectors/libsql/web"; +import type { ConnectorOptions as MSSQLOptions } from "db0/connectors/mssql"; import type { ConnectorOptions as MySQL2Options } from "db0/connectors/mysql2"; import type { ConnectorOptions as NodeSQLiteOptions } from "db0/connectors/node-sqlite"; import type { ConnectorOptions as PgliteOptions } from "db0/connectors/pglite"; @@ -16,7 +17,7 @@ import type { ConnectorOptions as PlanetscaleOptions } from "db0/connectors/plan import type { ConnectorOptions as PostgreSQLOptions } from "db0/connectors/postgresql"; import type { ConnectorOptions as SQLite3Options } from "db0/connectors/sqlite3"; -export type ConnectorName = "better-sqlite3" | "bun-sqlite" | "bun" | "cloudflare-d1" | "cloudflare-hyperdrive-mysql" | "cloudflare-hyperdrive-postgresql" | "libsql-core" | "libsql-http" | "libsql-node" | "libsql" | "libsql-web" | "mysql2" | "node-sqlite" | "sqlite" | "pglite" | "planetscale" | "postgresql" | "sqlite3"; +export type ConnectorName = "better-sqlite3" | "bun-sqlite" | "bun" | "cloudflare-d1" | "cloudflare-hyperdrive-mysql" | "cloudflare-hyperdrive-postgresql" | "libsql-core" | "libsql-http" | "libsql-node" | "libsql" | "libsql-web" | "mssql" | "mysql2" | "node-sqlite" | "sqlite" | "pglite" | "planetscale" | "postgresql" | "sqlite3"; export type ConnectorOptions = { "better-sqlite3": BetterSQLite3Options; @@ -32,6 +33,7 @@ export type ConnectorOptions = { /** alias of libsql-node */ "libsql": LibSQLNodeOptions; "libsql-web": LibSQLWebOptions; + "mssql": MSSQLOptions; "mysql2": MySQL2Options; "node-sqlite": NodeSQLiteOptions; /** alias of node-sqlite */ @@ -56,6 +58,7 @@ export const connectors: Record = Object.freeze({ /** alias of libsql-node */ "libsql": "db0/connectors/libsql/node", "libsql-web": "db0/connectors/libsql/web", + "mssql": "db0/connectors/mssql", "mysql2": "db0/connectors/mysql2", "node-sqlite": "db0/connectors/node-sqlite", /** alias of node-sqlite */ diff --git a/src/connectors/mssql.ts b/src/connectors/mssql.ts new file mode 100644 index 0000000..246c0c7 --- /dev/null +++ b/src/connectors/mssql.ts @@ -0,0 +1,229 @@ +import { + Connection, + Request, + Connection as TediousConnection, + type ConnectionConfiguration, + TYPES, +} from "tedious"; + +import type { Connector, Statement, Primitive } from "db0"; + +// Type for tedious DataType +type DataType = (typeof TYPES)[keyof typeof TYPES]; + +export type ConnectorOptions = ConnectionConfiguration; + +export default function mssqlConnector(opts: ConnectorOptions) { + let _client: undefined | TediousConnection; + async function getClient(): Promise { + if (_client && _client.state === _client.STATE.LOGGED_IN) { + return _client; + } + + return new Promise((resolve, reject) => { + const client = new Connection(opts); + client.connect((error) => { + if (error) { + reject(error); + } + + _client = client; + }); + + client.on("connect", () => { + if (_client) { + resolve(_client); + } + }); + client.on("error", reject); + }); + } + + async function _run(sql: string, parameters?: unknown[]) { + if (!sql) { + throw new Error("SQL query must be provided"); + } + + const connection = await getClient(); + const { sql: _sql, parameters: _parameters } = prepareSqlParameters( + sql, + parameters, + ); + + const query = new Promise<{ rows: unknown[]; success: boolean }>( + (resolve, reject) => { + let success = false; + const request = new Request(_sql, (error) => { + if (error) { + reject(error); + } else { + success = true; + } + }); + + const parameterKeys = Object.keys(_parameters); + for (const key of parameterKeys) { + const parameter = _parameters[key]; + + request.addParameter(parameter.name, parameter.type, parameter.value); + } + + const rows: unknown[] = []; + request.on("row", (columns = []) => { + const currentRow: Record = {}; + for (const column of columns) { + const { value, metadata } = column; + const { colName } = metadata; + + currentRow[colName] = value; + } + + rows.push(currentRow); + }); + + request.on("requestCompleted", () => { + connection.close(); + resolve({ rows, success }); + }); + + request.on("error", (error) => { + connection.close(); + reject(error); + }); + + connection.execSql(request); + }, + ); + + try { + const { rows, success } = await query; + + return { + rows, + success, + }; + } catch (error: any) { + error.sql = _sql; + error.parameters = parameters; + throw error; + } + } + + return >{ + name: "mssql", + dialect: "mssql", + getInstance: () => getClient(), + exec(sql: string) { + return _run(sql, []); + }, + prepare(sql: string) { + const _sql = sql; + let _params: Primitive[] = []; + + const statement: Statement = { + bind(...params: Primitive[]) { + if (params.length > 0) { + _params = params; + } + return statement; + }, + async all(...params: Primitive[]) { + const { rows } = await _run( + _sql, + params.length > 0 ? params : _params, + ); + return rows; + }, + async run(...params: Primitive[]) { + const { success = false } = + (await _run(_sql, params.length > 0 ? params : _params)) || {}; + return { + success, + }; + }, + async get(...params: Primitive[]) { + const { + rows: [row], + } = await _run(_sql, params.length > 0 ? params : _params); + return row; + }, + }; + + return statement; + }, + }; +} + +// taken from the `kysely` library: https://github.com/kysely-org/kysely/blob/413a88516c20be42dc8cbebade68c27669a3ac1a/src/dialect/mssql/mssql-driver.ts#L440 +export function getTediousDataType(value: unknown): DataType { + if (value === null || value === undefined || typeof value === "string") { + return TYPES.NVarChar; + } + + if ( + typeof value === "bigint" || + (typeof value === "number" && value % 1 === 0) + ) { + return value < -2_147_483_648 || value > 2_147_483_647 + ? TYPES.BigInt + : TYPES.Int; + } + + if (typeof value === "number") { + return TYPES.Float; + } + + if (typeof value === "boolean") { + return TYPES.Bit; + } + + if (value instanceof Date) { + return TYPES.DateTime2; + } + + if (typeof Buffer !== "undefined" && Buffer.isBuffer(value)) { + return TYPES.VarBinary; + } + + return TYPES.NVarChar; +} + +// replace `?` placeholders with `@1`, `@2`, etc. +export function prepareSqlParameters( + sql: string, + parameters: unknown[] = [], +): { + sql: string; + parameters: Record; +} { + const parameterIndexes: number[] = []; + const tokens = [...sql]; + + // find all `?` placeholders in the SQL string + for (const [i, token] of tokens.entries()) { + if (token === "?") { + parameterIndexes.push(i); + } + } + + const namedParameters: Record< + string, + { name: string; type: DataType; value: unknown } + > = {}; + for (const [index, parameterIndex] of parameterIndexes.entries()) { + const incrementedIndex = index + 1; + // replace `?` placeholder with index-based parameter name + tokens[parameterIndex] = `@${incrementedIndex}`; + // store the parameter value and type with the index-based parameter name + namedParameters[`@${incrementedIndex}`] = { + name: String(incrementedIndex), + type: getTediousDataType(parameters[index]), + value: parameters[index], + }; + } + + return { + sql: tokens.join(""), // join the tokens back into a SQL string + parameters: namedParameters, + }; +} diff --git a/src/types.ts b/src/types.ts index c80bcc0..c3bc5dd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,7 +3,7 @@ */ export type Primitive = string | number | boolean | undefined | null; -export type SQLDialect = "mysql" | "postgresql" | "sqlite" | "libsql"; +export type SQLDialect = "mysql" | "postgresql" | "sqlite" | "libsql" | "mssql"; export type Statement = { /** diff --git a/test/connectors/_tests.ts b/test/connectors/_tests.ts index 7f42d78..0535a13 100644 --- a/test/connectors/_tests.ts +++ b/test/connectors/_tests.ts @@ -44,6 +44,15 @@ export function testConnector(opts: { await db.sql`CREATE TABLE users (\`id\` VARCHAR(4) PRIMARY KEY, \`firstName\` TEXT, \`lastName\` TEXT, \`email\` TEXT)`; break; } + case "mssql": { + await db.sql`CREATE TABLE users ( + [id] NVARCHAR(4) PRIMARY KEY, + [firstName] NVARCHAR(255), + [lastName] NVARCHAR(255), + [email] NVARCHAR(255) + )`; + break; + } default: { await db.sql`CREATE TABLE users ("id" TEXT PRIMARY KEY, "firstName" TEXT, "lastName" TEXT, "email" TEXT)`; break; @@ -57,6 +66,10 @@ export function testConnector(opts: { await db.sql`INSERT INTO users VALUES (${userId}, 'John', 'Doe', '')`; break; } + case "mssql": { + await db.sql`INSERT INTO users VALUES (${userId}, 'John', 'Doe', '')`; + break; + } default: { const { rows } = await db.sql`INSERT INTO users VALUES (${userId}, 'John', 'Doe', '') RETURNING *`; diff --git a/test/connectors/mssql.test.ts b/test/connectors/mssql.test.ts new file mode 100644 index 0000000..1eec6d8 --- /dev/null +++ b/test/connectors/mssql.test.ts @@ -0,0 +1,894 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { TYPES } from "tedious"; + +import { + getTediousDataType, + prepareSqlParameters, +} from "../../src/connectors/mssql.js"; +import connector from "../../src/connectors/mssql.js"; +import { testConnector } from "./_tests.js"; +import { createDatabase } from "../../src/index.js"; + +// Helper function to create connection configuration +function createConnectionConfig(database: string = process.env.MSSQL_DB_NAME!) { + return { + server: process.env.MSSQL_HOST!, + authentication: { + type: "default" as const, + options: { + userName: process.env.MSSQL_USERNAME!, + password: process.env.MSSQL_PASSWORD!, + }, + }, + options: { + database, + port: Number.parseInt(process.env.MSSQL_PORT || "1433", 10), + trustServerCertificate: true, + encrypt: false, + }, + }; +} + +describe.runIf( + process.env.MSSQL_HOST && + process.env.MSSQL_DB_NAME && + process.env.MSSQL_USERNAME && + process.env.MSSQL_PASSWORD, +)("connectors: mssql.test", () => { + testConnector({ + dialect: "mssql", + connector: connector(createConnectionConfig()), + }); +}); + +describe.runIf( + process.env.MSSQL_HOST && + process.env.MSSQL_DB_NAME && + process.env.MSSQL_USERNAME && + process.env.MSSQL_PASSWORD, +)("callProcedure", () => { + const db = createDatabase(connector(createConnectionConfig())); + + beforeAll(async () => { + // Drop procedure if it exists + await db.sql` + IF OBJECT_ID('dbo.GetUserCount', 'P') IS NOT NULL + DROP PROCEDURE dbo.GetUserCount + `; + + // Drop procedure if it exists + await db.sql` + IF OBJECT_ID('dbo.AddNumbers', 'P') IS NOT NULL + DROP PROCEDURE dbo.AddNumbers + `; + + // Drop procedure if it exists + await db.sql` + IF OBJECT_ID('dbo.ProcessUserData', 'P') IS NOT NULL + DROP PROCEDURE dbo.ProcessUserData + `; + + // Create a simple stored procedure that returns user count + await db.sql` + CREATE PROCEDURE dbo.GetUserCount + @minAge INT + AS + BEGIN + SELECT COUNT(*) as userCount + FROM (VALUES (1, 25), (2, 30), (3, 35)) AS Users(id, age) + WHERE age >= @minAge + END + `; + + // Create a stored procedure that adds two numbers + await db.sql` + CREATE PROCEDURE dbo.AddNumbers + @a INT, + @b INT + AS + BEGIN + SELECT (@a + @b) as result + END + `; + + // Create a stored procedure that accepts JSON data + await db.sql` + CREATE PROCEDURE dbo.ProcessUserData + @jsonData NVARCHAR(MAX) + AS + BEGIN + -- Parse JSON and return the data + SELECT + JSON_VALUE(@jsonData, '$.name') as name, + JSON_VALUE(@jsonData, '$.email') as email, + JSON_VALUE(@jsonData, '$.age') as age, + (SELECT * FROM OPENJSON(@jsonData, '$.hobbies') WITH (hobby NVARCHAR(100) '$') FOR JSON PATH) as hobbies + END + `; + }); + + afterAll(async () => { + // Clean up procedures + await db.sql` + IF OBJECT_ID('dbo.GetUserCount', 'P') IS NOT NULL + DROP PROCEDURE dbo.GetUserCount + `; + await db.sql` + IF OBJECT_ID('dbo.AddNumbers', 'P') IS NOT NULL + DROP PROCEDURE dbo.AddNumbers + `; + await db.sql` + IF OBJECT_ID('dbo.ProcessUserData', 'P') IS NOT NULL + DROP PROCEDURE dbo.ProcessUserData + `; + await db.dispose(); + }); + + it("should call a stored procedure with parameters", async () => { + const stmt = db.prepare("EXEC dbo.GetUserCount @minAge = ?"); + const rows = await stmt.all(30); + expect(rows).toBeDefined(); + expect(rows.length).toBe(1); + expect(rows[0]).toHaveProperty("userCount"); + expect((rows[0] as { userCount: number }).userCount).toBe(2); + }); + + it("should call a stored procedure with multiple parameters", async () => { + const stmt = db.prepare("EXEC dbo.AddNumbers @a = ?, @b = ?"); + const rows = await stmt.all(10, 20); + expect(rows).toBeDefined(); + expect(rows.length).toBe(1); + expect(rows[0]).toHaveProperty("result"); + expect((rows[0] as { result: number }).result).toBe(30); + }); + + it("should call a stored procedure using prepare", async () => { + const stmt = db.prepare("EXEC dbo.AddNumbers @a = ?, @b = ?"); + const rows = await stmt.all(5, 15); + expect(rows).toBeDefined(); + expect(rows.length).toBe(1); + expect(rows[0]).toHaveProperty("result"); + expect((rows[0] as { result: number }).result).toBe(20); + }); + + it("should return JSON data using FOR JSON PATH", async () => { + const stmt = db.prepare(` + SELECT + id, + firstName, + lastName, + email + FROM ( + VALUES + (1, 'John', 'Doe', 'john@example.com'), + (2, 'Jane', 'Smith', 'jane@example.com') + ) AS Users(id, firstName, lastName, email) + FOR JSON PATH + `); + const rows = await stmt.all(); + expect(rows).toBeDefined(); + expect(rows.length).toBeGreaterThan(0); + + // SQL Server returns JSON as a single column result + // The JSON data is in the first column (usually named "JSON_F52E2B61-18A1-11d1-B105-00805F49916B") + const jsonColumn = Object.keys(rows[0] as object)[0]!; + const jsonString = (rows[0] as Record)[jsonColumn]!; + + expect(jsonString).toBeDefined(); + const jsonData = JSON.parse(jsonString); + expect(Array.isArray(jsonData)).toBe(true); + expect(jsonData.length).toBe(2); + expect(jsonData[0]).toMatchObject({ + id: 1, + firstName: "John", + lastName: "Doe", + email: "john@example.com", + }); + expect(jsonData[1]).toMatchObject({ + id: 2, + firstName: "Jane", + lastName: "Smith", + email: "jane@example.com", + }); + }); + + it("should return JSON data with nested structure using FOR JSON PATH", async () => { + const stmt = db.prepare(` + SELECT + id, + firstName, + lastName, + ( + SELECT email, phone + FROM (VALUES ('john@example.com', '555-1234')) AS Contact(email, phone) + FOR JSON PATH + ) AS contact + FROM (VALUES (1, 'John', 'Doe')) AS Users(id, firstName, lastName) + FOR JSON PATH + `); + const rows = await stmt.all(); + expect(rows).toBeDefined(); + + const jsonColumn = Object.keys(rows[0] as object)[0]!; + const jsonString = (rows[0] as Record)[jsonColumn]!; + const jsonData = JSON.parse(jsonString); + + expect(Array.isArray(jsonData)).toBe(true); + expect(jsonData[0]).toHaveProperty("id", 1); + expect(jsonData[0]).toHaveProperty("firstName", "John"); + expect(jsonData[0]).toHaveProperty("contact"); + + // The nested contact is already a JSON string that needs to be parsed + const contactData = jsonData[0].contact; + const contact = + typeof contactData === "string" ? JSON.parse(contactData) : contactData; + expect(Array.isArray(contact)).toBe(true); + expect(contact[0]).toMatchObject({ + email: "john@example.com", + phone: "555-1234", + }); + }); + + it("should return single JSON object using FOR JSON PATH, WITHOUT_ARRAY_WRAPPER", async () => { + const stmt = db.prepare(` + SELECT + id, + firstName, + lastName, + email, + age + FROM ( + VALUES (1, 'John', 'Doe', 'john@example.com', 30) + ) AS Users(id, firstName, lastName, email, age) + FOR JSON PATH, WITHOUT_ARRAY_WRAPPER + `); + const rows = await stmt.all(); + expect(rows).toBeDefined(); + expect(rows.length).toBeGreaterThan(0); + + // SQL Server returns JSON as a single column result + const jsonColumn = Object.keys(rows[0] as object)[0]!; + const jsonString = (rows[0] as Record)[jsonColumn]!; + + expect(jsonString).toBeDefined(); + const jsonData = JSON.parse(jsonString); + + // WITHOUT_ARRAY_WRAPPER returns a single object, not an array + expect(Array.isArray(jsonData)).toBe(false); + expect(jsonData).toMatchObject({ + id: 1, + firstName: "John", + lastName: "Doe", + email: "john@example.com", + age: 30, + }); + }); + + it("should call a stored procedure with JSON parameter", async () => { + const userData = { + name: "Alice Johnson", + email: "alice@example.com", + age: "28", + hobbies: ["reading", "hiking", "photography"], + }; + + const jsonString = JSON.stringify(userData); + const stmt = db.prepare("EXEC dbo.ProcessUserData @jsonData = ?"); + const rows = await stmt.all(jsonString); + + expect(rows).toBeDefined(); + expect(rows.length).toBe(1); + expect(rows[0]).toHaveProperty("name", "Alice Johnson"); + expect(rows[0]).toHaveProperty("email", "alice@example.com"); + expect(rows[0]).toHaveProperty("age", "28"); + expect(rows[0]).toHaveProperty("hobbies"); + + // Parse the hobbies JSON array + const hobbiesData = (rows[0] as Record).hobbies; + if (hobbiesData) { + const hobbies = JSON.parse(hobbiesData); + expect(Array.isArray(hobbies)).toBe(true); + expect(hobbies.length).toBe(3); + expect(hobbies[0]).toHaveProperty("hobby", "reading"); + expect(hobbies[1]).toHaveProperty("hobby", "hiking"); + expect(hobbies[2]).toHaveProperty("hobby", "photography"); + } + }); + + it("should call a stored procedure with complex JSON parameter", async () => { + const complexData = { + name: "Bob Smith", + email: "bob@example.com", + age: "35", + hobbies: ["gaming", "cooking"], + }; + + const jsonString = JSON.stringify(complexData); + const stmt = db.prepare("EXEC dbo.ProcessUserData @jsonData = ?"); + const rows = await stmt.all(jsonString); + + expect(rows).toBeDefined(); + expect(rows.length).toBe(1); + + const result = rows[0] as Record; + expect(result.name).toBe("Bob Smith"); + expect(result.email).toBe("bob@example.com"); + expect(result.age).toBe("35"); + + // Verify hobbies array + if (result.hobbies) { + const hobbies = JSON.parse(result.hobbies); + expect(Array.isArray(hobbies)).toBe(true); + expect(hobbies.length).toBe(2); + } + }); +}); + +describe.runIf( + process.env.MSSQL_HOST && + process.env.MSSQL_USERNAME && + process.env.MSSQL_PASSWORD, +)("createDatabase", () => { + const testDbName = "TestDB_CreateTest"; + let db: ReturnType; + + beforeAll(() => { + // Connect to master database to create/drop test database + db = createDatabase(connector(createConnectionConfig("master"))); + }); + + afterAll(async () => { + // Clean up: drop the test database if it exists + // try { + // await db.exec(` + // IF EXISTS (SELECT * FROM sys.databases WHERE name = '${testDbName}') + // BEGIN + // ALTER DATABASE [${testDbName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + // DROP DATABASE [${testDbName}]; + // END + // `); + // } catch (error) { + // // Ignore errors if database doesn't exist + // } + await db.dispose(); + }); + + it("should create a new database", async () => { + // Drop database if it exists from previous failed test + try { + await db.exec(` + IF EXISTS (SELECT * FROM sys.databases WHERE name = '${testDbName}') + BEGIN + ALTER DATABASE [${testDbName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + DROP DATABASE [${testDbName}]; + END + `); + } catch { + // Ignore errors if database doesn't exist + } + + // Create the database + await db.exec(`CREATE DATABASE [${testDbName}]`); + + // Verify the database exists + const stmt = db.prepare("SELECT name FROM sys.databases WHERE name = ?"); + const rows = await stmt.all(testDbName); + expect(rows).toBeDefined(); + expect(rows.length).toBe(1); + expect((rows[0] as { name: string }).name).toBe(testDbName); + }); + + it("should check if database exists", async () => { + const stmt = db.prepare(` + SELECT CASE + WHEN EXISTS (SELECT * FROM sys.databases WHERE name = ?) + THEN 1 + ELSE 0 + END as dbExists + `); + const rows = await stmt.all(testDbName); + expect(rows).toBeDefined(); + expect(rows.length).toBe(1); + expect((rows[0] as { dbExists: number }).dbExists).toBe(1); + }); + + it.skip("should drop an existing database", async () => { + // Drop the database + await db.exec(` + ALTER DATABASE [${testDbName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + DROP DATABASE [${testDbName}]; + `); + + // Verify the database no longer exists + const stmt = db.prepare("SELECT name FROM sys.databases WHERE name = ?"); + const rows = await stmt.all(testDbName); + expect(rows).toBeDefined(); + expect(rows.length).toBe(0); + }); +}); + +describe("getTediousDataType", () => { + it("should return NVarChar for null", () => { + expect(getTediousDataType(null)).toBe(TYPES.NVarChar); + }); + + it("should return NVarChar for undefined", () => { + expect(getTediousDataType(undefined)).toBe(TYPES.NVarChar); + }); + + it("should return NVarChar for strings", () => { + expect(getTediousDataType("test")).toBe(TYPES.NVarChar); + }); + + it("should return Int for numbers", () => { + expect(getTediousDataType(123)).toBe(TYPES.Int); + }); + + it("should return BigInt for large integer numbers", () => { + expect(getTediousDataType(2_147_483_648)).toBe(TYPES.BigInt); + }); + + it("should return Float for floating point numbers", () => { + expect(getTediousDataType(123.45)).toBe(TYPES.Float); + }); + + it("should return Bit for boolean values", () => { + expect(getTediousDataType(true)).toBe(TYPES.Bit); + }); + + it("should return DateTime for Date objects", () => { + expect(getTediousDataType(new Date())).toBe(TYPES.DateTime2); + }); + + it("should return VarBinary for Buffer objects", () => { + expect(getTediousDataType(Buffer.from("test"))).toBe(TYPES.VarBinary); + }); + + it("should return NVarChar by default for other types", () => { + expect(getTediousDataType({})).toBe(TYPES.NVarChar); + }); +}); + +describe("prepareSqlParameters", () => { + it("should replace ? with @1, @2, etc.", () => { + const sql = "SELECT * FROM users WHERE id = ? AND name = ?"; + const parameters = [1, "John"]; + const result = prepareSqlParameters(sql, parameters); + expect(result.sql).toBe("SELECT * FROM users WHERE id = @1 AND name = @2"); + expect(result.parameters).toEqual({ + "@1": { name: "1", type: TYPES.Int, value: 1 }, + "@2": { name: "2", type: TYPES.NVarChar, value: "John" }, + }); + }); + + it("should handle no parameters", () => { + const sql = "SELECT * FROM users"; + const parameters: unknown[] = []; + const result = prepareSqlParameters(sql, parameters); + expect(result.sql).toBe("SELECT * FROM users"); + expect(result.parameters).toEqual({}); + }); + + it("should handle multiple parameters of different types", () => { + const sql = "SELECT * FROM users WHERE id = ? AND age = ? AND active = ?"; + const parameters = [1, 30, true]; + const result = prepareSqlParameters(sql, parameters); + expect(result.sql).toBe( + "SELECT * FROM users WHERE id = @1 AND age = @2 AND active = @3", + ); + expect(result.parameters).toEqual({ + "@1": { name: "1", type: TYPES.Int, value: 1 }, + "@2": { name: "2", type: TYPES.Int, value: 30 }, + "@3": { name: "3", type: TYPES.Bit, value: true }, + }); + }); + + it("should handle null and undefined parameters", () => { + const sql = "SELECT * FROM users WHERE name = ? AND email = ?"; + + const parameters = [null, undefined]; + const result = prepareSqlParameters(sql, parameters); + expect(result.sql).toBe( + "SELECT * FROM users WHERE name = @1 AND email = @2", + ); + expect(result.parameters).toEqual({ + "@1": { name: "1", type: TYPES.NVarChar, value: null }, + "@2": { name: "2", type: TYPES.NVarChar, value: undefined }, + }); + }); +}); + +// Error Handling Tests +describe.runIf( + process.env.MSSQL_HOST && + process.env.MSSQL_DB_NAME && + process.env.MSSQL_USERNAME && + process.env.MSSQL_PASSWORD, +)("error handling", () => { + const db = createDatabase(connector(createConnectionConfig())); + + afterAll(async () => { + await db.dispose(); + }); + + it("should handle invalid SQL syntax", async () => { + await expect(async () => { + await db.exec("SELECT * FORM invalid_table"); + }).rejects.toThrow(); + }); + + it("should handle non-existent table", async () => { + await expect(async () => { + await db.sql`SELECT * FROM non_existent_table_12345`; + }).rejects.toThrow(); + }); + + it("should handle parameter count mismatch", async () => { + const stmt = db.prepare( + "INSERT INTO sys.tables (name, object_id) VALUES (?, ?)", + ); + // Providing only one parameter when two are expected - should fail + await expect(async () => { + await stmt.all("test"); + }).rejects.toThrow(); + }); + + it("should handle empty SQL query", async () => { + await expect(async () => { + await db.exec(""); + }).rejects.toThrow("SQL query must be provided"); + }); + + it("should provide error context with SQL and parameters", async () => { + try { + const stmt = db.prepare("SELECT * FROM invalid_table WHERE id = ?"); + await stmt.all(123); + expect.fail("Should have thrown an error"); + } catch (error: any) { + expect(error.sql).toBeDefined(); + expect(error.parameters).toBeDefined(); + } + }); + + it("should handle type conversion errors gracefully", async () => { + // Test that tedious converts string numbers to integers automatically + const stmt = db.prepare("SELECT CAST(? AS INT) as result"); + const rows = await stmt.all("42"); + expect(rows.length).toBe(1); + expect((rows[0] as any).result).toBe(42); + }); +}); + +// Transaction Tests +describe.runIf( + process.env.MSSQL_HOST && + process.env.MSSQL_DB_NAME && + process.env.MSSQL_USERNAME && + process.env.MSSQL_PASSWORD, +)("transactions", () => { + const db = createDatabase(connector(createConnectionConfig())); + + beforeAll(async () => { + await db.exec(` + IF OBJECT_ID('dbo.test_transactions', 'U') IS NOT NULL + DROP TABLE dbo.test_transactions; + CREATE TABLE dbo.test_transactions ( + id INT PRIMARY KEY, + value NVARCHAR(100) + ); + `); + }); + + afterAll(async () => { + await db.exec("DROP TABLE IF EXISTS dbo.test_transactions"); + await db.dispose(); + }); + + it("should commit a transaction successfully", async () => { + // Use single connection for transaction operations + await db.exec(` + BEGIN TRANSACTION; + INSERT INTO dbo.test_transactions (id, value) VALUES (1, 'test1'); + COMMIT TRANSACTION; + `); + + const stmt = db.prepare("SELECT * FROM dbo.test_transactions WHERE id = ?"); + const rows = await stmt.all(1); + expect(rows.length).toBe(1); + expect((rows[0] as any).value).toBe("test1"); + }); + + it("should rollback a transaction", async () => { + // Use single connection for transaction operations + await db.exec(` + BEGIN TRANSACTION; + INSERT INTO dbo.test_transactions (id, value) VALUES (2, 'test2'); + ROLLBACK TRANSACTION; + `); + + const stmt = db.prepare("SELECT * FROM dbo.test_transactions WHERE id = ?"); + const rows = await stmt.all(2); + expect(rows.length).toBe(0); + }); + + it("should handle nested transactions with savepoints", async () => { + await db.exec(` + BEGIN TRANSACTION; + INSERT INTO dbo.test_transactions (id, value) VALUES (3, 'outer'); + SAVE TRANSACTION savepoint1; + INSERT INTO dbo.test_transactions (id, value) VALUES (4, 'inner'); + ROLLBACK TRANSACTION savepoint1; + COMMIT TRANSACTION; + `); + + const stmt = db.prepare( + "SELECT * FROM dbo.test_transactions WHERE id IN (?, ?)", + ); + const rows = await stmt.all(3, 4); + + expect(rows.length).toBe(1); + expect((rows[0] as any).id).toBe(3); + }); + + it("should handle transaction isolation levels", async () => { + await db.exec(` + SET TRANSACTION ISOLATION LEVEL READ COMMITTED; + BEGIN TRANSACTION; + INSERT INTO dbo.test_transactions (id, value) VALUES (5, 'isolation_test'); + COMMIT TRANSACTION; + `); + + const stmt = db.prepare("SELECT * FROM dbo.test_transactions WHERE id = ?"); + const rows = await stmt.all(5); + expect(rows.length).toBe(1); + }); +}); + +// Batch Operations and Advanced Tests +describe.runIf( + process.env.MSSQL_HOST && + process.env.MSSQL_DB_NAME && + process.env.MSSQL_USERNAME && + process.env.MSSQL_PASSWORD, +)("batch operations", () => { + const db = createDatabase(connector(createConnectionConfig())); + + beforeAll(async () => { + await db.exec(` + IF OBJECT_ID('dbo.test_batch', 'U') IS NOT NULL + DROP TABLE dbo.test_batch; + CREATE TABLE dbo.test_batch ( + id INT PRIMARY KEY, + name NVARCHAR(100), + data VARBINARY(MAX) + ); + + IF OBJECT_ID('dbo.GetUserWithOutput', 'P') IS NOT NULL + DROP PROCEDURE dbo.GetUserWithOutput; + `); + + await db.exec(` + CREATE PROCEDURE dbo.GetUserWithOutput + @userId INT, + @userName NVARCHAR(100) OUTPUT + AS + BEGIN + SET @userName = 'User_' + CAST(@userId AS NVARCHAR); + SELECT @userId as id, @userName as name; + END + `); + }); + + afterAll(async () => { + await db.exec("DROP TABLE IF EXISTS dbo.test_batch"); + await db.exec("DROP PROCEDURE IF EXISTS dbo.GetUserWithOutput"); + await db.dispose(); + }); + + it("should handle multiple inserts in batch", async () => { + const stmt = db.prepare( + "INSERT INTO dbo.test_batch (id, name) VALUES (?, ?)", + ); + + await stmt.run(1, "Alice"); + await stmt.run(2, "Bob"); + await stmt.run(3, "Charlie"); + + const selectStmt = db.prepare( + "SELECT COUNT(*) as count FROM dbo.test_batch", + ); + const rows = await selectStmt.all(); + expect((rows[0] as any).count).toBe(3); + }); + + it("should handle binary data (BLOB)", async () => { + const binaryData = Buffer.from("Hello, World!", "utf8"); + const stmt = db.prepare( + "INSERT INTO dbo.test_batch (id, name, data) VALUES (?, ?, ?);", + ); + await stmt.run(10, "binary_test", binaryData as any); + + const selectStmt = db.prepare("SELECT * FROM dbo.test_batch WHERE id = ?"); + const rows = await selectStmt.all(10); + expect(rows.length).toBe(1); + expect(Buffer.isBuffer((rows[0] as any).data)).toBe(true); + expect((rows[0] as any).data.toString("utf8")).toBe("Hello, World!"); + }); + + it("should handle special characters in parameters", async () => { + const specialText = + "Test with 'quotes', \"double quotes\", and\nnewlines\ttabs"; + const stmt = db.prepare( + "INSERT INTO dbo.test_batch (id, name) VALUES (?, ?)", + ); + await stmt.run(20, specialText); + + const selectStmt = db.prepare("SELECT * FROM dbo.test_batch WHERE id = ?"); + const rows = await selectStmt.all(20); + expect((rows[0] as any).name).toBe(specialText); + }); + + it("should handle unicode characters", async () => { + const unicodeText = "Hello 世界 🌍 مرحبا"; + const stmt = db.prepare( + "INSERT INTO dbo.test_batch (id, name) VALUES (?, ?)", + ); + await stmt.run(30, unicodeText); + + const selectStmt = db.prepare("SELECT * FROM dbo.test_batch WHERE id = ?"); + const rows = await selectStmt.all(30); + expect((rows[0] as any).name).toBe(unicodeText); + }); + + it("should handle stored procedure with output parameters", async () => { + // Note: Current implementation doesn't support OUTPUT parameters directly + // This test calls the procedure and gets result set instead (returns 2 rows) + const stmt = db.prepare( + "DECLARE @name NVARCHAR(100); EXEC dbo.GetUserWithOutput @userId = ?, @userName = @name OUTPUT; SELECT @name as userName", + ); + const rows = await stmt.all(42); + // Procedure returns result set + our SELECT statement = 2 rows + expect(rows.length).toBeGreaterThanOrEqual(1); + // Check the last row for our output + const lastRow = rows.at(-1); + expect((lastRow as any).userName).toBe("User_42"); + }); + + it("should handle empty result sets", async () => { + const stmt = db.prepare("SELECT * FROM dbo.test_batch WHERE id = ?"); + const rows = await stmt.all(99_999); + expect(rows.length).toBe(0); + }); + + it("should handle NULL values correctly", async () => { + const stmt = db.prepare( + "INSERT INTO dbo.test_batch (id, name) VALUES (?, ?)", + ); + + await stmt.run(40, null); + + const selectStmt = db.prepare("SELECT * FROM dbo.test_batch WHERE id = ?"); + const rows = await selectStmt.all(40); + expect(rows.length).toBe(1); + expect((rows[0] as any).name).toBeNull(); + }); +}); + +// Performance Tests +describe.runIf( + process.env.MSSQL_HOST && + process.env.MSSQL_DB_NAME && + process.env.MSSQL_USERNAME && + process.env.MSSQL_PASSWORD, +)("performance", () => { + const db = createDatabase(connector(createConnectionConfig())); + + beforeAll(async () => { + await db.exec(` + IF OBJECT_ID('dbo.test_performance', 'U') IS NOT NULL + DROP TABLE dbo.test_performance; + CREATE TABLE dbo.test_performance ( + id INT PRIMARY KEY, + data NVARCHAR(1000) + ); + `); + }); + + afterAll(async () => { + await db.exec("DROP TABLE IF EXISTS dbo.test_performance"); + await db.dispose(); + }); + + it("should handle multiple sequential queries efficiently", async () => { + const start = Date.now(); + const stmt = db.prepare("SELECT ?"); + + for (let i = 0; i < 10; i++) { + await stmt.all(i); + } + + const duration = Date.now() - start; + // Should complete 10 queries in reasonable time (less than 5 seconds) + expect(duration).toBeLessThan(5000); + }); + + it("should handle large result sets", async () => { + // Insert 100 rows + const insertStmt = db.prepare( + "INSERT INTO dbo.test_performance (id, data) VALUES (?, ?)", + ); + for (let i = 1; i <= 100; i++) { + await insertStmt.run(i, `Data for row ${i}`); + } + + const start = Date.now(); + const selectStmt = db.prepare("SELECT * FROM dbo.test_performance"); + const rows = await selectStmt.all(); + const duration = Date.now() - start; + + expect(rows.length).toBe(100); + // Should fetch 100 rows in reasonable time (less than 2 seconds) + expect(duration).toBeLessThan(2000); + }); + + it("should handle prepared statement reuse", async () => { + const stmt = db.prepare("SELECT * FROM dbo.test_performance WHERE id = ?"); + + const start = Date.now(); + for (let i = 1; i <= 20; i++) { + await stmt.all(i); + } + const duration = Date.now() - start; + + // Reusing prepared statement should be efficient (less than 3 seconds) + expect(duration).toBeLessThan(3000); + }); + + it("should handle concurrent query execution", async () => { + const stmt = db.prepare("SELECT ?"); + + const start = Date.now(); + // Note: Current implementation may execute these sequentially due to connection management + const promises = []; + for (let i = 0; i < 5; i++) { + promises.push(stmt.all(i)); + } + await Promise.all(promises); + const duration = Date.now() - start; + + // Should handle 5 queries efficiently + expect(duration).toBeLessThan(5000); + }); + + it("should handle large text data", async () => { + const largeText = "a".repeat(900); // Just under 1000 char limit + const stmt = db.prepare( + "INSERT INTO dbo.test_performance (id, data) VALUES (?, ?)", + ); + + const start = Date.now(); + await stmt.run(1000, largeText); + const duration = Date.now() - start; + + expect(duration).toBeLessThan(2000); + + const selectStmt = db.prepare( + "SELECT * FROM dbo.test_performance WHERE id = ?", + ); + const rows = await selectStmt.all(1000); + expect((rows[0] as any).data).toBe(largeText); + }); + + it("should handle query timeout scenarios", async () => { + // Test a long-running query + const stmt = db.prepare("WAITFOR DELAY '00:00:01'; SELECT 1 as result"); + + const start = Date.now(); + const rows = await stmt.all(); + const duration = Date.now() - start; + + expect(rows.length).toBe(1); + expect(duration).toBeGreaterThanOrEqual(1000); + expect(duration).toBeLessThan(3000); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index cec357c..3570523 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,7 +4,7 @@ export default defineConfig({ test: { setupFiles: ["dotenv/config"], coverage: { - reporter: ["text", "clover", "json"], + reporter: ["text", "clover", "json", "html"], include: ["src/**/*.ts"], }, },