diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index ca779c4..cededc9 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -6,7 +6,7 @@ on: - main - rc - beta - - dev + - next workflow_dispatch: jobs: @@ -25,14 +25,32 @@ jobs: node-version: '24' registry-url: 'https://registry.npmjs.org' + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: latest + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Cache pnpm store + uses: actions/cache@v4 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + - name: Install dependencies - run: npm ci + run: pnpm install - name: Run tests - run: npm test + run: pnpm test - name: Build project - run: npm run build:prod + run: pnpm build:prod - name: Determine version and tag id: version @@ -45,8 +63,15 @@ jobs: "main") NPM_TAG="latest" VERSION=$PACKAGE_VERSION + + # Find the latest tag matching this package and error if it already exists + LAST_TAG=$(git tag --list "v${PACKAGE_VERSION}" | sort -V | tail -n1) + if [ "$LAST_TAG" == "v${PACKAGE_VERSION}" ]; then + echo "Version $PACKAGE_VERSION already exists. Please update the version in package.json and jsr.json before publishing." + exit 1 + fi ;; - "rc"|"beta"|"dev") + "rc"|"beta"|"next") IDENTIFIER=$BRANCH_NAME NPM_TAG=$BRANCH_NAME @@ -77,14 +102,32 @@ jobs: - name: Update package version run: | - npm version ${{ steps.version.outputs.version }} --no-git-tag-version --allow-same-version + pnpm version ${{ steps.version.outputs.version }} --no-git-tag-version --allow-same-version + ./scripts/update-jsr-version.sh ${{ steps.version.outputs.version }} + + - name: Create distribution archive + run: | + # Create a compressed archive of the dist directory + tar -czf dist-${{ steps.version.outputs.version }}.tar.gz dist/ + + # Create a zip archive as well for Windows users + zip -r dist-${{ steps.version.outputs.version }}.zip dist/ + + # Create a compressed archive of the dist-qjs directory + tar -czf dist-qjs-${{ steps.version.outputs.version }}.tar.gz dist-qjs/ + + # Create a zip archive of the dist-qjs directory for Windows users + zip -r dist-qjs-${{ steps.version.outputs.version }}.zip dist-qjs - name: Publish to NPM run: | - npm publish --tag ${{ steps.version.outputs.npm_tag }} + pnpm publish --tag ${{ steps.version.outputs.npm_tag }} --no-git-checks env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Publish to JSR + run: pnpx jsr publish --allow-dirty + - name: Create GitHub Release (main branch only) if: github.ref == 'refs/heads/main' uses: ncipollo/release-action@v1 @@ -92,6 +135,11 @@ jobs: tag: v${{ steps.version.outputs.version }} name: Release v${{ steps.version.outputs.version }} commit: ${{ github.sha }} + artifacts: | + dist-${{ steps.version.outputs.version }}.tar.gz + dist-${{ steps.version.outputs.version }}.zip + dist-qjs-${{ steps.version.outputs.version }}.tar.gz + dist-qjs-${{ steps.version.outputs.version }}.zip body: | ## Changes @@ -102,18 +150,27 @@ jobs: npm install sqm@latest ``` + Download the distribution archives: + - dist-${{ steps.version.outputs.version }}.tar.gz `compressed tar.gz archive` + - dist-${{ steps.version.outputs.version }}.zip `zip archive` + For full changelog, see the commit history. draft: false prerelease: false generateReleaseNotes: true - - name: Create Pre-release (rc, beta, dev branches) + - name: Create Pre-release (rc, beta, next branches) if: github.ref != 'refs/heads/main' uses: ncipollo/release-action@v1 with: tag: v${{ steps.version.outputs.version }} name: ${{ steps.version.outputs.branch }} v${{ steps.version.outputs.version }} commit: ${{ github.sha }} + artifacts: | + dist-${{ steps.version.outputs.version }}.tar.gz + dist-${{ steps.version.outputs.version }}.zip + dist-qjs-${{ steps.version.outputs.version }}.tar.gz + dist-qjs-${{ steps.version.outputs.version }}.zip body: | ## ${{ steps.version.outputs.branch }} Release @@ -124,6 +181,10 @@ jobs: npm install sqm@${{ steps.version.outputs.npm_tag }} ``` + Download the distribution archives: + - dist-${{ steps.version.outputs.version }}.tar.gz `compressed tar.gz archive` + - dist-${{ steps.version.outputs.version }}.zip `zip archive` + **⚠️ Warning**: This is a pre-release version and may contain bugs or incomplete features. draft: false prerelease: true diff --git a/.gitignore b/.gitignore index 0e75fe5..397e09c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules dist +dist* coverage diff --git a/.npmignore b/.npmignore index 85056a0..e8b628c 100644 --- a/.npmignore +++ b/.npmignore @@ -6,4 +6,5 @@ package-lock.json .vscode .idea .DS_Store +scripts UPDATE-STRATEGY.md diff --git a/UPDATE-STRATEGY.md b/UPDATE-STRATEGY.md index 222cc04..19bf5c6 100644 --- a/UPDATE-STRATEGY.md +++ b/UPDATE-STRATEGY.md @@ -4,34 +4,34 @@ Fisrt I'll clarify branch names: - `main` is the production branch, it should always be stable and deployable. - `rc` is the release candidate branch, it is used for testing new features before they are merged into `main`. - `beta` is the beta branch, it is used for testing new features before they are merged into `rc`. -- `dev` is the development branch, it is used for active development and may be unstable +- `next` is the development branch, it is used for active development and may be unstable ## Update Strategy 1. **Development Phase**: - - All new features and bug fixes are developed in the `dev` branch. - - Regular commits and pushes to `dev` to ensure changes are tracked. + - All new features and bug fixes are developed in the `next` branch. + - Regular commits and pushes to `next` to ensure changes are tracked. - Once a feature or fix is complete, it is merged into the `beta` branch for initial testing. 2. **Beta Testing Phase**: - The `beta` branch is published into npm with the `beta` tag. - Beta testers and early adopters can install the beta version using `npm install sqm@beta`. - - Feedback from beta testers is collected and any issues are addressed in the `dev` branch. + - Feedback from beta testers is collected and any issues are addressed in the `next` branch. - Once the beta version is stable and all critical issues are resolved, it is merged into the `rc` branch. 3. **Release Candidate Phase**: - The `rc` branch is published into npm with the `rc` tag. - Further testing is conducted to ensure stability and performance. - - Any final bugs or issues are fixed in the `dev` branch and merged into `rc`. + - Any final bugs or issues are fixed in the `next` branch and merged into `rc`. - Once the release candidate is deemed stable, it is merged into the `main` branch. 4. **Production Release Phase**: - The `main` branch is published into npm with the `latest` tag. - Users can install the stable version using `npm install sqm`. - Post-release monitoring is conducted to ensure the release is functioning as expected. - - Any critical issues found in production are addressed in the `dev` branch and the cycle repeats. + - Any critical issues found in production are addressed in the `next` branch and the cycle repeats. ## Hotfixes In case of critical bugs in the `main` branch: 1. Create a hotfix branch from `main`. 2. Implement the fix and test it thoroughly. -3. Merge the hotfix branch back into `main`, `rc`, and `dev` branches. +3. Merge the hotfix branch back into `main`, `rc`, and `next` branches. 4. Publish the updated `main` branch and other branches as necessary. 5. Communicate the hotfix to users if necessary. diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..695de85 --- /dev/null +++ b/biome.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.2.5/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false, + "includes": ["**/*.ts", "!**/*.test.ts", "!**/*.spec.ts"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noExplicitAny": "off", + "useIterableCallbackReturn": "off", + "noConfusingVoidType": "off" + }, + "style": { + "noNonNullAssertion": "off" + }, + "complexity": { + "noBannedTypes": "off", + "noStaticOnlyClass": "off", + "useLiteralKeys": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/jsr.json b/jsr.json new file mode 100644 index 0000000..fe5ac05 --- /dev/null +++ b/jsr.json @@ -0,0 +1,36 @@ +{ + "name": "@fragmenta/sqm", + "version": "0.7.0", + "description": "A lightweight and flexible query maker library for building raw SQL queries in JavaScript.", + "license": "Apache-2.0", + "exports": { + ".": "./src/index.ts", + "./queryUtils": "./src/queryUtils/index.ts", + "./types": "./src/types/index.ts", + "./ddl": "./src/queryKinds/ddl/index.ts", + "./dml": "./src/queryKinds/dml/index.ts", + "./ddl/table": "./src/queryKinds/ddl/table/index.ts" + }, + "publish": { + "include": [ + "src", + "README.md", + "LICENSE.md" + ], + "exclude": [ + "node_modules", + "dist", + "dist-qjs", + "**/*.test.ts", + "**/*.spec.ts", + "scripts", + "tsup.config.ts", + "tsup.config.qjs.ts", + "vitest.config.ts", + ".gitignore", + ".npmignore", + "package-lock.json", + "pnpm-lock.yaml" + ] + } +} diff --git a/package-lock.json b/package-lock.json index 65a7eab..e0fd28a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,30 +1,29 @@ { "name": "sqm", - "version": "0.6.0", + "version": "0.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sqm", - "version": "0.6.0", + "version": "0.7.1", "license": "Apache-2.0", - "dependencies": { - "ts-pattern": "^5.8.0" - }, "devDependencies": { - "@swc/core": "^1.13.5", - "@vitest/coverage-v8": "^3.2.4", + "@biomejs/biome": "^2.3.2", + "@swc/core": "^1.14.0", + "@vitest/coverage-v8": "^4.0.5", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "concurrently": "^9.2.1", + "jsr": "^0.13.5", "nodemon": "^3.1.10", "terser": "^5.44.0", "tslib": "^2.8.1", "tsup": "^8.5.0", - "typescript": "^5.9.2", - "vitest": "^3.2.4", + "typescript": "^5.9.3", + "vitest": "^4.0.5", "wait-on": "^9.0.1", - "zod": "^4.1.11" + "zod": "^4.1.12" }, "engines": { "node": ">=21" @@ -46,20 +45,6 @@ } } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -120,6 +105,169 @@ "node": ">=18" } }, + "node_modules/@biomejs/biome": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.2.tgz", + "integrity": "sha512-8e9tzamuDycx7fdrcJ/F/GDZ8SYukc5ud6tDicjjFqURKYFSWMl0H0iXNXZEGmcmNUmABgGuHThPykcM41INgg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.3.2", + "@biomejs/cli-darwin-x64": "2.3.2", + "@biomejs/cli-linux-arm64": "2.3.2", + "@biomejs/cli-linux-arm64-musl": "2.3.2", + "@biomejs/cli-linux-x64": "2.3.2", + "@biomejs/cli-linux-x64-musl": "2.3.2", + "@biomejs/cli-win32-arm64": "2.3.2", + "@biomejs/cli-win32-x64": "2.3.2" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.2.tgz", + "integrity": "sha512-4LECm4kc3If0JISai4c3KWQzukoUdpxy4fRzlrPcrdMSRFksR9ZoXK7JBcPuLBmd2SoT4/d7CQS33VnZpgBjew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.2.tgz", + "integrity": "sha512-jNMnfwHT4N3wi+ypRfMTjLGnDmKYGzxVr1EYAPBcauRcDnICFXN81wD6wxJcSUrLynoyyYCdfW6vJHS/IAoTDA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.2.tgz", + "integrity": "sha512-amnqvk+gWybbQleRRq8TMe0rIv7GHss8mFJEaGuEZYWg1Tw14YKOkeo8h6pf1c+d3qR+JU4iT9KXnBKGON4klw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.2.tgz", + "integrity": "sha512-2Zz4usDG1GTTPQnliIeNx6eVGGP2ry5vE/v39nT73a3cKN6t5H5XxjcEoZZh62uVZvED7hXXikclvI64vZkYqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.2.tgz", + "integrity": "sha512-8BG/vRAhFz1pmuyd24FQPhNeueLqPtwvZk6yblABY2gzL2H8fLQAF/Z2OPIc+BPIVPld+8cSiKY/KFh6k81xfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.2.tgz", + "integrity": "sha512-gzB19MpRdTuOuLtPpFBGrV3Lq424gHyq2lFj8wfX9tvLMLdmA/R9C7k/mqBp/spcbWuHeIEKgEs3RviOPcWGBA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.2.tgz", + "integrity": "sha512-lCruqQlfWjhMlOdyf5pDHOxoNm4WoyY2vZ4YN33/nuZBRstVDuqPPjS0yBkbUlLEte11FbpW+wWSlfnZfSIZvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.2.tgz", + "integrity": "sha512-6Ee9P26DTb4D8sN9nXxgbi9Dw5vSOfH98M7UlmkjKB2vtUbrRqCbZiNfryGiwnPIpd6YUoTl7rLVD2/x1CyEHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.10", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", @@ -634,16 +782,6 @@ "node": ">=12" } }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1021,15 +1159,16 @@ "license": "MIT" }, "node_modules/@swc/core": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.5.tgz", - "integrity": "sha512-WezcBo8a0Dg2rnR82zhwoR6aRNxeTGfK5QCD6TQ+kg3xx/zNT02s/0o+81h/3zhvFSB24NtqEr8FTw88O5W/JQ==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.14.0.tgz", + "integrity": "sha512-oExhY90bes5pDTVrei0xlMVosTxwd/NMafIpqsC4dMbRYZ5KB981l/CX8tMnGsagTplj/RcG9BeRYmV6/J5m3w==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.24" + "@swc/types": "^0.1.25" }, "engines": { "node": ">=10" @@ -1039,16 +1178,16 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.13.5", - "@swc/core-darwin-x64": "1.13.5", - "@swc/core-linux-arm-gnueabihf": "1.13.5", - "@swc/core-linux-arm64-gnu": "1.13.5", - "@swc/core-linux-arm64-musl": "1.13.5", - "@swc/core-linux-x64-gnu": "1.13.5", - "@swc/core-linux-x64-musl": "1.13.5", - "@swc/core-win32-arm64-msvc": "1.13.5", - "@swc/core-win32-ia32-msvc": "1.13.5", - "@swc/core-win32-x64-msvc": "1.13.5" + "@swc/core-darwin-arm64": "1.14.0", + "@swc/core-darwin-x64": "1.14.0", + "@swc/core-linux-arm-gnueabihf": "1.14.0", + "@swc/core-linux-arm64-gnu": "1.14.0", + "@swc/core-linux-arm64-musl": "1.14.0", + "@swc/core-linux-x64-gnu": "1.14.0", + "@swc/core-linux-x64-musl": "1.14.0", + "@swc/core-win32-arm64-msvc": "1.14.0", + "@swc/core-win32-ia32-msvc": "1.14.0", + "@swc/core-win32-x64-msvc": "1.14.0" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" @@ -1060,9 +1199,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.13.5.tgz", - "integrity": "sha512-lKNv7SujeXvKn16gvQqUQI5DdyY8v7xcoO3k06/FJbHJS90zEwZdQiMNRiqpYw/orU543tPaWgz7cIYWhbopiQ==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.14.0.tgz", + "integrity": "sha512-uHPC8rlCt04nvYNczWzKVdgnRhxCa3ndKTBBbBpResOZsRmiwRAvByIGh599j+Oo6Z5eyTPrgY+XfJzVmXnN7Q==", "cpu": [ "arm64" ], @@ -1077,9 +1216,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.13.5.tgz", - "integrity": "sha512-ILd38Fg/w23vHb0yVjlWvQBoE37ZJTdlLHa8LRCFDdX4WKfnVBiblsCU9ar4QTMNdeTBEX9iUF4IrbNWhaF1Ng==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.14.0.tgz", + "integrity": "sha512-2SHrlpl68vtePRknv9shvM9YKKg7B9T13tcTg9aFCwR318QTYo+FzsKGmQSv9ox/Ua0Q2/5y2BNjieffJoo4nA==", "cpu": [ "x64" ], @@ -1094,9 +1233,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.13.5.tgz", - "integrity": "sha512-Q6eS3Pt8GLkXxqz9TAw+AUk9HpVJt8Uzm54MvPsqp2yuGmY0/sNaPPNVqctCX9fu/Nu8eaWUen0si6iEiCsazQ==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.14.0.tgz", + "integrity": "sha512-SMH8zn01dxt809svetnxpeg/jWdpi6dqHKO3Eb11u4OzU2PK7I5uKS6gf2hx5LlTbcJMFKULZiVwjlQLe8eqtg==", "cpu": [ "arm" ], @@ -1111,9 +1250,9 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.13.5.tgz", - "integrity": "sha512-aNDfeN+9af+y+M2MYfxCzCy/VDq7Z5YIbMqRI739o8Ganz6ST+27kjQFd8Y/57JN/hcnUEa9xqdS3XY7WaVtSw==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.14.0.tgz", + "integrity": "sha512-q2JRu2D8LVqGeHkmpVCljVNltG0tB4o4eYg+dElFwCS8l2Mnt9qurMCxIeo9mgoqz0ax+k7jWtIRHktnVCbjvQ==", "cpu": [ "arm64" ], @@ -1128,9 +1267,9 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.13.5.tgz", - "integrity": "sha512-9+ZxFN5GJag4CnYnq6apKTnnezpfJhCumyz0504/JbHLo+Ue+ZtJnf3RhyA9W9TINtLE0bC4hKpWi8ZKoETyOQ==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.14.0.tgz", + "integrity": "sha512-uofpVoPCEUjYIv454ZEZ3sLgMD17nIwlz2z7bsn7rl301Kt/01umFA7MscUovFfAK2IRGck6XB+uulMu6aFhKQ==", "cpu": [ "arm64" ], @@ -1145,9 +1284,9 @@ } }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.13.5.tgz", - "integrity": "sha512-WD530qvHrki8Ywt/PloKUjaRKgstQqNGvmZl54g06kA+hqtSE2FTG9gngXr3UJxYu/cNAjJYiBifm7+w4nbHbA==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.14.0.tgz", + "integrity": "sha512-quTTx1Olm05fBfv66DEBuOsOgqdypnZ/1Bh3yGXWY7ANLFeeRpCDZpljD9BSjdsNdPOlwJmEUZXMHtGm3v1TZQ==", "cpu": [ "x64" ], @@ -1162,9 +1301,9 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.13.5.tgz", - "integrity": "sha512-Luj8y4OFYx4DHNQTWjdIuKTq2f5k6uSXICqx+FSabnXptaOBAbJHNbHT/06JZh6NRUouaf0mYXN0mcsqvkhd7Q==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.14.0.tgz", + "integrity": "sha512-caaNAu+aIqT8seLtCf08i8C3/UC5ttQujUjejhMcuS1/LoCKtNiUs4VekJd2UGt+pyuuSrQ6dKl8CbCfWvWeXw==", "cpu": [ "x64" ], @@ -1179,9 +1318,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.13.5.tgz", - "integrity": "sha512-cZ6UpumhF9SDJvv4DA2fo9WIzlNFuKSkZpZmPG1c+4PFSEMy5DFOjBSllCvnqihCabzXzpn6ykCwBmHpy31vQw==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.14.0.tgz", + "integrity": "sha512-EeW3jFlT3YNckJ6V/JnTfGcX7UHGyh6/AiCPopZ1HNaGiXVCKHPpVQZicmtyr/UpqxCXLrTgjHOvyMke7YN26A==", "cpu": [ "arm64" ], @@ -1196,9 +1335,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.13.5.tgz", - "integrity": "sha512-C5Yi/xIikrFUzZcyGj9L3RpKljFvKiDMtyDzPKzlsDrKIw2EYY+bF88gB6oGY5RGmv4DAX8dbnpRAqgFD0FMEw==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.14.0.tgz", + "integrity": "sha512-dPai3KUIcihV5hfoO4QNQF5HAaw8+2bT7dvi8E5zLtecW2SfL3mUZipzampXq5FHll0RSCLzlrXnSx+dBRZIIQ==", "cpu": [ "ia32" ], @@ -1213,9 +1352,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.13.5.tgz", - "integrity": "sha512-YrKdMVxbYmlfybCSbRtrilc6UA8GF5aPmGKBdPvjrarvsmf4i7ZHGCEnLtfOMd3Lwbs2WUZq3WdMbozYeLU93Q==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.14.0.tgz", + "integrity": "sha512-nm+JajGrTqUA6sEHdghDlHMNfH1WKSiuvljhdmBACW4ta4LC3gKurX2qZuiBARvPkephW9V/i5S8QPY1PzFEqg==", "cpu": [ "x64" ], @@ -1247,13 +1386,14 @@ } }, "node_modules/@types/chai": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", - "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", "dependencies": { - "@types/deep-eql": "*" + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" } }, "node_modules/@types/deep-eql": { @@ -1278,32 +1418,30 @@ "license": "MIT" }, "node_modules/@vitest/coverage-v8": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", - "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.5.tgz", + "integrity": "sha512-Yn5Dx0UVvllE3uatQw+ftObWtM/TjAOdbd8WvygaR04iyFXdNmtvZ/nJ2/JndyzfPQtbAWw0F+GJY5+lgM/7qg==", "dev": true, "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^1.0.2", - "ast-v8-to-istanbul": "^0.3.3", - "debug": "^4.4.1", + "@vitest/utils": "4.0.5", + "ast-v8-to-istanbul": "^0.3.5", + "debug": "^4.4.3", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", - "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.17", + "istanbul-reports": "^3.2.0", "magicast": "^0.3.5", "std-env": "^3.9.0", - "test-exclude": "^7.0.1", - "tinyrainbow": "^2.0.0" + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "3.2.4", - "vitest": "3.2.4" + "@vitest/browser": "4.0.5", + "vitest": "4.0.5" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1312,39 +1450,40 @@ } }, "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.5.tgz", + "integrity": "sha512-DJctLVlKoddvP/G389oGmKWNG6GD9frm2FPXARziU80Rjo7SIYxQzb2YFzmQ4fVD3Q5utUYY8nUmWrqsuIlIXQ==", "dev": true, "license": "MIT", "dependencies": { + "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" + "@vitest/spy": "4.0.5", + "@vitest/utils": "4.0.5", + "chai": "^6.0.1", + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.5.tgz", + "integrity": "sha512-iYHIy72LfbK+mL5W8zXROp6oOcJKXWeKcNjcPPsqoa18qIEDrhB6/Z08o0wRajTd6SSSDNw8NCSIHVNOMpz0mw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.4", + "@vitest/spy": "4.0.5", "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" + "magic-string": "^0.30.19" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0-0" }, "peerDependenciesMeta": { "msw": { @@ -1356,42 +1495,41 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.5.tgz", + "integrity": "sha512-t1T/sSdsYyNc5AZl0EMeD0jW9cpJe2cODP0R++ZQe1kTkpgrwEfxGFR/yCG4w8ZybizbXRTHU7lE8sTDD/QsGw==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^2.0.0" + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.5.tgz", + "integrity": "sha512-CQVVe+YEeKSiFBD5gBAmRDQglm4PnMBYzeTmt06t5iWtsUN9StQeeKhYCea/oaqBYilf8sARG6fSctUcEL/UmQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" + "@vitest/utils": "4.0.5", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.5.tgz", + "integrity": "sha512-jfmSAeR6xYNEvcD+/RxFGA1bzpqHtkVhgxo2cxXia+Q3xX7m6GpZij07rz+WyQcA/xEGn4eIS1OItkMyWsGBmQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", + "@vitest/pretty-format": "4.0.5", + "magic-string": "^0.30.19", "pathe": "^2.0.3" }, "funding": { @@ -1399,28 +1537,24 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.5.tgz", + "integrity": "sha512-TUmVQpAQign7r8+EnZsgTF3vY9BdGofTUge1rGNbnHn2IN3FChiQoT9lrPz7A7AVUZJU2LAZXl4v66HhsNMhoA==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.5.tgz", + "integrity": "sha512-V5RndUgCB5/AfNvK9zxGCrRs99IrPYtMTIdUzJMMFs9nrmE5JXExIEfjVtUteyTRiLfCm+dCRMHf/Uu7Mm8/dg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" + "@vitest/pretty-format": "4.0.5", + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" @@ -1631,18 +1765,11 @@ } }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.0.tgz", + "integrity": "sha512-aUTnJc/JipRzJrNADXVvpVqi6CO0dn3nx4EVPxijri+fj3LUUDyZQOgVeW54Ob3Y1Xh9Iz8f+CgaCl8v0mn9bA==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } @@ -1693,16 +1820,6 @@ "node": ">=8" } }, - "node_modules/check-error": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", - "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -1954,16 +2071,6 @@ } } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2066,6 +2173,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -2598,6 +2706,20 @@ "dev": true, "license": "MIT" }, + "node_modules/jsr": { + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/jsr/-/jsr-0.13.5.tgz", + "integrity": "sha512-qQP20ZcG28pYes7bCq3uuvixl1TL1EpJzwLPfoQadSyWk9j2AID66qhW8+aXpRDRFDvDkXFnONsSRhpnnQAupg==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-stream-zip": "^1.15.0", + "semiver": "^1.1.0" + }, + "bin": { + "jsr": "dist/bin.js" + } + }, "node_modules/libphonenumber-js": { "version": "1.12.17", "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.17.tgz", @@ -2649,13 +2771,6 @@ "dev": true, "license": "MIT" }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -2821,6 +2936,20 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-stream-zip": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.15.0.tgz", + "integrity": "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/antelle" + } + }, "node_modules/nodemon": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", @@ -3009,16 +3138,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3032,6 +3151,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3081,6 +3201,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3243,6 +3364,16 @@ "tslib": "^2.1.0" } }, + "node_modules/semiver": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/semiver/-/semiver-1.1.0.tgz", + "integrity": "sha512-QNI2ChmuioGC1/xjyYwyZYADILWyW6AmS1UH6gDj/SFUUUS4MBAWs/7mxnkRPc/F4iHezDP+O8t0dO8WHiEOdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/semver": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", @@ -3474,19 +3605,6 @@ "node": ">=8" } }, - "node_modules/strip-literal": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.0.0.tgz", - "integrity": "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, "node_modules/sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", @@ -3542,6 +3660,7 @@ "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -3555,21 +3674,6 @@ "node": ">=10" } }, - "node_modules/test-exclude": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", - "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^9.0.4" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -3624,30 +3728,10 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", "dev": true, "license": "MIT", "engines": { @@ -3704,12 +3788,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/ts-pattern": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.8.0.tgz", - "integrity": "sha512-kIjN2qmWiHnhgr5DAkAafF9fwb0T5OhMVSWrm8XEdTFnX6+wfXwYOFjeF86UZ54vduqiR7BfqScFmXSzSaH8oA==", - "license": "MIT" - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -3785,11 +3863,12 @@ } }, "node_modules/typescript": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", - "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3823,11 +3902,12 @@ } }, "node_modules/vite": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.6.tgz", - "integrity": "sha512-SRYIB8t/isTwNn8vMB3MR6E+EQZM/WG1aKmmIUCfDXfVvKfc20ZpamngWHKzAmmu9ppsgxsg4b2I7c90JZudIQ==", + "version": "7.1.12", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", + "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -3897,65 +3977,40 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.5.tgz", + "integrity": "sha512-4H+J28MI5oeYgGg3h5BFSkQ1g/2GKK1IR8oorH3a6EQQbb7CwjbnyBjH4PGxw9/6vpwAPNzaeUMp4Js4WJmdXQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", + "@vitest/expect": "4.0.5", + "@vitest/mocker": "4.0.5", + "@vitest/pretty-format": "4.0.5", + "@vitest/runner": "4.0.5", + "@vitest/snapshot": "4.0.5", + "@vitest/spy": "4.0.5", + "@vitest/utils": "4.0.5", + "debug": "^4.4.3", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.19", "pathe": "^2.0.3", - "picomatch": "^4.0.2", + "picomatch": "^4.0.3", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -3963,9 +4018,11 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.5", + "@vitest/browser-preview": "4.0.5", + "@vitest/browser-webdriverio": "4.0.5", + "@vitest/ui": "4.0.5", "happy-dom": "*", "jsdom": "*" }, @@ -3979,7 +4036,13 @@ "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { "optional": true }, "@vitest/ui": { @@ -4248,9 +4311,9 @@ } }, "node_modules/zod": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz", - "integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "dev": true, "license": "MIT", "funding": { diff --git a/package.json b/package.json index 54d9e1f..6494679 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sqm", - "version": "0.6.4", + "version": "0.7.1", "description": "A lightweight and flexible query maker library for building raw SQL queries in JavaScript.", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -9,18 +9,45 @@ ".": { "import": "./dist/index.mjs", "require": "./dist/index.js" + }, + "./queryUtils": { + "import": "./dist/queryUtils/index.mjs", + "require": "./dist/queryUtils/index.js" + }, + "./types": { + "import": "./dist/types/index.mjs", + "require": "./dist/types/index.js" + }, + "./ddl": { + "import": "./dist/queryKinds/ddl/index.mjs", + "require": "./dist/queryKinds/ddl/index.js" + }, + "./dml": { + "import": "./dist/queryKinds/dml/index.mjs", + "require": "./dist/queryKinds/dml/index.js" + }, + "./ddl/table": { + "import": "./dist/queryKinds/ddl/table/index.mjs", + "require": "./dist/queryKinds/ddl/table/index.js" } }, "scripts": { "build": "tsup --config tsup.config.ts --format esm", "build:dev": "tsup --config tsup.config.ts --watch --format esm", "start:dev": "nodemon --watch 'dist' --exec 'wait-on dist/index.mjs && node dist/index.mjs'", - "dev": "concurrently \"npm run build:dev\" \"npm run start:dev\"", + "start:dev:deno": "nodemon --watch 'dist' --exec 'wait-on dist/index.mjs && deno run --allow-read dist/index.mjs'", + "start:dev:bun": "nodemon --watch 'dist' --exec 'wait-on dist/index.mjs && bun run dist/index.mjs'", + "dev": "concurrently \"pnpm run build:dev\" \"pnpm run start:dev\"", + "dev:deno": "concurrently \"pnpm run build:dev\" \"pnpm run start:dev:deno\"", + "dev:bun": "concurrently \"pnpm run build:dev\" \"pnpm run start:dev:bun\"", "start": "node dist/index.js", - "build:prod": "tsup --config tsup.config.ts", + "build:prod:everything": "tsup --config tsup.config.ts", + "build:prod:qjs": "tsup --config tsup.config.qjs.ts", + "build:prod": "concurrently \"pnpm run build:prod:everything\" \"pnpm run build:prod:qjs\"", "test": "vitest run", "test:watch": "vitest", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "publish:jsr:dry": "jsr publish --dry-run --allow-dirty" }, "files": [ "dist", @@ -40,7 +67,7 @@ ], "repository": { "type": "git", - "url": "https://github.com/NickRMD/queryMaker.git" + "url": "git+https://github.com/NickRMD/queryMaker.git" }, "homepage": "https://github.com/NickRMD/queryMaker#readme", "bugs": { @@ -49,22 +76,21 @@ "author": "Nicolas R. M. Dias ", "license": "Apache-2.0", "devDependencies": { - "@swc/core": "^1.13.5", - "@vitest/coverage-v8": "^3.2.4", + "@biomejs/biome": "^2.3.2", + "@swc/core": "^1.14.0", + "@vitest/coverage-v8": "^4.0.5", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "concurrently": "^9.2.1", + "jsr": "^0.13.5", "nodemon": "^3.1.10", "terser": "^5.44.0", "tslib": "^2.8.1", "tsup": "^8.5.0", - "typescript": "^5.9.2", - "vitest": "^3.2.4", + "typescript": "^5.9.3", + "vitest": "^4.0.5", "wait-on": "^9.0.1", - "zod": "^4.1.11" - }, - "dependencies": { - "ts-pattern": "^5.8.0" + "zod": "^4.1.12" }, "peerDependencies": { "class-transformer": ">=0.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62df6ad..a871a06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,17 +7,16 @@ settings: importers: .: - dependencies: - ts-pattern: - specifier: ^5.8.0 - version: 5.8.0 devDependencies: + '@biomejs/biome': + specifier: ^2.3.2 + version: 2.3.2 '@swc/core': - specifier: ^1.13.5 - version: 1.13.5 + specifier: ^1.14.0 + version: 1.14.0 '@vitest/coverage-v8': - specifier: ^3.2.4 - version: 3.2.4(vitest@3.2.4(terser@5.44.0)) + specifier: ^4.0.5 + version: 4.0.5(vitest@4.0.5(terser@5.44.0)) class-transformer: specifier: ^0.5.1 version: 0.5.1 @@ -27,6 +26,9 @@ importers: concurrently: specifier: ^9.2.1 version: 9.2.1 + jsr: + specifier: ^0.13.5 + version: 0.13.5 nodemon: specifier: ^3.1.10 version: 3.1.10 @@ -38,26 +40,22 @@ importers: version: 2.8.1 tsup: specifier: ^8.5.0 - version: 8.5.0(@swc/core@1.13.5)(postcss@8.5.6)(typescript@5.9.2) + version: 8.5.0(@swc/core@1.14.0)(postcss@8.5.6)(typescript@5.9.3) typescript: - specifier: ^5.9.2 - version: 5.9.2 + specifier: ^5.9.3 + version: 5.9.3 vitest: - specifier: ^3.2.4 - version: 3.2.4(terser@5.44.0) + specifier: ^4.0.5 + version: 4.0.5(terser@5.44.0) wait-on: specifier: ^9.0.1 version: 9.0.1 zod: - specifier: ^4.1.11 - version: 4.1.11 + specifier: ^4.1.12 + version: 4.1.12 packages: - '@ampproject/remapping@2.3.0': - resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} - engines: {node: '>=6.0.0'} - '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -79,6 +77,59 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@biomejs/biome@2.3.2': + resolution: {integrity: sha512-8e9tzamuDycx7fdrcJ/F/GDZ8SYukc5ud6tDicjjFqURKYFSWMl0H0iXNXZEGmcmNUmABgGuHThPykcM41INgg==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.3.2': + resolution: {integrity: sha512-4LECm4kc3If0JISai4c3KWQzukoUdpxy4fRzlrPcrdMSRFksR9ZoXK7JBcPuLBmd2SoT4/d7CQS33VnZpgBjew==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.3.2': + resolution: {integrity: sha512-jNMnfwHT4N3wi+ypRfMTjLGnDmKYGzxVr1EYAPBcauRcDnICFXN81wD6wxJcSUrLynoyyYCdfW6vJHS/IAoTDA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.3.2': + resolution: {integrity: sha512-2Zz4usDG1GTTPQnliIeNx6eVGGP2ry5vE/v39nT73a3cKN6t5H5XxjcEoZZh62uVZvED7hXXikclvI64vZkYqw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@2.3.2': + resolution: {integrity: sha512-amnqvk+gWybbQleRRq8TMe0rIv7GHss8mFJEaGuEZYWg1Tw14YKOkeo8h6pf1c+d3qR+JU4iT9KXnBKGON4klw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@2.3.2': + resolution: {integrity: sha512-gzB19MpRdTuOuLtPpFBGrV3Lq424gHyq2lFj8wfX9tvLMLdmA/R9C7k/mqBp/spcbWuHeIEKgEs3RviOPcWGBA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@2.3.2': + resolution: {integrity: sha512-8BG/vRAhFz1pmuyd24FQPhNeueLqPtwvZk6yblABY2gzL2H8fLQAF/Z2OPIc+BPIVPld+8cSiKY/KFh6k81xfA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@2.3.2': + resolution: {integrity: sha512-lCruqQlfWjhMlOdyf5pDHOxoNm4WoyY2vZ4YN33/nuZBRstVDuqPPjS0yBkbUlLEte11FbpW+wWSlfnZfSIZvg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.3.2': + resolution: {integrity: sha512-6Ee9P26DTb4D8sN9nXxgbi9Dw5vSOfH98M7UlmkjKB2vtUbrRqCbZiNfryGiwnPIpd6YUoTl7rLVD2/x1CyEHQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + '@esbuild/aix-ppc64@0.25.9': resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==} engines: {node: '>=18'} @@ -259,10 +310,6 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} - '@istanbuljs/schema@0.1.3': - resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} - engines: {node: '>=8'} - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -391,68 +438,68 @@ packages: '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} - '@swc/core-darwin-arm64@1.13.5': - resolution: {integrity: sha512-lKNv7SujeXvKn16gvQqUQI5DdyY8v7xcoO3k06/FJbHJS90zEwZdQiMNRiqpYw/orU543tPaWgz7cIYWhbopiQ==} + '@swc/core-darwin-arm64@1.14.0': + resolution: {integrity: sha512-uHPC8rlCt04nvYNczWzKVdgnRhxCa3ndKTBBbBpResOZsRmiwRAvByIGh599j+Oo6Z5eyTPrgY+XfJzVmXnN7Q==} engines: {node: '>=10'} cpu: [arm64] os: [darwin] - '@swc/core-darwin-x64@1.13.5': - resolution: {integrity: sha512-ILd38Fg/w23vHb0yVjlWvQBoE37ZJTdlLHa8LRCFDdX4WKfnVBiblsCU9ar4QTMNdeTBEX9iUF4IrbNWhaF1Ng==} + '@swc/core-darwin-x64@1.14.0': + resolution: {integrity: sha512-2SHrlpl68vtePRknv9shvM9YKKg7B9T13tcTg9aFCwR318QTYo+FzsKGmQSv9ox/Ua0Q2/5y2BNjieffJoo4nA==} engines: {node: '>=10'} cpu: [x64] os: [darwin] - '@swc/core-linux-arm-gnueabihf@1.13.5': - resolution: {integrity: sha512-Q6eS3Pt8GLkXxqz9TAw+AUk9HpVJt8Uzm54MvPsqp2yuGmY0/sNaPPNVqctCX9fu/Nu8eaWUen0si6iEiCsazQ==} + '@swc/core-linux-arm-gnueabihf@1.14.0': + resolution: {integrity: sha512-SMH8zn01dxt809svetnxpeg/jWdpi6dqHKO3Eb11u4OzU2PK7I5uKS6gf2hx5LlTbcJMFKULZiVwjlQLe8eqtg==} engines: {node: '>=10'} cpu: [arm] os: [linux] - '@swc/core-linux-arm64-gnu@1.13.5': - resolution: {integrity: sha512-aNDfeN+9af+y+M2MYfxCzCy/VDq7Z5YIbMqRI739o8Ganz6ST+27kjQFd8Y/57JN/hcnUEa9xqdS3XY7WaVtSw==} + '@swc/core-linux-arm64-gnu@1.14.0': + resolution: {integrity: sha512-q2JRu2D8LVqGeHkmpVCljVNltG0tB4o4eYg+dElFwCS8l2Mnt9qurMCxIeo9mgoqz0ax+k7jWtIRHktnVCbjvQ==} engines: {node: '>=10'} cpu: [arm64] os: [linux] - '@swc/core-linux-arm64-musl@1.13.5': - resolution: {integrity: sha512-9+ZxFN5GJag4CnYnq6apKTnnezpfJhCumyz0504/JbHLo+Ue+ZtJnf3RhyA9W9TINtLE0bC4hKpWi8ZKoETyOQ==} + '@swc/core-linux-arm64-musl@1.14.0': + resolution: {integrity: sha512-uofpVoPCEUjYIv454ZEZ3sLgMD17nIwlz2z7bsn7rl301Kt/01umFA7MscUovFfAK2IRGck6XB+uulMu6aFhKQ==} engines: {node: '>=10'} cpu: [arm64] os: [linux] - '@swc/core-linux-x64-gnu@1.13.5': - resolution: {integrity: sha512-WD530qvHrki8Ywt/PloKUjaRKgstQqNGvmZl54g06kA+hqtSE2FTG9gngXr3UJxYu/cNAjJYiBifm7+w4nbHbA==} + '@swc/core-linux-x64-gnu@1.14.0': + resolution: {integrity: sha512-quTTx1Olm05fBfv66DEBuOsOgqdypnZ/1Bh3yGXWY7ANLFeeRpCDZpljD9BSjdsNdPOlwJmEUZXMHtGm3v1TZQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] - '@swc/core-linux-x64-musl@1.13.5': - resolution: {integrity: sha512-Luj8y4OFYx4DHNQTWjdIuKTq2f5k6uSXICqx+FSabnXptaOBAbJHNbHT/06JZh6NRUouaf0mYXN0mcsqvkhd7Q==} + '@swc/core-linux-x64-musl@1.14.0': + resolution: {integrity: sha512-caaNAu+aIqT8seLtCf08i8C3/UC5ttQujUjejhMcuS1/LoCKtNiUs4VekJd2UGt+pyuuSrQ6dKl8CbCfWvWeXw==} engines: {node: '>=10'} cpu: [x64] os: [linux] - '@swc/core-win32-arm64-msvc@1.13.5': - resolution: {integrity: sha512-cZ6UpumhF9SDJvv4DA2fo9WIzlNFuKSkZpZmPG1c+4PFSEMy5DFOjBSllCvnqihCabzXzpn6ykCwBmHpy31vQw==} + '@swc/core-win32-arm64-msvc@1.14.0': + resolution: {integrity: sha512-EeW3jFlT3YNckJ6V/JnTfGcX7UHGyh6/AiCPopZ1HNaGiXVCKHPpVQZicmtyr/UpqxCXLrTgjHOvyMke7YN26A==} engines: {node: '>=10'} cpu: [arm64] os: [win32] - '@swc/core-win32-ia32-msvc@1.13.5': - resolution: {integrity: sha512-C5Yi/xIikrFUzZcyGj9L3RpKljFvKiDMtyDzPKzlsDrKIw2EYY+bF88gB6oGY5RGmv4DAX8dbnpRAqgFD0FMEw==} + '@swc/core-win32-ia32-msvc@1.14.0': + resolution: {integrity: sha512-dPai3KUIcihV5hfoO4QNQF5HAaw8+2bT7dvi8E5zLtecW2SfL3mUZipzampXq5FHll0RSCLzlrXnSx+dBRZIIQ==} engines: {node: '>=10'} cpu: [ia32] os: [win32] - '@swc/core-win32-x64-msvc@1.13.5': - resolution: {integrity: sha512-YrKdMVxbYmlfybCSbRtrilc6UA8GF5aPmGKBdPvjrarvsmf4i7ZHGCEnLtfOMd3Lwbs2WUZq3WdMbozYeLU93Q==} + '@swc/core-win32-x64-msvc@1.14.0': + resolution: {integrity: sha512-nm+JajGrTqUA6sEHdghDlHMNfH1WKSiuvljhdmBACW4ta4LC3gKurX2qZuiBARvPkephW9V/i5S8QPY1PzFEqg==} engines: {node: '>=10'} cpu: [x64] os: [win32] - '@swc/core@1.13.5': - resolution: {integrity: sha512-WezcBo8a0Dg2rnR82zhwoR6aRNxeTGfK5QCD6TQ+kg3xx/zNT02s/0o+81h/3zhvFSB24NtqEr8FTw88O5W/JQ==} + '@swc/core@1.14.0': + resolution: {integrity: sha512-oExhY90bes5pDTVrei0xlMVosTxwd/NMafIpqsC4dMbRYZ5KB981l/CX8tMnGsagTplj/RcG9BeRYmV6/J5m3w==} engines: {node: '>=10'} peerDependencies: '@swc/helpers': '>=0.5.17' @@ -478,43 +525,43 @@ packages: '@types/validator@13.15.3': resolution: {integrity: sha512-7bcUmDyS6PN3EuD9SlGGOxM77F8WLVsrwkxyWxKnxzmXoequ6c7741QBrANq6htVRGOITJ7z72mTP6Z4XyuG+Q==} - '@vitest/coverage-v8@3.2.4': - resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} + '@vitest/coverage-v8@4.0.5': + resolution: {integrity: sha512-Yn5Dx0UVvllE3uatQw+ftObWtM/TjAOdbd8WvygaR04iyFXdNmtvZ/nJ2/JndyzfPQtbAWw0F+GJY5+lgM/7qg==} peerDependencies: - '@vitest/browser': 3.2.4 - vitest: 3.2.4 + '@vitest/browser': 4.0.5 + vitest: 4.0.5 peerDependenciesMeta: '@vitest/browser': optional: true - '@vitest/expect@3.2.4': - resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + '@vitest/expect@4.0.5': + resolution: {integrity: sha512-DJctLVlKoddvP/G389oGmKWNG6GD9frm2FPXARziU80Rjo7SIYxQzb2YFzmQ4fVD3Q5utUYY8nUmWrqsuIlIXQ==} - '@vitest/mocker@3.2.4': - resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + '@vitest/mocker@4.0.5': + resolution: {integrity: sha512-iYHIy72LfbK+mL5W8zXROp6oOcJKXWeKcNjcPPsqoa18qIEDrhB6/Z08o0wRajTd6SSSDNw8NCSIHVNOMpz0mw==} peerDependencies: msw: ^2.4.9 - vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + vite: ^6.0.0 || ^7.0.0-0 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@3.2.4': - resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/pretty-format@4.0.5': + resolution: {integrity: sha512-t1T/sSdsYyNc5AZl0EMeD0jW9cpJe2cODP0R++ZQe1kTkpgrwEfxGFR/yCG4w8ZybizbXRTHU7lE8sTDD/QsGw==} - '@vitest/runner@3.2.4': - resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + '@vitest/runner@4.0.5': + resolution: {integrity: sha512-CQVVe+YEeKSiFBD5gBAmRDQglm4PnMBYzeTmt06t5iWtsUN9StQeeKhYCea/oaqBYilf8sARG6fSctUcEL/UmQ==} - '@vitest/snapshot@3.2.4': - resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + '@vitest/snapshot@4.0.5': + resolution: {integrity: sha512-jfmSAeR6xYNEvcD+/RxFGA1bzpqHtkVhgxo2cxXia+Q3xX7m6GpZij07rz+WyQcA/xEGn4eIS1OItkMyWsGBmQ==} - '@vitest/spy@3.2.4': - resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/spy@4.0.5': + resolution: {integrity: sha512-TUmVQpAQign7r8+EnZsgTF3vY9BdGofTUge1rGNbnHn2IN3FChiQoT9lrPz7A7AVUZJU2LAZXl4v66HhsNMhoA==} - '@vitest/utils@3.2.4': - resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vitest/utils@4.0.5': + resolution: {integrity: sha512-V5RndUgCB5/AfNvK9zxGCrRs99IrPYtMTIdUzJMMFs9nrmE5JXExIEfjVtUteyTRiLfCm+dCRMHf/Uu7Mm8/dg==} acorn@8.15.0: resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} @@ -544,10 +591,6 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} - assertion-error@2.0.1: - resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} - engines: {node: '>=12'} - ast-v8-to-istanbul@0.3.5: resolution: {integrity: sha512-9SdXjNheSiE8bALAQCQQuT6fgQaoxJh7IRYrRGZ8/9nv8WhJeC1aXAwN8TbaOssGOukUvyvnkgD9+Yuykvl1aA==} @@ -591,18 +634,14 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} - chai@5.3.3: - resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + chai@6.2.0: + resolution: {integrity: sha512-aUTnJc/JipRzJrNADXVvpVqi6CO0dn3nx4EVPxijri+fj3LUUDyZQOgVeW54Ob3Y1Xh9Iz8f+CgaCl8v0mn9bA==} engines: {node: '>=18'} chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} - check-error@2.1.1: - resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} - engines: {node: '>= 16'} - chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -667,9 +706,14 @@ packages: supports-color: optional: true - deep-eql@5.0.2: - resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} - engines: {node: '>=6'} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} @@ -867,6 +911,10 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + jsr@0.13.5: + resolution: {integrity: sha512-qQP20ZcG28pYes7bCq3uuvixl1TL1EpJzwLPfoQadSyWk9j2AID66qhW8+aXpRDRFDvDkXFnONsSRhpnnQAupg==} + hasBin: true + libphonenumber-js@1.12.17: resolution: {integrity: sha512-bsxi8FoceAYR/bjHcLYc2ShJ/aVAzo5jaxAYiMHF0BD+NTp47405CGuPNKYpw+lHadN9k/ClFGc9X5vaZswIrA==} @@ -887,9 +935,6 @@ packages: lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - loupe@3.2.1: - resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} - lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -943,6 +988,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + node-stream-zip@1.15.0: + resolution: {integrity: sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==} + engines: {node: '>=0.12.0'} + nodemon@3.1.10: resolution: {integrity: sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==} engines: {node: '>=10'} @@ -970,10 +1019,6 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - pathval@2.0.1: - resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} - engines: {node: '>= 14.16'} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1048,6 +1093,10 @@ packages: rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + semiver@1.1.0: + resolution: {integrity: sha512-QNI2ChmuioGC1/xjyYwyZYADILWyW6AmS1UH6gDj/SFUUUS4MBAWs/7mxnkRPc/F4iHezDP+O8t0dO8WHiEOdg==} + engines: {node: '>=6'} + semver@7.7.2: resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} engines: {node: '>=10'} @@ -1114,9 +1163,6 @@ packages: resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} - strip-literal@3.0.0: - resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} - sucrase@3.35.0: resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} engines: {node: '>=16 || 14 >=14.17'} @@ -1139,10 +1185,6 @@ packages: engines: {node: '>=10'} hasBin: true - test-exclude@7.0.1: - resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} - engines: {node: '>=18'} - thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -1160,16 +1202,8 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} - tinypool@1.1.1: - resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} - engines: {node: ^18.0.0 || >=20.0.0} - - tinyrainbow@2.0.0: - resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} - engines: {node: '>=14.0.0'} - - tinyspy@4.0.4: - resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} to-regex-range@5.0.1: @@ -1190,9 +1224,6 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - ts-pattern@5.8.0: - resolution: {integrity: sha512-kIjN2qmWiHnhgr5DAkAafF9fwb0T5OhMVSWrm8XEdTFnX6+wfXwYOFjeF86UZ54vduqiR7BfqScFmXSzSaH8oA==} - tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1215,8 +1246,8 @@ packages: typescript: optional: true - typescript@5.9.2: - resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true @@ -1230,11 +1261,6 @@ packages: resolution: {integrity: sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==} engines: {node: '>= 0.10'} - vite-node@3.2.4: - resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - vite@7.1.6: resolution: {integrity: sha512-SRYIB8t/isTwNn8vMB3MR6E+EQZM/WG1aKmmIUCfDXfVvKfc20ZpamngWHKzAmmu9ppsgxsg4b2I7c90JZudIQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1275,16 +1301,18 @@ packages: yaml: optional: true - vitest@3.2.4: - resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + vitest@4.0.5: + resolution: {integrity: sha512-4H+J28MI5oeYgGg3h5BFSkQ1g/2GKK1IR8oorH3a6EQQbb7CwjbnyBjH4PGxw9/6vpwAPNzaeUMp4Js4WJmdXQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@types/debug': ^4.1.12 - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - '@vitest/browser': 3.2.4 - '@vitest/ui': 3.2.4 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.5 + '@vitest/browser-preview': 4.0.5 + '@vitest/browser-webdriverio': 4.0.5 + '@vitest/ui': 4.0.5 happy-dom: '*' jsdom: '*' peerDependenciesMeta: @@ -1294,7 +1322,11 @@ packages: optional: true '@types/node': optional: true - '@vitest/browser': + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': optional: true '@vitest/ui': optional: true @@ -1344,16 +1376,11 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} - zod@4.1.11: - resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==} + zod@4.1.12: + resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} snapshots: - '@ampproject/remapping@2.3.0': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.27.1': {} @@ -1369,6 +1396,41 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@biomejs/biome@2.3.2': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.3.2 + '@biomejs/cli-darwin-x64': 2.3.2 + '@biomejs/cli-linux-arm64': 2.3.2 + '@biomejs/cli-linux-arm64-musl': 2.3.2 + '@biomejs/cli-linux-x64': 2.3.2 + '@biomejs/cli-linux-x64-musl': 2.3.2 + '@biomejs/cli-win32-arm64': 2.3.2 + '@biomejs/cli-win32-x64': 2.3.2 + + '@biomejs/cli-darwin-arm64@2.3.2': + optional: true + + '@biomejs/cli-darwin-x64@2.3.2': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.3.2': + optional: true + + '@biomejs/cli-linux-arm64@2.3.2': + optional: true + + '@biomejs/cli-linux-x64-musl@2.3.2': + optional: true + + '@biomejs/cli-linux-x64@2.3.2': + optional: true + + '@biomejs/cli-win32-arm64@2.3.2': + optional: true + + '@biomejs/cli-win32-x64@2.3.2': + optional: true + '@esbuild/aix-ppc64@0.25.9': optional: true @@ -1472,8 +1534,6 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 - '@istanbuljs/schema@0.1.3': {} - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1561,51 +1621,51 @@ snapshots: '@standard-schema/spec@1.0.0': {} - '@swc/core-darwin-arm64@1.13.5': + '@swc/core-darwin-arm64@1.14.0': optional: true - '@swc/core-darwin-x64@1.13.5': + '@swc/core-darwin-x64@1.14.0': optional: true - '@swc/core-linux-arm-gnueabihf@1.13.5': + '@swc/core-linux-arm-gnueabihf@1.14.0': optional: true - '@swc/core-linux-arm64-gnu@1.13.5': + '@swc/core-linux-arm64-gnu@1.14.0': optional: true - '@swc/core-linux-arm64-musl@1.13.5': + '@swc/core-linux-arm64-musl@1.14.0': optional: true - '@swc/core-linux-x64-gnu@1.13.5': + '@swc/core-linux-x64-gnu@1.14.0': optional: true - '@swc/core-linux-x64-musl@1.13.5': + '@swc/core-linux-x64-musl@1.14.0': optional: true - '@swc/core-win32-arm64-msvc@1.13.5': + '@swc/core-win32-arm64-msvc@1.14.0': optional: true - '@swc/core-win32-ia32-msvc@1.13.5': + '@swc/core-win32-ia32-msvc@1.14.0': optional: true - '@swc/core-win32-x64-msvc@1.13.5': + '@swc/core-win32-x64-msvc@1.14.0': optional: true - '@swc/core@1.13.5': + '@swc/core@1.14.0': dependencies: '@swc/counter': 0.1.3 '@swc/types': 0.1.25 optionalDependencies: - '@swc/core-darwin-arm64': 1.13.5 - '@swc/core-darwin-x64': 1.13.5 - '@swc/core-linux-arm-gnueabihf': 1.13.5 - '@swc/core-linux-arm64-gnu': 1.13.5 - '@swc/core-linux-arm64-musl': 1.13.5 - '@swc/core-linux-x64-gnu': 1.13.5 - '@swc/core-linux-x64-musl': 1.13.5 - '@swc/core-win32-arm64-msvc': 1.13.5 - '@swc/core-win32-ia32-msvc': 1.13.5 - '@swc/core-win32-x64-msvc': 1.13.5 + '@swc/core-darwin-arm64': 1.14.0 + '@swc/core-darwin-x64': 1.14.0 + '@swc/core-linux-arm-gnueabihf': 1.14.0 + '@swc/core-linux-arm64-gnu': 1.14.0 + '@swc/core-linux-arm64-musl': 1.14.0 + '@swc/core-linux-x64-gnu': 1.14.0 + '@swc/core-linux-x64-musl': 1.14.0 + '@swc/core-win32-arm64-msvc': 1.14.0 + '@swc/core-win32-ia32-msvc': 1.14.0 + '@swc/core-win32-x64-msvc': 1.14.0 '@swc/counter@0.1.3': {} @@ -1623,66 +1683,61 @@ snapshots: '@types/validator@13.15.3': {} - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(terser@5.44.0))': + '@vitest/coverage-v8@4.0.5(vitest@4.0.5(terser@5.44.0))': dependencies: - '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.0.5 ast-v8-to-istanbul: 0.3.5 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.3 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-lib-source-maps: 5.0.6 istanbul-reports: 3.2.0 - magic-string: 0.30.19 magicast: 0.3.5 std-env: 3.9.0 - test-exclude: 7.0.1 - tinyrainbow: 2.0.0 - vitest: 3.2.4(terser@5.44.0) + tinyrainbow: 3.0.3 + vitest: 4.0.5(terser@5.44.0) transitivePeerDependencies: - supports-color - '@vitest/expect@3.2.4': + '@vitest/expect@4.0.5': dependencies: + '@standard-schema/spec': 1.0.0 '@types/chai': 5.2.2 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 - chai: 5.3.3 - tinyrainbow: 2.0.0 + '@vitest/spy': 4.0.5 + '@vitest/utils': 4.0.5 + chai: 6.2.0 + tinyrainbow: 3.0.3 - '@vitest/mocker@3.2.4(vite@7.1.6(terser@5.44.0))': + '@vitest/mocker@4.0.5(vite@7.1.6(terser@5.44.0))': dependencies: - '@vitest/spy': 3.2.4 + '@vitest/spy': 4.0.5 estree-walker: 3.0.3 magic-string: 0.30.19 optionalDependencies: vite: 7.1.6(terser@5.44.0) - '@vitest/pretty-format@3.2.4': + '@vitest/pretty-format@4.0.5': dependencies: - tinyrainbow: 2.0.0 + tinyrainbow: 3.0.3 - '@vitest/runner@3.2.4': + '@vitest/runner@4.0.5': dependencies: - '@vitest/utils': 3.2.4 + '@vitest/utils': 4.0.5 pathe: 2.0.3 - strip-literal: 3.0.0 - '@vitest/snapshot@3.2.4': + '@vitest/snapshot@4.0.5': dependencies: - '@vitest/pretty-format': 3.2.4 + '@vitest/pretty-format': 4.0.5 magic-string: 0.30.19 pathe: 2.0.3 - '@vitest/spy@3.2.4': - dependencies: - tinyspy: 4.0.4 + '@vitest/spy@4.0.5': {} - '@vitest/utils@3.2.4': + '@vitest/utils@4.0.5': dependencies: - '@vitest/pretty-format': 3.2.4 - loupe: 3.2.1 - tinyrainbow: 2.0.0 + '@vitest/pretty-format': 4.0.5 + tinyrainbow: 3.0.3 acorn@8.15.0: {} @@ -1703,8 +1758,6 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 - assertion-error@2.0.1: {} - ast-v8-to-istanbul@0.3.5: dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -1752,21 +1805,13 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 - chai@5.3.3: - dependencies: - assertion-error: 2.0.1 - check-error: 2.1.1 - deep-eql: 5.0.2 - loupe: 3.2.1 - pathval: 2.0.1 + chai@6.2.0: {} chalk@4.1.2: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 - check-error@2.1.1: {} - chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -1838,7 +1883,9 @@ snapshots: optionalDependencies: supports-color: 5.5.0 - deep-eql@5.0.2: {} + debug@4.4.3: + dependencies: + ms: 2.1.3 delayed-stream@1.0.0: {} @@ -2022,7 +2069,7 @@ snapshots: istanbul-lib-source-maps@5.0.6: dependencies: '@jridgewell/trace-mapping': 0.3.31 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.3 istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: - supports-color @@ -2052,6 +2099,11 @@ snapshots: js-tokens@9.0.1: {} + jsr@0.13.5: + dependencies: + node-stream-zip: 1.15.0 + semiver: 1.1.0 + libphonenumber-js@1.12.17: {} lilconfig@3.1.3: {} @@ -2064,8 +2116,6 @@ snapshots: lodash@4.17.21: {} - loupe@3.2.1: {} - lru-cache@10.4.3: {} magic-string@0.30.19: @@ -2119,6 +2169,8 @@ snapshots: nanoid@3.3.11: {} + node-stream-zip@1.15.0: {} + nodemon@3.1.10: dependencies: chokidar: 3.6.0 @@ -2147,8 +2199,6 @@ snapshots: pathe@2.0.3: {} - pathval@2.0.1: {} - picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -2222,6 +2272,8 @@ snapshots: dependencies: tslib: 2.8.1 + semiver@1.1.0: {} + semver@7.7.2: {} shebang-command@2.0.0: @@ -2277,10 +2329,6 @@ snapshots: dependencies: ansi-regex: 6.2.2 - strip-literal@3.0.0: - dependencies: - js-tokens: 9.0.1 - sucrase@3.35.0: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -2310,12 +2358,6 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 - test-exclude@7.0.1: - dependencies: - '@istanbuljs/schema': 0.1.3 - glob: 10.4.5 - minimatch: 9.0.5 - thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -2333,11 +2375,7 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - tinypool@1.1.1: {} - - tinyrainbow@2.0.0: {} - - tinyspy@4.0.4: {} + tinyrainbow@3.0.3: {} to-regex-range@5.0.1: dependencies: @@ -2353,11 +2391,9 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-pattern@5.8.0: {} - tslib@2.8.1: {} - tsup@8.5.0(@swc/core@1.13.5)(postcss@8.5.6)(typescript@5.9.2): + tsup@8.5.0(@swc/core@1.14.0)(postcss@8.5.6)(typescript@5.9.3): dependencies: bundle-require: 5.1.0(esbuild@0.25.9) cac: 6.7.14 @@ -2377,16 +2413,16 @@ snapshots: tinyglobby: 0.2.15 tree-kill: 1.2.2 optionalDependencies: - '@swc/core': 1.13.5 + '@swc/core': 1.14.0 postcss: 8.5.6 - typescript: 5.9.2 + typescript: 5.9.3 transitivePeerDependencies: - jiti - supports-color - tsx - yaml - typescript@5.9.2: {} + typescript@5.9.3: {} ufo@1.6.1: {} @@ -2394,27 +2430,6 @@ snapshots: validator@13.15.15: {} - vite-node@3.2.4(terser@5.44.0): - dependencies: - cac: 6.7.14 - debug: 4.4.1(supports-color@5.5.0) - es-module-lexer: 1.7.0 - pathe: 2.0.3 - vite: 7.1.6(terser@5.44.0) - transitivePeerDependencies: - - '@types/node' - - jiti - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - vite@7.1.6(terser@5.44.0): dependencies: esbuild: 0.25.9 @@ -2427,18 +2442,17 @@ snapshots: fsevents: 2.3.3 terser: 5.44.0 - vitest@3.2.4(terser@5.44.0): + vitest@4.0.5(terser@5.44.0): dependencies: - '@types/chai': 5.2.2 - '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.1.6(terser@5.44.0)) - '@vitest/pretty-format': 3.2.4 - '@vitest/runner': 3.2.4 - '@vitest/snapshot': 3.2.4 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 - chai: 5.3.3 - debug: 4.4.1(supports-color@5.5.0) + '@vitest/expect': 4.0.5 + '@vitest/mocker': 4.0.5(vite@7.1.6(terser@5.44.0)) + '@vitest/pretty-format': 4.0.5 + '@vitest/runner': 4.0.5 + '@vitest/snapshot': 4.0.5 + '@vitest/spy': 4.0.5 + '@vitest/utils': 4.0.5 + debug: 4.4.3 + es-module-lexer: 1.7.0 expect-type: 1.2.2 magic-string: 0.30.19 pathe: 2.0.3 @@ -2447,10 +2461,8 @@ snapshots: tinybench: 2.9.0 tinyexec: 0.3.2 tinyglobby: 0.2.15 - tinypool: 1.1.1 - tinyrainbow: 2.0.0 + tinyrainbow: 3.0.3 vite: 7.1.6(terser@5.44.0) - vite-node: 3.2.4(terser@5.44.0) why-is-node-running: 2.3.0 transitivePeerDependencies: - jiti @@ -2519,4 +2531,4 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 - zod@4.1.11: {} + zod@4.1.12: {} diff --git a/scripts/update-jsr-version.sh b/scripts/update-jsr-version.sh new file mode 100755 index 0000000..432ccb4 --- /dev/null +++ b/scripts/update-jsr-version.sh @@ -0,0 +1,31 @@ +#!/usr/bin/bash + +# This script updates the JSR version in the jsr.json file. +# Usage: ./update-jsr-version.sh + + +if [ "$#" -ne 1 ]; then + echo "Usage: $0 " + exit 1 +fi + +NEW_VERSION=$1 + +# Check if the version format is valid (basic check), 0.1.0 or 1.0.0-beta or 0.7.0-.20251002002 +if ! [[ "$NEW_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then + echo "Error: Invalid version format. Expected format: X.Y.Z or X.Y.Z-suffix" + exit 1 +fi + +JSR_FILE="jsr.json" + +if [ ! -f "$JSR_FILE" ]; then + echo "Error: $JSR_FILE not found!" + exit 1 +fi + +# Update the version in jsr.json +sed -i.bak -E "s/\"version\": \"[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?\"/\"version\": \"$NEW_VERSION\"/" "$JSR_FILE" +rm "$JSR_FILE.bak" + +echo "Updated JSR version to $NEW_VERSION in $JSR_FILE" diff --git a/src/cteMaker.ts b/src/cteMaker.ts index c95d072..d7a8ad3 100644 --- a/src/cteMaker.ts +++ b/src/cteMaker.ts @@ -1,133 +1,146 @@ -import QueryDefinition from "./queryKinds/query.js"; -import SelectQuery from "./queryKinds/select.js"; +import type QueryDefinition from "./queryKinds/dml/dmlQueryDefinition.js"; +import SelectQuery from "./queryKinds/dml/select.js"; +import Union from "./queryKinds/dml/union.js"; /** - * Cte represents a Common Table Expression (CTE) in SQL. - * It allows defining a named subquery that can be referenced within other queries. - */ + * Cte represents a Common Table Expression (CTE) in SQL. + * It allows defining a named subquery that can be referenced within other queries. + */ export class Cte { /** - * The name of the CTE. - */ + * The name of the CTE. + */ private name: string; - + /** - * The query that defines the CTE. - */ + * The query that defines the CTE. + */ private query: QueryDefinition; /** - * Indicates whether the CTE is recursive. - */ + * Indicates whether the CTE is recursive. + */ private recursiveCte: boolean; /** - * Creates an instance of Cte. - * @param name - The name of the CTE. - * @param query - The query that defines the CTE. - * @param recursive - Whether the CTE is recursive (default is false). - */ + * Creates an instance of Cte. + * @param name - The name of the CTE. + * @param query - The query that defines the CTE. + * @param recursive - Whether the CTE is recursive (default is false). + */ constructor( - name?: string, + name?: string, query?: QueryDefinition, - recursive: boolean = false + recursive: boolean = false, ) { - this.name = name || ''; + this.name = name || ""; this.query = query || new SelectQuery(); this.recursiveCte = recursive; } /** - * Marks the CTE as recursive. - * @returns The current Cte instance for method chaining. - */ + * Marks the CTE as recursive. + * @returns The current Cte instance for method chaining. + */ public recursive(): this { this.recursiveCte = true; return this; } /** - * Sets the name of the CTE. - * The name should be a valid SQL identifier. - * @param name - The name of the CTE. - * @returns The current Cte instance for method chaining. - */ + * Sets the name of the CTE. + * The name should be a valid SQL identifier. + * @param name - The name of the CTE. + * @returns The current Cte instance for method chaining. + */ public as(name: string): this { this.name = name; return this; } /** - * Sets the query that defines the CTE. - * The query should be an instance of a class that extends QueryDefinition (e.g., SelectQuery). - * @param query - The query defining the CTE. - * @returns The current Cte instance for method chaining. - */ - public withQuery(query: SelectQuery): this { + * Sets the query that defines the CTE. + * The query should be an instance of a class that extends QueryDefinition (e.g., SelectQuery). + * @param query - The query defining the CTE. + * @returns The current Cte instance for method chaining. + */ + public withQuery(query: QueryDefinition): this { this.query = query; return this; } /** - * Builds the SQL string for the CTE, including its name and query. - * Returns an object containing the SQL text and associated parameter values. - * @returns An object with the SQL text and parameter values. - */ + * Builds the SQL string for the CTE, including its name and query. + * Returns an object containing the SQL text and associated parameter values. + * @returns An object with the SQL text and parameter values. + */ public build(): { text: string; values: any[] } { - const recursiveStr = this.recursiveCte ? 'RECURSIVE ' : ''; + const recursiveStr = this.recursiveCte ? "RECURSIVE " : ""; + if ( + this.query instanceof SelectQuery + || this.query instanceof Union + ) { + (this.query as any).disabledAnalysis = true; + } const query = this.query.build(); + if ( + this.query instanceof SelectQuery + || this.query instanceof Union + ) { + (this.query as any).disabledAnalysis = false; + } return { text: `${recursiveStr}${this.name} AS (\n${query.text}\n)`, - values: query.values + values: query.values, }; } } /** - * CteMaker helps in constructing SQL queries with multiple Common Table Expressions (CTEs). - * It manages a list of CTEs and builds the final SQL string with proper parameter indexing. - */ + * CteMaker helps in constructing SQL queries with multiple Common Table Expressions (CTEs). + * It manages a list of CTEs and builds the final SQL string with proper parameter indexing. + */ export default class CteMaker { /** - * The list of CTEs to be included in the SQL query. - */ + * The list of CTEs to be included in the SQL query. + */ private ctes: Cte[] = []; /** - * Creates an instance of CteMaker. - * @param ctes - An optional array of CTEs to initialize the CteMaker with. - */ + * Creates an instance of CteMaker. + * @param ctes - An optional array of CTEs to initialize the CteMaker with. + */ constructor(...ctes: Cte[]) { this.ctes = ctes; } /** - * Adds a new CTE to the list. - * @param cte - The CTE to be added. - * @returns The current CteMaker instance for method chaining. - */ + * Adds a new CTE to the list. + * @param cte - The CTE to be added. + * @returns The current CteMaker instance for method chaining. + */ public addCte(cte: Cte): this { this.ctes.push(cte); return this; } /** - * Adds multiple CTEs to the list. - * @param ctes - An array of CTEs to be added. - * @returns The current CteMaker instance for method chaining. - */ + * Adds multiple CTEs to the list. + * @param ctes - An array of CTEs to be added. + * @returns The current CteMaker instance for method chaining. + */ public addCtes(ctes: Cte[]): this { this.ctes.push(...ctes); return this; } /** - * Builds the SQL string for all CTEs, renumbering parameters to ensure uniqueness. - * @returns An object with the SQL text and parameter values. - */ + * Builds the SQL string for all CTEs, renumbering parameters to ensure uniqueness. + * @returns An object with the SQL text and parameter values. + */ public build(): { text: string; values: any[] } { if (this.ctes.length === 0) { - return { text: '', values: [] }; + return { text: "", values: [] }; } const cteResults: Array<{ text: string; values: any[] }> = []; @@ -136,7 +149,7 @@ export default class CteMaker { // Build each CTE and renumber its parameters for (const cte of this.ctes) { const builtCte = cte.build(); - + // Renumber parameters in this CTE's text const renumberedText = builtCte.text.replace(/\$(\d+)/g, () => { return `$${paramIndex++}`; @@ -144,13 +157,13 @@ export default class CteMaker { cteResults.push({ text: renumberedText, - values: builtCte.values + values: builtCte.values, }); } return { - text: `WITH ${cteResults.map(r => r.text).join(', ')}`, - values: cteResults.flatMap(r => r.values) + text: `WITH ${cteResults.map((r) => r.text).join(", ")}`, + values: cteResults.flatMap((r) => r.values), }; } } diff --git a/src/deepEqual.ts b/src/deepEqual.ts index f3287aa..797f1a7 100644 --- a/src/deepEqual.ts +++ b/src/deepEqual.ts @@ -1,41 +1,42 @@ - /** - * This function extracts parameter names from a function definition. - * Useful for comparing function signatures. - * @param func - The function from which to extract parameter names. - * @returns An array of parameter names. - */ + * This function extracts parameter names from a function definition. + * Useful for comparing function signatures. + * @param func - The function from which to extract parameter names. + * @returns An array of parameter names. + */ function getFunctionParameters(func: Function) { - const funcStr = func.toString(); - const match = funcStr.match(/\(([^)]*)\)/); - if (!match) return []; - - return match[1] - ?.split(',') - .map(param => param?.trim()?.split('=')?.[0]?.trim()) - .filter(param => param !== '') || []; + const funcStr = func.toString(); + const match = funcStr.match(/\(([^)]*)\)/); + if (!match) return []; + + return ( + match[1] + ?.split(",") + .map((param) => param?.trim()?.split("=")?.[0]?.trim()) + .filter((param) => param !== "") || [] + ); } /** - * Compares the parameters of two functions to see if they match. - * @param fn1 - The first function to compare. - * @param fn2 - The second function to compare. - * @returns True if the functions have the same parameters, false otherwise. - */ + * Compares the parameters of two functions to see if they match. + * @param fn1 - The first function to compare. + * @param fn2 - The second function to compare. + * @returns True if the functions have the same parameters, false otherwise. + */ function compareParameters(fn1: Function, fn2: Function): boolean { - const params1 = getFunctionParameters(fn1); - const params2 = getFunctionParameters(fn2); - - return JSON.stringify(params1) === JSON.stringify(params2); + const params1 = getFunctionParameters(fn1); + const params2 = getFunctionParameters(fn2); + + return JSON.stringify(params1) === JSON.stringify(params2); } /** - * Deeply compares two values for equality. - * Handles primitives, arrays, objects, and functions (by comparing their string representations and parameters). - * @param a - The first value to compare. - * @param b - The second value to compare. - * @returns True if the values are deeply equal, false otherwise. - */ + * Deeply compares two values for equality. + * Handles primitives, arrays, objects, and functions (by comparing their string representations and parameters). + * @param a - The first value to compare. + * @param b - The second value to compare. + * @returns True if the values are deeply equal, false otherwise. + */ export default function deepEqual(a: any, b: any): boolean { if (a === b) return true; @@ -45,13 +46,13 @@ export default function deepEqual(a: any, b: any): boolean { if (typeof a !== typeof b) return false; - if (typeof a === 'function' && typeof b === 'function') { - const funcA = a.toString().replace(/,line:\d+/g, ''); - const funcB = b.toString().replace(/,line:\d+/g, ''); + if (typeof a === "function" && typeof b === "function") { + const funcA = a.toString().replace(/,line:\d+/g, ""); + const funcB = b.toString().replace(/,line:\d+/g, ""); return funcA === funcB && compareParameters(a, b); } - if (typeof a !== 'object' || typeof b !== 'object') { + if (typeof a !== "object" || typeof b !== "object") { return a === b; } diff --git a/src/getOptionalPackages.ts b/src/getOptionalPackages.ts index f57a512..680206c 100644 --- a/src/getOptionalPackages.ts +++ b/src/getOptionalPackages.ts @@ -1,16 +1,15 @@ - /** - * Type definition for the object returned by getClassValidator function. - * Contains the class-validator and class-transformer modules. - */ + * Type definition for the object returned by getClassValidator function. + * Contains the class-validator and class-transformer modules. + */ export type ClassValidatorModule = { /** - * The class-validator module. - */ + * The class-validator module. + */ classValidator: typeof import("class-validator"); /** - * The class-transformer module. - */ + * The class-transformer module. + */ classTransformer: typeof import("class-transformer"); }; @@ -18,52 +17,50 @@ export type ClassValidatorModule = { // Avoids costly re-imports. /** - * Cache for the Zod module. - */ -let zodCache: typeof import('zod') | null = null; + * Cache for the Zod module. + */ +let zodCache: typeof import("zod") | null = null; /** - * Cache for the class-validator and class-transformer modules. - */ + * Cache for the class-validator and class-transformer modules. + */ let classValidatorCache: ClassValidatorModule | null = null; /** - * Dynamically imports the Zod library. - * @returns The Zod module. - * @throws if Zod is not installed. - */ -async function getZod(): Promise { + * Dynamically imports the Zod library. + * @returns The Zod module. + * @throws if Zod is not installed. + */ +async function getZod(): Promise { try { - zodCache = await import('zod'); + zodCache = await import("zod"); return zodCache; } catch { throw new Error( 'Zod is not installed. Please install it with "npm install zod" or "yarn add zod".', - ) + ); } } /** - * Dynamically imports the class-validator and class-transformer libraries. - * @returns An object containing the class-validator and class-transformer modules. - * @throws if either class-validator or class-transformer is not installed. - */ + * Dynamically imports the class-validator and class-transformer libraries. + * @returns An object containing the class-validator and class-transformer modules. + * @throws if either class-validator or class-transformer is not installed. + */ async function getClassValidator(): Promise { try { - const classValidator = await import('class-validator'); - const classTransformer = await import('class-transformer'); + const classValidator = await import("class-validator"); + const classTransformer = await import("class-transformer"); classValidatorCache = { classValidator, classTransformer }; return classValidatorCache; } catch { - throw new Error([ - 'class-validator and class-transformer are not installed.', - 'Please install them with "npm install class-validator class-transformer" or "yarn add class-validator class-transformer".' - ].join(' ')); + throw new Error( + [ + "class-validator and class-transformer are not installed.", + 'Please install them with "npm install class-validator class-transformer" or "yarn add class-validator class-transformer".', + ].join(" "), + ); } } - -export { - getZod, - getClassValidator, -} +export { getZod, getClassValidator }; diff --git a/src/index.ts b/src/index.ts index 12a1620..c9b29d2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,7 @@ import Query from "./queryMaker.js"; import Statement from "./statementMaker.js"; -import CteMaker from "./cteMaker.js"; -export { - Query, - Statement, - CteMaker -} +export * from "./cteMaker.js"; +export { default as CteMaker } from "./cteMaker.js"; + +export { Query, Statement }; diff --git a/src/queryKinds/ddl/ddlQueryDefinition.test.ts b/src/queryKinds/ddl/ddlQueryDefinition.test.ts new file mode 100644 index 0000000..e72e756 --- /dev/null +++ b/src/queryKinds/ddl/ddlQueryDefinition.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from "vitest"; +import { Query } from "../../index.js" +import Column from "../../queryUtils/Column.js"; + +describe('DDL Query Definition', () => { + it('should create a basic DDL query using Query interface and test if it\'s done', () => { + const query = Query + .table.create + .table('users') + .setColumns([ + Column('id', 'INT').primaryKey().notNull(), + Column('username', 'VARCHAR(50)').notNull() + ]); + + let isDone = query.isDone(); + expect(isDone).toBe(false); + + expect(query.build()) + .toEqual('CREATE TABLE "users" (\n id INT PRIMARY KEY,\n username VARCHAR(50) NOT NULL\n);'); + + isDone = query.isDone(); + expect(isDone).toBe(true); + }); + + it('should be able to build explain query', () => { + const query = Query + .table.create + .table('products') + .setColumns([ + Column('product_id', 'INT').primaryKey().notNull(), + Column('product_name', 'VARCHAR(100)').notNull() + ]); + + expect(query.buildExplain()) + .toEqual('EXPLAIN CREATE TABLE "products" (\n product_id INT PRIMARY KEY,\n product_name VARCHAR(100) NOT NULL\n);'); + + // Explain Analyze + expect(query.buildExplainAnalyze()) + .toEqual('EXPLAIN ANALYZE CREATE TABLE "products" (\n product_id INT PRIMARY KEY,\n product_name VARCHAR(100) NOT NULL\n);'); + }); + + it('should be able to handle schemas in table names', () => { + const query = Query + .table.create + .schema('client_a') + .table('$schema.products') + .setColumns([ + Column('product_id', 'INT').primaryKey().notNull(), + Column('product_name', 'VARCHAR(100)').notNull() + ]); + + expect(query.build()) + .toEqual('CREATE TABLE client_a."products" (\n product_id INT PRIMARY KEY,\n product_name VARCHAR(100) NOT NULL\n);'); + + // add schema + const query2 = Query + .table.create + .schema('client_a') + .table('$schema1.products') + .setColumns([ + Column('product_id', 'INT').primaryKey().notNull(), + Column('product_name', 'VARCHAR(100)').notNull() + ]) + .addSchema('client_b'); + + expect(query2.build()) + .toEqual('CREATE TABLE client_b."products" (\n product_id INT PRIMARY KEY,\n product_name VARCHAR(100) NOT NULL\n);'); + }); + + + it('should be able to execute the built query', async () => { + const executeFunction = async (queryString: string) => { + // Mock execution function + expect(queryString).toSatisfy((value: string | string[]) => { + if (typeof value !== 'string' && !Array.isArray(value)) { + return false; + } + return true; + }); + }; + + const query = Query + .table.create + .table('orders') + .setColumns([ + Column('order_id', 'INT').primaryKey().notNull(), + Column('order_date', 'DATE').notNull() + ]); + + const result = await query.execute(executeFunction); + expect(result).toEqual(undefined); + + const query2 = Query + .table.alter + .table('employees') + .addColumnsToAdd([ + Column('employee_id', 'INT').primaryKey().notNull(), + Column('employee_name', 'VARCHAR(100)').notNull(), + ]); + + const result2 = await query2.execute(executeFunction); + expect(result2).toEqual(undefined); + + let executeObject: { + [key: string]: any, + manager?: { + execute?: (queryString: string) => Promise; + }; + } = { + execute: async (queryString: string) => { + // Mock execution function + expect(queryString).toSatisfy((value: string | string[]) => { + if (typeof value !== 'string' && !Array.isArray(value)) { + return false; + } + return true; + }); + } + }; + + const query3 = Query + .table.create + .table('customers') + .setColumns([ + Column('customer_id', 'INT').primaryKey().notNull(), + Column('customer_name', 'VARCHAR(100)').notNull() + ]); + + const result3 = await query3.execute(executeObject); + expect(result3).toEqual(undefined); + + executeObject.manager = { + execute: async (queryString: string) => { + // Mock execution function + expect(queryString).toSatisfy((value: string | string[]) => { + if (typeof value !== 'string' && !Array.isArray(value)) { + return false; + } + return true; + }); + } + } + + const query4 = Query + .table.create + .table('suppliers') + .setColumns([ + Column('supplier_id', 'INT').primaryKey().notNull(), + Column('supplier_name', 'VARCHAR(100)').notNull() + ]); + + const result4 = await query4.execute(executeObject); + expect(result4).toEqual(undefined); + + const query5 = Query + .table.drop + .table('old_table'); + + delete executeObject.manager.execute; + + await expect(query5.execute(executeObject)).rejects.toThrowError(); + + // Ignore manager + const query6 = Query + .table.drop + .table('another_old_table'); + + const result6 = await query6.execute(executeObject, true); + expect(result6).toEqual(undefined); + + const query7 = Query + .table.alter + .table('departments') + .addColumnsToAdd([ + Column('department_id', 'INT').primaryKey().notNull(), + Column('department_name', 'VARCHAR(100)').notNull(), + ]); + + executeObject.manager = { + execute: async (queryString: string) => { + // Mock execution function + expect(queryString).toSatisfy((value: string | string[]) => { + if (typeof value !== 'string' && !Array.isArray(value)) { + return false; + } + return true; + }); + } + }; + + const result7 = await query7.execute(executeObject); + expect(result7).toEqual(undefined); + + delete executeObject.manager; + + expect(await query7.execute(executeObject)).toEqual(undefined); + }); + + it('should test utility spaceLines', () => { + const query = Query + .table.create; + + const noSpaces = `CREATE TABLE "test" (\nid INT\n);`; + expect(query['spaceLines'](noSpaces, 1)).toEqual(" CREATE TABLE \"test\" (\n id INT\n );"); + + + }) +}); diff --git a/src/queryKinds/ddl/ddlQueryDefinition.ts b/src/queryKinds/ddl/ddlQueryDefinition.ts new file mode 100644 index 0000000..05487d8 --- /dev/null +++ b/src/queryKinds/ddl/ddlQueryDefinition.ts @@ -0,0 +1,249 @@ +import SqlEscaper from "../../sqlEscaper.js"; +import type QueryKind from "../../types/QueryKind"; +import sqlFlavor from "../../types/sqlFlavor.js"; + +/** + * An array of function names that can be used to execute SQL queries. + * These functions are commonly found in database client libraries. + */ +const functionNames = ["execute", "query", "run", "all", "get"] as const; + +/** + * FunctionDeclaration type defines the signature for functions that execute SQL queries. + * It takes a query string and an array of parameters, and returns a promise that resolves + * with any result. + */ +type FunctionDeclaration = (query: string, params?: any[]) => Promise; + +/** + * QueryExecutorObject interface defines the structure for an object that can execute SQL queries. + * It includes optional methods for executing queries in different ways, as well as an optional manager property. + */ +interface QueryExecutorObject { + execute?: FunctionDeclaration; + query?: FunctionDeclaration; + run?: FunctionDeclaration; + all?: FunctionDeclaration; + get?: FunctionDeclaration; + manager?: QueryExecutor; +} + +/** + * QueryExecutor type can be either a QueryExecutorObject or a function that executes a query. + */ +type QueryExecutor = QueryExecutorObject | FunctionDeclaration; + +/** + * Abstract class DdlQueryDefinition serves as a blueprint for defining DDL (Data Definition Language) query structures. + * It is intended to be extended by specific DDL query classes such as CreateTableQuery, AlterTableQuery, and DropTableQuery. + * This class will provide common properties and methods that are shared among all DDL query types and also some abstract methods + * that must be implemented by the subclasses to ensure they adhere to a consistent interface for building DDL queries. + */ +export default abstract class DdlQueryDefinition { + /** The name of the table involved in the DDL operation. */ + protected tableName: string = ""; + + /** The built DDL query string, initialized to null. */ + protected builtQuery: string | string[] | null = null; + + /** + * Sets the name of the table for the DDL operation. + * @param name - The name of the table. + * @returns The current instance for method chaining. + */ + public table(name: string | null = null): this { + this.tableName = name ? SqlEscaper.escapeTableName(name, this.flavor) : ""; + return this; + } + + /** + * Checks if the DDL query has been built. + * @returns True if the query has been built, false otherwise. + */ + public isDone(): boolean { + return this.builtQuery !== null; + } + + /** + * Abstract method to build the DDL query string. + * This method must be implemented by subclasses to generate the appropriate SQL statement. + * @param deepAnalysis - Optional boolean to indicate if deep analysis is required (default is false). + * @returns The constructed DDL query string. + */ + public abstract build(deepAnalysis?: boolean): string | string[]; + + /** + * Builds an EXPLAIN query for the DDL operation. + * This method prefixes the built DDL query with "EXPLAIN". + * @param deepAnalysis - Optional boolean to indicate if deep analysis is required (default is false). + * @returns The constructed EXPLAIN query string. + */ + public buildExplain(deepAnalysis?: boolean): string { + return `EXPLAIN ${this.build(deepAnalysis)}`; + } + + /** + * Builds an EXPLAIN ANALYZE query for the DDL operation. + * This method prefixes the built DDL query with "EXPLAIN ANALYZE". + * @param deepAnalysis - Optional boolean to indicate if deep analysis is required (default is false). + * @returns The constructed EXPLAIN ANALYZE query string. + */ + public buildExplainAnalyze(deepAnalysis?: boolean): string { + return `EXPLAIN ANALYZE ${this.build(deepAnalysis)}`; + } + + /** + * Utility method to add indentation to each line of a given string. + * This is useful for formatting multi-line SQL queries for better readability. + * @param str - The input string to be indented. + * @param spaces - The number of spaces to indent each line (default is 0). + * @returns The indented string. + */ + protected spaceLines(str: string, spaces: number = 0): string { + const space = " ".repeat(spaces); + return str + .split("\n") + .map((line) => space + line) + .join("\n"); + } + + /** + * Abstract method to clone the current DDL query definition instance. + * This method must be implemented by subclasses to return a new instance + * that is a copy of the current instance. + * @returns A new instance of the DDL query definition. + */ + public abstract clone(): DdlQueryDefinition; + + /** + * Abstract method to reset the state of the DDL query definition. + * This method must be implemented by subclasses to clear any set properties + * and return the instance to its initial state. + * @returns The current instance for method chaining. + */ + public abstract reset(): this; + + /** + * Abstract method to convert the DDL query definition to its SQL string representation. + * This method must be implemented by subclasses to return the SQL string + * that represents the DDL operation defined by the instance. + * @returns The SQL string representation of the DDL query. + */ + public abstract toSQL(): string | string[]; + + /** + * Abstract getter to retrieve the kind of DDL query. + * This property must be implemented by subclasses to return the specific + * type of DDL operation (e.g., 'CREATE', 'ALTER', 'DROP'). + */ + public abstract get kind(): QueryKind; + + /** + * The SQL flavor to use for escaping identifiers. + * Default is PostgreSQL. + */ + protected flavor: sqlFlavor = sqlFlavor.postgres; + + /** + * Schemas to be used in the query. + * This is useful for databases that support multiple schemas. + * NOTICE: SQL Injection is not checked in schema names. Be sure to use only trusted schema names. + */ + protected schemas: string[] = []; + + /** + * Sets the SQL flavor for escaping identifiers. + * @param flavor The SQL flavor to set. + * @returns The current DmlQueryDefinition instance for chaining. + */ + public sqlFlavor(flavor: sqlFlavor) { + this.flavor = flavor; + return this; + } + + /** + * Set schemas to be used in the query. + * This is useful for databases that support multiple schemas. + * NOTICE: SQL Injection is not checked in schema names. Be sure to use only trusted schema names. + * @param schemas The schemas to set. + * @returns The current SelectQuery instance for chaining. + */ + public schema(...schemas: string[]): this { + this.schemas = schemas; + return this; + } + + /** + * Adds schemas to the existing list of schemas. + * This is useful for databases that support multiple schemas. + * NOTICE: SQL Injection is not checked in schema names. Be sure to use only trusted schema names. + * @param schemas The schemas to add. + * @returns The current SelectQuery instance for chaining. + */ + public addSchema(...schemas: string[]): this { + this.schemas.push(...schemas); + return this; + } + + /** + * Executes the built SQL query using the provided query executor. + * The query executor can be a function or an object with methods to execute the query. + * The optional noManager parameter can be used to bypass the manager property if present. + * @param queryExecutor The executor to run the SQL query. + * @param noManager If true, bypasses the manager property of the executor object. + * @returns A promise that resolves when the query execution is complete. + * @throws An error if the provided query executor is invalid. + */ + public async execute( + queryExecutor: QueryExecutor, + noManager: boolean = false, + ): Promise { + if (typeof queryExecutor === "function") { + const builtQuery = this.build(); + if (Array.isArray(builtQuery)) { + for (const query of builtQuery) { + await queryExecutor(query); + } + } else { + await queryExecutor(builtQuery); + } + return; + } + + if ( + !noManager && + "manager" in queryExecutor && + typeof queryExecutor?.manager === "object" + ) { + for (const functionName of functionNames) { + if (typeof queryExecutor.manager[functionName] === "function") { + const builtQuery = this.build(); + if (Array.isArray(builtQuery)) { + for (const query of builtQuery) { + await queryExecutor.manager[functionName]!(query); + } + } else { + await queryExecutor.manager[functionName]!(builtQuery); + } + return; + } + } + } else if (typeof queryExecutor === "object") { + for (const functionName of functionNames) { + if (typeof queryExecutor[functionName] === "function") { + const builtQuery = this.build(); + if (Array.isArray(builtQuery)) { + for (const query of builtQuery) { + await queryExecutor[functionName]!(query); + } + } else { + await queryExecutor[functionName]!(builtQuery); + } + return; + } + } + } + + throw new Error("Invalid query executor provided."); + } +} diff --git a/src/queryKinds/ddl/index.ts b/src/queryKinds/ddl/index.ts new file mode 100644 index 0000000..288f023 --- /dev/null +++ b/src/queryKinds/ddl/index.ts @@ -0,0 +1,55 @@ +import sqlFlavor from "../../types/sqlFlavor.js"; +import * as tables from "./table/index.js"; + +export * from "./ddlQueryDefinition.js"; +export { default as DdlQueryDefinition } from "./ddlQueryDefinition.js"; + +/** + * Class representing a Table for DDL operations. + * This class provides methods to initiate DDL queries such as CREATE TABLE. + */ +export class Table { + constructor( + private deepAnalysis: boolean = false, + private flavor = sqlFlavor.postgres, + ) {} + + /** + * Initiates a new CREATE TABLE query. + * @returns A new CreateTableQuery instance with a build method that respects the deepAnalysis setting. + */ + public get create(): tables.CreateTableQuery { + const query = new tables.CreateTableQuery(); + query.sqlFlavor(this.flavor); + query.build = (deepAnalysis: boolean = this.deepAnalysis) => { + return tables.CreateTableQuery.prototype.build.call(query, deepAnalysis); + }; + return query; + } + + /** + * Initiates a new DROP TABLE query. + * @returns A new DropTableQuery instance with a build method that respects the deepAnalysis setting. + */ + public get drop(): tables.DropTableQuery { + const query = new tables.DropTableQuery(); + query.sqlFlavor(this.flavor); + query.build = (deepAnalysis: boolean = this.deepAnalysis) => { + return tables.DropTableQuery.prototype.build.call(query, deepAnalysis); + }; + return query; + } + + /** + * Initiates a new ALTER TABLE query. + * @returns A new AlterTableQuery instance with a build method that respects the deepAnalysis setting. + */ + public get alter(): tables.AlterTableQuery { + const query = new tables.AlterTableQuery(); + query.sqlFlavor(this.flavor); + query.build = (deepAnalysis: boolean = this.deepAnalysis) => { + return tables.AlterTableQuery.prototype.build.call(query, deepAnalysis); + }; + return query; + } +} diff --git a/src/queryKinds/ddl/table/Alter.test.ts b/src/queryKinds/ddl/table/Alter.test.ts new file mode 100644 index 0000000..b5f9b84 --- /dev/null +++ b/src/queryKinds/ddl/table/Alter.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from "vitest"; +import AlterTableQuery from "./Alter.js"; +import Column from "../../../queryUtils/Column.js"; +import { Varchar } from "../../../types/ColumnTypes.js"; + + +describe('Alter Table Query', () => { + it('should create a basic ALTER TABLE query to add a column', () => { + const query = new AlterTableQuery('users') + .addColumnsToAdd(Column('id', 'INT').primaryKey().notNull()) + .build(); + + expect(query).toEqual([ + 'ALTER TABLE "users" ADD COLUMN id INT;', + 'ALTER TABLE "users" ALTER COLUMN id SET NOT NULL;', + 'ALTER TABLE "users" ADD CONSTRAINT users_id_pkey PRIMARY KEY (id);', + ]); + + const query2 = new AlterTableQuery('employees') + .addColumnsToAdd([ + Column('employee_id', 'INT').primaryKey().notNull(), + Column('employee_name', Varchar(100)).notNull(), + ]) + .build(); + + expect(query2).toEqual([ + 'ALTER TABLE "employees" ADD COLUMN employee_id INT;', + 'ALTER TABLE "employees" ALTER COLUMN employee_id SET NOT NULL;', + 'ALTER TABLE "employees" ADD CONSTRAINT employees_employee_id_pkey PRIMARY KEY (employee_id);', + 'ALTER TABLE "employees" ADD COLUMN employee_name VARCHAR(100);', + 'ALTER TABLE "employees" ALTER COLUMN employee_name SET NOT NULL;', + ]); + }); + + it('should allow to set table name later', () => { + const query = new AlterTableQuery() + .table('products') + .addColumnsToAdd([ + Column('product_id', 'INT').primaryKey().notNull(), + ]) + .build(); + + expect(query).toEqual([ + 'ALTER TABLE "products" ADD COLUMN product_id INT;', + 'ALTER TABLE "products" ALTER COLUMN product_id SET NOT NULL;', + 'ALTER TABLE "products" ADD CONSTRAINT products_product_id_pkey PRIMARY KEY (product_id);', + ]); + }); + + it('should allow to set columns to add', () => { + const query = new AlterTableQuery('orders') + .setColumnsToAdd([ + Column('order_id', 'INT').primaryKey().notNull(), + Column('order_date', 'DATE').notNull(), + ]) + .build(); + + expect(query).toEqual([ + 'ALTER TABLE "orders" ADD COLUMN order_id INT;', + 'ALTER TABLE "orders" ALTER COLUMN order_id SET NOT NULL;', + 'ALTER TABLE "orders" ADD CONSTRAINT orders_order_id_pkey PRIMARY KEY (order_id);', + 'ALTER TABLE "orders" ADD COLUMN order_date DATE;', + 'ALTER TABLE "orders" ALTER COLUMN order_date SET NOT NULL;', + ]); + + const query2 = new AlterTableQuery('orders') + .setColumnsToAdd(Column('customer_id', 'INT').notNull()) + .build(); + + expect(query2).toEqual([ + 'ALTER TABLE "orders" ADD COLUMN customer_id INT;', + 'ALTER TABLE "orders" ALTER COLUMN customer_id SET NOT NULL;', + ]); + }); + + it('should allow to set columns to alter', () => { + const query = new AlterTableQuery('customers') + .setColumnsToAlter([ + { name: 'customer_name', columns: Column('customer_name', Varchar(100)).notNull() }, + ]) + .build(); + + expect(query).toEqual([ + 'ALTER TABLE "customers" ALTER COLUMN customer_name TYPE VARCHAR(100);', + 'ALTER TABLE "customers" ALTER COLUMN customer_name SET NOT NULL;', + ]); + + const query2 = new AlterTableQuery('customers') + .setColumnsToAlter({ name: 'customer_email', columns: Column('customer_email', Varchar(150)).unique() }) + .build(); + + expect(query2).toEqual([ + 'ALTER TABLE "customers" ALTER COLUMN customer_email TYPE VARCHAR(150);', + 'ALTER TABLE "customers" ALTER COLUMN customer_email DROP NOT NULL;', + 'ALTER TABLE "customers" ADD CONSTRAINT customers_customer_email_unique UNIQUE (customer_email);' + ]); + }); + + it('should allow to drop columns', () => { + const query = new AlterTableQuery('inventory') + .dropColumnsByName(['old_column1', 'old_column2']) + .build(); + + expect(query).toEqual([ + 'ALTER TABLE "inventory" DROP COLUMN old_column1;', + 'ALTER TABLE "inventory" DROP COLUMN old_column2;', + ]); + + const query2 = new AlterTableQuery('inventory') + .dropColumnsByName('obsolete_column') + .build(); + + expect(query2).toEqual([ + 'ALTER TABLE "inventory" DROP COLUMN obsolete_column;', + ]); + + // set + const query3 = new AlterTableQuery('inventory') + .setColumnsToDrop('discontinued_column') + .build(); + + expect(query3).toEqual([ + 'ALTER TABLE "inventory" DROP COLUMN discontinued_column;', + ]); + + const query4 = new AlterTableQuery('inventory') + .setColumnsToDrop(['temp_column1', 'temp_column2']) + .build(); + + expect(query4).toEqual([ + 'ALTER TABLE "inventory" DROP COLUMN temp_column1;', + 'ALTER TABLE "inventory" DROP COLUMN temp_column2;', + ]); + }); + + it('should give kind as ALTER_TABLE', () => { + const query = new AlterTableQuery('test_table'); + expect(query.kind).toBe('ALTER_TABLE'); + }); + + it('should throw error if table name is not provided', () => { + const query = new AlterTableQuery(); + expect(() => query.build()).toThrowError( + 'Table name is required to build ALTER TABLE query.' + ); + }); + + it('should throw error if no alterations are specified', () => { + const query = new AlterTableQuery('test_table'); + expect(() => query.build()).toThrowError( + 'No alterations specified for ALTER TABLE query.' + ); + }); + + it('should be able to convert to sql using toSQL method', () => { + const query = new AlterTableQuery('employees') + .addColumnsToAdd(Column('employee_id', 'INT').primaryKey().notNull()); + + expect(query.toSQL()).toEqual([ + 'ALTER TABLE "employees" ADD COLUMN employee_id INT;', + 'ALTER TABLE "employees" ALTER COLUMN employee_id SET NOT NULL;', + 'ALTER TABLE "employees" ADD CONSTRAINT employees_employee_id_pkey PRIMARY KEY (employee_id);', + ]); + + const query2 = new AlterTableQuery('employees') + .dropColumnsByName('old_employee_column'); + + query2.toSQL(); + + expect(query2.toSQL()).toEqual([ + 'ALTER TABLE "employees" DROP COLUMN old_employee_column;', + ]); + }); + + it('should clone the AlterTableQuery instance', () => { + const originalQuery = new AlterTableQuery('departments') + .addColumnsToAdd(Column('department_id', 'INT').primaryKey().notNull()); + + const clonedQuery = originalQuery.clone(); + + expect(clonedQuery).toEqual(originalQuery); + expect(clonedQuery).not.toBe(originalQuery); + + // Modify the clone and ensure the original is unaffected + clonedQuery.table('new_departments'); + + expect((clonedQuery as any).tableName).toBe('"new_departments"'); + expect((originalQuery as any).tableName).toBe('"departments"'); + }); + + it('should be able to reset the AlterTableQuery instance', () => { + const query = new AlterTableQuery('projects') + .addColumnsToAdd(Column('project_id', 'INT').primaryKey().notNull()) + .dropColumnsByName('old_project_column'); + + query.reset(); + + expect(() => query.build()).toThrowError( + 'Table name is required to build ALTER TABLE query.' + ); + + query.table('new_projects'); + + expect(() => query.build()).toThrowError( + 'No alterations specified for ALTER TABLE query.' + ); + }); +}); diff --git a/src/queryKinds/ddl/table/Alter.ts b/src/queryKinds/ddl/table/Alter.ts new file mode 100644 index 0000000..aa40c54 --- /dev/null +++ b/src/queryKinds/ddl/table/Alter.ts @@ -0,0 +1,234 @@ +import type { ColumnDefinition } from "../../../queryUtils/Column.js"; +import SqlEscaper from "../../../sqlEscaper.js"; +import QueryKind from "../../../types/QueryKind.js"; +import TableQueryDefinition from "./tableColumnDefinition.js"; + +/** + * Class representing an ALTER TABLE SQL query. + * This class allows you to define and build an ALTER TABLE SQL query + * with specified table name, columns to add, alter, or drop. + */ +export default class AlterTableQuery extends TableQueryDefinition { + /** The columns to be added to the table. */ + columnsToAdd: ColumnDefinition[] = []; + /** The columns to be altered in the table, mapped by column name. */ + columnsToAlter: Map = new Map(); + /** The names of the columns to be dropped from the table. */ + columnsToDrop: string[] = []; + + constructor(tableName: string | null = null) { + super(); + this.tableName = tableName + ? SqlEscaper.escapeTableName(tableName, this.flavor) + : ""; + } + + /** + * Adds columns to be added to the table. + * @param columns - A single Column instance or an array of Column instances. + * @returns The current instance for method chaining. + */ + public addColumnsToAdd(columns: ColumnDefinition | ColumnDefinition[]): this { + if (Array.isArray(columns)) { + this.columnsToAdd.push(...columns); + } else { + this.columnsToAdd.push(columns); + } + return this; + } + + /** + * Sets the columns to be added to the table, replacing any existing ones. + * @param columns - A single Column instance or an array of Column instances. + * @returns The current instance for method chaining. + */ + public setColumnsToAdd(columns: ColumnDefinition | ColumnDefinition[]): this { + this.columnsToAdd = []; + return this.addColumnsToAdd(columns); + } + + /** + * Adds columns to be altered in the table. + * @param alteration - An object or array of objects containing the column name and the Column instance with alterations. + * @returns The current instance for method chaining. + */ + public addColumnsToAlter( + alteration: + | { name: string; columns: ColumnDefinition } + | { name: string; columns: ColumnDefinition }[], + ): this { + if (Array.isArray(alteration)) { + alteration.forEach(({ name, columns }) => { + if (!this.columnsToAlter.has(name)) { + this.columnsToAlter.set(name, []); + } + this.columnsToAlter.get(name)?.push(columns); + }); + } else { + const { name, columns } = alteration; + if (!this.columnsToAlter.has(name)) { + this.columnsToAlter.set(name, []); + } + this.columnsToAlter.get(name)?.push(columns); + } + return this; + } + + /** + * Sets the columns to be altered in the table, replacing any existing ones. + * @param alteration - An object or array of objects containing the column name and the Column instance with alterations. + * @returns The current instance for method chaining. + */ + public setColumnsToAlter( + alteration: + | { name: string; columns: ColumnDefinition } + | { name: string; columns: ColumnDefinition }[], + ): this { + this.columnsToAlter.clear(); + return this.addColumnsToAlter(alteration); + } + + /** + * Adds column names to be dropped from the table. + * @param columnNames - A single column name or an array of column names. + * @returns The current instance for method chaining. + */ + public dropColumnsByName(columnNames: string | string[]): this { + if (Array.isArray(columnNames)) { + this.columnsToDrop.push(...columnNames); + } else { + this.columnsToDrop.push(columnNames); + } + return this; + } + + /** + * Sets the column names to be dropped from the table, replacing any existing ones. + * @param columnNames - A single column name or an array of column names. + * @returns The current instance for method chaining. + */ + public setColumnsToDrop(columnNames: string | string[]): this { + this.columnsToDrop = []; + return this.dropColumnsByName(columnNames); + } + + /** + * Generates the SQL fragment for dropping a column. + * @param columnName - The name of the column to drop. + * @returns The SQL fragment for dropping the specified column. + */ + public static dropColumn(columnName: string): string { + return `DROP COLUMN ${columnName}`; + } + + /** + * Generates the SQL fragments for adding and altering columns. + * @returns An array of SQL fragments for adding and altering columns. + */ + private alterColumns(): string[][] { + const toAdd = this.columnsToAdd.map((col) => + col.buildToAdd(this.tableName), + ); + const toAlter = Array.from(this.columnsToAlter.entries()).flatMap( + ([colName, alterations]) => { + return alterations.map((alteration) => + alteration.buildToAlter(this.tableName, colName), + ); + }, + ); + return [...toAdd, ...toAlter]; + } + + /** + * Generates the SQL fragments for dropping columns. + * @returns An array of SQL fragments for dropping columns. + */ + private dropColumns(): string[] { + return this.columnsToDrop.map((colName) => + AlterTableQuery.dropColumn(colName), + ); + } + + /** + * Gets the kind of query. + * @returns The kind of query, which is 'ALTER_TABLE' for this class. + */ + public get kind(): QueryKind { + return QueryKind.ALTER_TABLE; + } + + /** + * Builds the ALTER TABLE SQL query strings. + * @param _deepAnalysis - Optional boolean to indicate if deep analysis is required (default is false). + * @returns An array of constructed ALTER TABLE SQL query strings. + * @throws Error if the table name is not provided or if no alterations are specified. + */ + public build(_deepAnalysis?: boolean): string[] { + if (!this.tableName) { + throw new Error("Table name is required to build ALTER TABLE query."); + } + + const queries: string[] = []; + const alterCols = this.alterColumns(); + const dropCols = this.dropColumns(); + + alterCols.forEach((colParts) => { + queries.push(...colParts); + }); + + dropCols.forEach((dropPart) => { + const query = `ALTER TABLE ${this.tableName} ${dropPart}`; + queries.push(query); + }); + + if (queries.length === 0) { + throw new Error("No alterations specified for ALTER TABLE query."); + } + + this.builtQuery = []; + queries.forEach((query) => { + (this.builtQuery as any as string[])?.push( + SqlEscaper.appendSchemas(`${query};`, this.schemas), + ); + }); + return this.builtQuery; + } + + /** + * Returns the SQL string representation of the ALTER TABLE query. + * @returns The SQL string representation of the ALTER TABLE query. + * @throws Error if the query has not been built yet. + */ + public toSQL(): string[] { + if (!this.builtQuery) this.build(); + return this.builtQuery as string[]; + } + + /** + * Creates a clone of the current AlterTableQuery instance. + * @returns A new AlterTableQuery instance with the same properties as the current instance. + */ + public clone(): AlterTableQuery { + const cloned = new AlterTableQuery(); + cloned.tableName = this.tableName; + cloned.flavor = this.flavor; + cloned.columnsToAdd = [...this.columnsToAdd]; + cloned.columnsToAlter = new Map(this.columnsToAlter); + cloned.columnsToDrop = [...this.columnsToDrop]; + return cloned; + } + + /** + * Resets the AlterTableQuery instance to its initial state. + * This method clears the table name, columns to add, alter, drop, and the built query. + * @returns The current instance for method chaining. + */ + public reset(): this { + this.tableName = ""; + this.builtQuery = null; + this.columnsToAdd = []; + this.columnsToAlter.clear(); + this.columnsToDrop = []; + return this; + } +} diff --git a/src/queryKinds/ddl/table/Create.test.ts b/src/queryKinds/ddl/table/Create.test.ts new file mode 100644 index 0000000..743db36 --- /dev/null +++ b/src/queryKinds/ddl/table/Create.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from "vitest"; +import CreateTableQuery from "./Create.js"; +import Column from "../../../queryUtils/Column.js"; +import { Decimal, Varchar } from "../../../types/ColumnTypes.js"; +import QueryKind from "../../../types/QueryKind.js"; + + +describe('Create Table Query', () => { + it('should create a basic CREATE TABLE query', () => { + const query = new CreateTableQuery('users') + .addColumns([ + Column('id', 'INT').primaryKey().notNull(), + Column('name', Varchar(100)).notNull(), + Column('email', Varchar(100)).unique() + ]) + .build(); + + expect(query).toBe( + 'CREATE TABLE "users" (\n id INT PRIMARY KEY,\n name VARCHAR(100) NOT NULL,\n email VARCHAR(100) UNIQUE\n);' + ); + }); + + it('should create a CREATE TABLE query with IF NOT EXISTS', () => { + const query = new CreateTableQuery('users') + .ifNotExists() + .addColumns([ + Column('id', 'INT').primaryKey().notNull(), + Column('name', Varchar(100)).notNull(), + Column('email', Varchar(100)).unique() + ]) + .build(); + + expect(query).toBe( + 'CREATE TABLE IF NOT EXISTS "users" (\n id INT PRIMARY KEY,\n name VARCHAR(100) NOT NULL,\n email VARCHAR(100) UNIQUE\n);' + ); + }); + + it('should throw an error if table name is not set', () => { + const query = new CreateTableQuery() + .addColumns([ + Column('id', 'INT').primaryKey().notNull(), + Column('name', Varchar(100)).notNull() + ]); + + expect(() => query.build()).toThrow('Table name is not set.'); + }); + + it('should work with foreign keys', () => { + const query = new CreateTableQuery('orders') + .addColumns([ + Column('id', 'INT').primaryKey().notNull(), + Column('user_id', 'INT').notNull().references('users', 'id'), + Column('total', Decimal(10, 2)).notNull() + ]) + .build(); + + expect(query).toBe( + 'CREATE TABLE "orders" (\n id INT PRIMARY KEY,\n user_id INT NOT NULL REFERENCES users(id),\n total DECIMAL(10, 2) NOT NULL\n);' + ); + }); + + it('should clone the query correctly', () => { + const original = new CreateTableQuery('products') + .ifNotExists() + .addColumns([ + Column('id', 'INT').primaryKey().notNull(), + Column('name', Varchar(100)).notNull() + ]); + + const cloned = original.clone(); + cloned.addColumns([ + Column('price', Decimal(10, 2)).notNull() + ]); + + const originalQuery = original.build(); + const clonedQuery = cloned.build(); + + expect(originalQuery).toBe( + 'CREATE TABLE IF NOT EXISTS "products" (\n id INT PRIMARY KEY,\n name VARCHAR(100) NOT NULL\n);' + ); + + expect(clonedQuery).toBe( + 'CREATE TABLE IF NOT EXISTS "products" (\n id INT PRIMARY KEY,\n name VARCHAR(100) NOT NULL,\n price DECIMAL(10, 2) NOT NULL\n);' + ); + }); + + it('should support schemas', () => { + const query = new CreateTableQuery('$schema.users') + .schema('public') + .addColumns([ + Column('id', 'INT').primaryKey().notNull(), + Column('name', Varchar(100)).notNull() + ]) + .build(); + + expect(query).toBe( + 'CREATE TABLE public."users" (\n id INT PRIMARY KEY,\n name VARCHAR(100) NOT NULL\n);' + ); + }); + + it('should throw an error if no columns are defined', () => { + const query = new CreateTableQuery('empty_table'); + expect(() => query.build()).toThrow('No columns defined for the table.'); + }); + + it('should reset the query state', () => { + const query = new CreateTableQuery('temp_table') + .ifNotExists() + .addColumns([ + Column('id', 'INT').primaryKey().notNull() + ]); + + query.reset(); + + expect(() => query.build()).toThrow('Table name is not set.'); + }); + + it('should return toSQL correctly', () => { + const query = new CreateTableQuery('logs') + .addColumns(Column('id', 'INT').primaryKey().notNull()) + .addColumns(Column('message', Varchar(255)).notNull()); + + expect(query.toSQL()).toBe( + 'CREATE TABLE "logs" (\n id INT PRIMARY KEY,\n message VARCHAR(255) NOT NULL\n);' + ); + }); + + it('should be able to handle setting columns', () => { + const query = new CreateTableQuery('employees') + .setColumns([ + Column('id', 'INT').primaryKey().notNull(), + Column('first_name', Varchar(50)).notNull(), + Column('last_name', Varchar(50)).notNull() + ]); + + expect(query.build()).toBe( + 'CREATE TABLE "employees" (\n id INT PRIMARY KEY,\n first_name VARCHAR(50) NOT NULL,\n last_name VARCHAR(50) NOT NULL\n);' + ); + }); + + it('should be able to return its columns array', () => { + const columns = [ + Column('id', 'INT').primaryKey().notNull(), + Column('username', Varchar(50)).notNull() + ]; + const query = new CreateTableQuery('accounts') + .setColumns(columns); + + expect(query.columns).toEqual(columns); + }); + + it('should be able to set the table name later', () => { + const query = new CreateTableQuery() + .table('departments') + .addColumns([ + Column('id', 'INT').primaryKey().notNull(), + Column('dept_name', Varchar(100)).notNull() + ]); + + expect(query.build()).toBe( + 'CREATE TABLE "departments" (\n id INT PRIMARY KEY,\n dept_name VARCHAR(100) NOT NULL\n);' + ); + }); + + it('should return its kind', () => { + const query = new CreateTableQuery('audit'); + expect(query.kind).toBe(QueryKind.CREATE_TABLE); + }); +}); diff --git a/src/queryKinds/ddl/table/Create.ts b/src/queryKinds/ddl/table/Create.ts new file mode 100644 index 0000000..9f712f2 --- /dev/null +++ b/src/queryKinds/ddl/table/Create.ts @@ -0,0 +1,121 @@ +import type { ColumnDefinition } from "../../../queryUtils/Column.js"; +import SqlEscaper from "../../../sqlEscaper.js"; +import QueryKind from "../../../types/QueryKind.js"; +import TableQueryDefinition from "./tableColumnDefinition.js"; + +/** + * Class representing a CREATE TABLE SQL query. + * This class allows you to define and build a CREATE TABLE SQL query + * with specified table name and columns. + */ +export default class CreateTableQuery extends TableQueryDefinition { + /** The name of the table to be created. */ + private tableColumns: ColumnDefinition[] = []; + + constructor(tableName?: string, ...columns: ColumnDefinition[]) { + super(); + this.tableName = tableName + ? SqlEscaper.escapeTableName(tableName, this.flavor) + : ""; + this.tableColumns = columns; + } + + /** + * Gets the columns defined for the table. + * @returns An array of Column instances representing the table's columns. + */ + public get columns(): ColumnDefinition[] { + return this.tableColumns; + } + + /** + * Sets the columns for the table. + * @param columns - A single Column instance or an array of Column instances. + * @returns The current instance for method chaining. + */ + public setColumns(columns: ColumnDefinition | ColumnDefinition[]): this { + this.tableColumns = []; + return this.addColumns(columns); + } + + /** + * Adds columns to the table. + * @param columns - A single Column instance or an array of Column instances. + * @returns The current instance for method chaining. + */ + public addColumns(columns: ColumnDefinition | ColumnDefinition[]): this { + if (Array.isArray(columns)) { + this.tableColumns.push(...columns); + } else { + this.tableColumns.push(columns); + } + return this; + } + + /** + * Builds the CREATE TABLE SQL query string. + * @param _deepAnalysis - Optional boolean to indicate if deep analysis is required (default is false). + * @returns The constructed CREATE TABLE SQL query string. + * @throws Error if the table name is not set or if no columns are defined. + */ + public build(_deepAnalysis?: boolean): string { + if (!this.tableName) { + throw new Error("Table name is not set."); + } + + if (this.tableColumns.length === 0) { + throw new Error("No columns defined for the table."); + } + + const columnsDef = this.tableColumns + .map((col) => ` ${col.build()}`) + .join(",\n"); + const ifNotExists = this.ifNotExistsFlag ? "IF NOT EXISTS " : ""; + this.builtQuery = `CREATE TABLE ${ifNotExists}${this.tableName} (\n${columnsDef}\n);`; + this.builtQuery = SqlEscaper.appendSchemas(this.builtQuery, this.schemas); + return this.builtQuery; + } + + /** + * Creates a clone of the current CreateTableQuery instance. + * @returns A new instance of CreateTableQuery with the same properties as the current instance. + */ + public clone(): CreateTableQuery { + const cloned = new CreateTableQuery(); + cloned.tableName = this.tableName; + cloned.tableColumns = [...this.tableColumns]; + cloned.flavor = this.flavor; + cloned.ifNotExistsFlag = this.ifNotExistsFlag; + return cloned; + } + + /** + * Resets the state of the CreateTableQuery instance. + * This method clears the table name, columns, and built query. + * @returns The current instance for method chaining. + */ + public reset(): this { + this.tableName = ""; + this.tableColumns = []; + this.builtQuery = null; + this.resetIfNotExists(); + return this; + } + + /** + * Returns the SQL string representation of the CREATE TABLE query. + * @returns The SQL string representation of the CREATE TABLE query. + */ + public toSQL(): string { + if(!this.builtQuery) this.build(); + return this.builtQuery as string; + } + + /** + * Gets the kind of the query. + * @returns The kind of the query, which is QueryKind.CREATE_TABLE. + */ + public get kind() { + return QueryKind.CREATE_TABLE; + } +} diff --git a/src/queryKinds/ddl/table/Drop.test.ts b/src/queryKinds/ddl/table/Drop.test.ts new file mode 100644 index 0000000..79875cb --- /dev/null +++ b/src/queryKinds/ddl/table/Drop.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import DropTableQuery from "./Drop.js"; +import QueryKind from "../../../types/QueryKind.js"; + +describe('Drop Table Query', () => { + it('should create a basic DROP TABLE query', () => { + const query = new DropTableQuery('users').build(); + expect(query).toBe('DROP TABLE "users";'); + }); + + it('should be able to set table name later', () => { + const query = new DropTableQuery().table('products').build(); + expect(query).toBe('DROP TABLE "products";'); + }); + + it('should throw if table name is not provided', () => { + const query = new DropTableQuery(); + expect(() => query.build()).toThrow('Table name is required to build DROP TABLE query.'); + }); + + it('should create a DROP TABLE query with IF EXISTS', () => { + const query = new DropTableQuery('users').ifExists().build(); + expect(query).toBe('DROP TABLE IF EXISTS "users";'); + }); + + it('should clone the DropTableQuery instance', () => { + const original = new DropTableQuery('orders').ifExists(); + const cloned = original.clone(); + expect(cloned).not.toBe(original); + expect(cloned.build()).toBe(original.build()); + }); + + it('should reset the DropTableQuery instance', () => { + const query = new DropTableQuery('customers').ifExists(); + query.reset(); + expect(() => query.build()).toThrow('Table name is required to build DROP TABLE query.'); + }); + + it('should return the correct SQL string representation', () => { + const query = new DropTableQuery('employees').ifExists(); + expect(query.toSQL()).toBe('DROP TABLE IF EXISTS "employees";'); + }); + + it('should return kind as DROP_TABLE', () => { + const query = new DropTableQuery('departments'); + expect(query.kind).toBe(QueryKind.DROP_TABLE); + }); +}); diff --git a/src/queryKinds/ddl/table/Drop.ts b/src/queryKinds/ddl/table/Drop.ts new file mode 100644 index 0000000..f1a13ff --- /dev/null +++ b/src/queryKinds/ddl/table/Drop.ts @@ -0,0 +1,79 @@ +import SqlEscaper from "../../../sqlEscaper.js"; +import QueryKind from "../../../types/QueryKind.js"; +import TableQueryDefinition from "./tableColumnDefinition.js"; + +/** + * Class representing a DROP TABLE SQL query. + * This class allows you to define and build a DROP TABLE SQL query + * with specified table name and options like IF EXISTS. + */ +export default class DropTableQuery extends TableQueryDefinition { + constructor(tableName: string | null = null) { + super(); + this.tableName = tableName + ? SqlEscaper.escapeTableName(tableName, this.flavor) + : ""; + } + + public ifExists(): this { + this.ifNotExistsFlag = true; + return this; + } + + /** + * Builds the DROP TABLE SQL query string. + * @param _deepAnalysis - Optional boolean to indicate if deep analysis is required (default is false). + * @returns The constructed DROP TABLE SQL query string. + * @throws Error if the table name is not provided. + */ + public build(_deepAnalysis: boolean = false): string { + if (!this.tableName) { + throw new Error("Table name is required to build DROP TABLE query."); + } + let query = "DROP TABLE "; + if (this.ifNotExistsFlag) { + query += "IF EXISTS "; + } + query += `${this.tableName};`; + this.builtQuery = SqlEscaper.appendSchemas(query, this.schemas); + return this.builtQuery; + } + + /** + * Creates a clone of the current DropTableQuery instance. + * @returns A new DropTableQuery instance with the same properties as the current instance. + */ + public clone(): DropTableQuery { + const cloned = new DropTableQuery(); + cloned.tableName = this.tableName; + cloned.flavor = this.flavor; + cloned.ifNotExistsFlag = this.ifNotExistsFlag; + return cloned; + } + + /** + * Resets the DropTableQuery instance to its initial state. + * This method clears the table name, built query, and IF NOT EXISTS flag. + * @returns The current instance for method chaining. + */ + public reset(): this { + this.tableName = ""; + this.builtQuery = null; + this.resetIfNotExists(); + return this; + } + + /** + * Returns the SQL string representation of the DROP TABLE query. + * @returns The SQL string representation of the DROP TABLE query. + */ + public toSQL(): string { + if(!this.builtQuery) this.build(); + return this.builtQuery as string; + } + + /** Getter for the kind of query. */ + public get kind() { + return QueryKind.DROP_TABLE; + } +} diff --git a/src/queryKinds/ddl/table/index.ts b/src/queryKinds/ddl/table/index.ts new file mode 100644 index 0000000..514b140 --- /dev/null +++ b/src/queryKinds/ddl/table/index.ts @@ -0,0 +1,8 @@ +export * from "./Alter.js"; +export { default as AlterTableQuery } from "./Alter.js"; +export * from "./Create.js"; +export { default as CreateTableQuery } from "./Create.js"; +export * from "./Drop.js"; +export { default as DropTableQuery } from "./Drop.js"; +export * from "./tableColumnDefinition.js"; +export { default as TableColumnDefinition } from "./tableColumnDefinition.js"; diff --git a/src/queryKinds/ddl/table/tableColumnDefinition.ts b/src/queryKinds/ddl/table/tableColumnDefinition.ts new file mode 100644 index 0000000..c6f88d3 --- /dev/null +++ b/src/queryKinds/ddl/table/tableColumnDefinition.ts @@ -0,0 +1,24 @@ +import DdlQueryDefinition from "../ddlQueryDefinition.js"; + +export default abstract class TableQueryDefinition extends DdlQueryDefinition { + /** Flag indicating whether to include IF NOT EXISTS clause. */ + protected ifNotExistsFlag: boolean = false; + + /** + * Marks the table creation to include IF NOT EXISTS clause. + * @returns The current instance for method chaining. + */ + public ifNotExists(): this { + this.ifNotExistsFlag = true; + return this; + } + + /** + * Resets the IF NOT EXISTS clause for the table creation. + * @returns The current instance for method chaining. + */ + public resetIfNotExists(): this { + this.ifNotExistsFlag = false; + return this; + } +} diff --git a/src/queryKinds/delete.ts b/src/queryKinds/delete.ts deleted file mode 100644 index a2fd035..0000000 --- a/src/queryKinds/delete.ts +++ /dev/null @@ -1,294 +0,0 @@ -import CteMaker, { Cte } from "../cteMaker.js"; -import SqlEscaper from "../sqlEscaper.js"; -import Statement from "../statementMaker.js"; -import QueryKind from "../types/QueryKind.js"; -import UsingTable from "../types/UsingTable.js"; -import QueryDefinition from "./query.js"; - -/** - * DeleteQuery class represents a SQL DELETE query. - * It provides methods to build and manipulate the query, including specifying the table to delete from, - * adding USING clauses, WHERE conditions, RETURNING fields, and Common Table Expressions (CTEs). - * The class supports cloning, resetting, and building the final SQL query string with parameters. - */ -export default class DeleteQuery extends QueryDefinition { - /** The table from which records will be deleted. */ - private deletingFrom: string; - /** An optional alias for the table being deleted from. */ - private deletingFromAlias: string | null = null; - /** Tables to be used in the USING clause. */ - private usingTables: UsingTable[] = []; - /** The fields to be returned after the delete operation. */ - private returningFields: string[] = []; - - /** - * Creates an instance of DeleteQuery. - * @param from - The name of the table from which records will be deleted. - * @param alias - An optional alias for the table. - */ - constructor(from?: string, alias: string | null = null) { - super(); - this.deletingFrom = from ? SqlEscaper.escapeTableName(from, this.flavor) : ''; - this.deletingFromAlias = alias; - } - - /** - * Adds Common Table Expressions (CTEs) to the query. - * Accepts a CteMaker instance, a single Cte, or an array of Ctes. - * @param ctes - The CTEs to be added to the query. - * @returns The current DeleteQuery instance for method chaining. - */ - public with(ctes: CteMaker | Cte | Cte[]): this { - if (ctes instanceof CteMaker) { - this.ctes = ctes; - } else if (Array.isArray(ctes)) { - this.ctes = new CteMaker(...ctes); - } else { - this.ctes = new CteMaker(ctes); - } - return this; - } - - /** - * Specifies the table from which records will be deleted, with an optional alias. - * @param table - The name of the table. - * @param alias - An optional alias for the table. - * @returns The current DeleteQuery instance for method chaining. - */ - public from(table: string, alias: string | null = null): this { - this.deletingFrom = SqlEscaper.escapeTableName(table, this.flavor); - this.deletingFromAlias = alias; - return this; - } - - /** - * Adds tables to the USING clause. Accepts a string, a UsingTable object, or an array of UsingTable objects. - * @param tables - The table(s) to be added to the USING clause. - * @returns The current DeleteQuery instance for method chaining. - * @throws Error if an invalid table name is provided in string format. - */ - public using(tables: string | UsingTable | UsingTable[]): this { - if (Array.isArray(tables)) { - this.usingTables.push(...tables.map(t => ({ - table: SqlEscaper.escapeTableName(t.table, this.flavor), - alias: t.alias || null - }))); - } else if (typeof tables === 'string') { - const tableParts = tables.split(' '); - if (tableParts[0] && tableParts[0]?.trim() !== '') { - this.usingTables.push({ - table: SqlEscaper.escapeTableName(tableParts[0], this.flavor), - alias: tableParts[1] || null - }); - } else { - throw new Error('Invalid table name provided to USING clause.'); - } - } else { - this.usingTables.push({ - table: SqlEscaper.escapeTableName(tables.table, this.flavor), - alias: tables.alias || null - }); - } - return this; - } - - /** - * Specifies the WHERE clause for the DELETE query. - * Accepts either a Statement object or a raw SQL string with optional parameters. - * @param statement - The WHERE clause as a Statement or raw SQL string. - * @param values - Optional parameters for the raw SQL string. - * @returns The current DeleteQuery instance for method chaining. - */ - public where(statement: Statement | string, ...values: any[]): this { - if (typeof statement === 'string') { - statement = new Statement().raw('', statement, ...values); - } - - this.whereStatement = statement; - return this; - } - - /** - * Allows building the WHERE clause using a callback function that receives a Statement object. - * This provides a more fluent interface for constructing complex WHERE conditions. - * @param statement - A callback function that takes a Statement object and returns a modified Statement. - * @returns The current DeleteQuery instance for method chaining. - */ - public useStatement(statement: (stmt: Statement) => Statement | void): this { - const stmt = new Statement(); - const newStmt = statement(stmt) || stmt; - return this.where(newStmt); - } - - /** - * Specifies the fields to be returned after the delete operation. - * Accepts a string or an array of strings. - * @param fields - The field(s) to be returned. - * @returns The current DeleteQuery instance for method chaining. - */ - public returning(fields: string | string[]): this { - if (Array.isArray(fields)) { - this.returningFields = SqlEscaper.escapeSelectIdentifiers(fields, this.flavor); - } else { - this.returningFields = SqlEscaper.escapeSelectIdentifiers([fields], this.flavor); - } - return this; - } - - /** - * Adds fields to the existing RETURNING clause. - * Accepts a string or an array of strings. - * @param fields - The field(s) to be returned. - * @returns The current DeleteQuery instance for method chaining. - */ - public addReturning(fields: string | string[]): this { - if (Array.isArray(fields)) { - this.returningFields.push(...SqlEscaper.escapeSelectIdentifiers(fields, this.flavor)); - } else { - this.returningFields.push(...SqlEscaper.escapeSelectIdentifiers([fields], this.flavor)); - } - return this; - } - - /** - * Creates a deep clone of the current DeleteQuery instance. - * This is useful for creating variations of the query without modifying the original. - * @returns A new DeleteQuery instance with the same properties as the original. - */ - public clone(): DeleteQuery { - const cloned = new DeleteQuery(); - cloned.schemas = [...this.schemas]; - cloned.deletingFrom = this.deletingFrom; - cloned.deletingFromAlias = this.deletingFromAlias; - cloned.usingTables = JSON.parse(JSON.stringify(this.usingTables)); - cloned.whereStatement = this.whereStatement ? this.whereStatement.clone() : null; - cloned.returningFields = [...this.returningFields]; - cloned.ctes = this.ctes ? new CteMaker(...this.ctes['ctes']) : null; - return cloned; - } - - /** - * Resets the state of the DeleteQuery instance, clearing all configurations. - * This allows reusing the instance for building a new query from scratch. - * @returns void - */ - public reset(): void { - this.deletingFrom = ''; - this.schemas = []; - this.deletingFromAlias = null; - this.usingTables = []; - this.whereStatement = null; - this.returningFields = []; - this.ctes = null; - this.builtQuery = null; - } - - /** - * This a DELETE query. - * @returns The kind of SQL operation, which is 'DELETE' for this class. - */ - public get kind() { - return QueryKind.DELETE; - } - - /** - * Invalidates the current state of the query, forcing a rebuild on the next operation. - * @returns void - */ - public invalidate(): void { - this.builtQuery = null; - if (this.whereStatement) this.whereStatement.invalidate(); - if (this.ctes) { - for (const cte of this.ctes['ctes']) { - cte['query'].invalidate(); - } - } - } - - /** - * Builds the SQL DELETE query and returns an object containing the query text and its parameters. - * The optional deepAnalysis parameter can be used to control the depth of analysis during the build process. - * @param deepAnalysis - If true, performs a deeper analysis for duplicate parameters. - * @returns An object containing the query text and its parameters. - * @throws Error if no table is specified for the DELETE query. - */ - public build(deepAnalysis: boolean = false): { text: string; values: any[] } { - if (!this.deletingFrom.trim()) { - throw new Error('No table specified for DELETE query.'); - } - - this.whereStatement = this.whereStatement || new Statement(); - this.whereStatement.enableWhere(); - - let ctesClause = ''; - if (this.ctes) { - const ctesBuilt = this.ctes.build(); - ctesClause = ctesBuilt.text; - this.whereStatement.addParams(ctesBuilt.values); - } - - let deleteClause = `DELETE FROM ${this.deletingFrom}`; - if (this.deletingFromAlias) { - deleteClause += ` AS ${this.deletingFromAlias}`; - } - - let usingClause = ''; - if (this.usingTables.length > 0) { - const usingParts = this.usingTables.map(t => t.alias ? `${t.table} AS ${t.alias}` : t.table); - usingClause = `USING ${usingParts.join(',\n ')}`; - } - - let whereClause = ''; - let values: any[] = []; - if (this.whereStatement) { - const stmt = this.whereStatement.build(false); - whereClause = stmt.statement; - values = stmt.values; - } - - let returningClause = ''; - if (this.returningFields.length > 0) { - returningClause = `RETURNING ${this.returningFields.join(', ')}`; - } - - this.builtQuery = [ - ctesClause, - deleteClause, - usingClause, - whereClause, - returningClause - ].filter(part => part !== '') - .join('\n '); - - this.builtQuery = SqlEscaper.appendSchemas( - this.builtQuery, this.schemas - ); - - const analyzed = this.reAnalyzeParsedQueryForDuplicateParams(this.builtQuery, values, deepAnalysis); - this.builtQuery = analyzed.text; - this.builtParams = analyzed.values; - return { text: this.builtQuery, values: this.builtParams }; - } - - /** - * Builds the SQL DELETE query and returns the query text as a string. - * This method is useful for obtaining the raw SQL query without parameters. - * @returns The SQL DELETE query as a string. - */ - public toSQL(): string { - if(!this.builtQuery) this.build(); - if(!this.builtQuery) throw new Error('Failed to build query.'); - return this.builtQuery; - } - - /** - * Builds the SQL DELETE query and returns the parameters as an array. - * This method is useful for obtaining the parameters to be used with the SQL query. - * @returns An array of parameters for the SQL DELETE query. - */ - public getParams(): any[] { - if(!this.builtQuery) this.build(); - if(!this.builtQuery) throw new Error('Failed to build query.'); - return this.builtParams || []; - } -} diff --git a/src/queryKinds/delete.test.ts b/src/queryKinds/dml/delete.test.ts similarity index 95% rename from src/queryKinds/delete.test.ts rename to src/queryKinds/dml/delete.test.ts index 8dbd5f5..2f2246e 100644 --- a/src/queryKinds/delete.test.ts +++ b/src/queryKinds/dml/delete.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import DeleteQuery from "./delete.js"; -import Statement from "../statementMaker.js"; -import { Cte } from "../cteMaker.js"; +import Statement from "../../statementMaker.js"; +import { Cte } from "../../cteMaker.js"; import SelectQuery from "./select.js"; describe('Delete Query', () => { @@ -291,4 +291,14 @@ describe('Delete Query', () => { expect(query.getParams()).toEqual([1]); expect(query.toSQL()).toBe('DELETE FROM "users" AS u\n WHERE (u.id = $1)\n RETURNING "id", "email", "biscuit"'); }); + + it('should support returning all with returnAllFields', () => { + const query = new DeleteQuery('users', 'u') + .where('u.id = ?', 1) + .returnAllFields() + .build(); + + expect(query.text).toBe('DELETE FROM "users" AS u\n WHERE (u.id = $1)\n RETURNING *'); + expect(query.values).toEqual([1]); + }); }); diff --git a/src/queryKinds/dml/delete.ts b/src/queryKinds/dml/delete.ts new file mode 100644 index 0000000..518b896 --- /dev/null +++ b/src/queryKinds/dml/delete.ts @@ -0,0 +1,355 @@ +import CteMaker, { type Cte } from "../../cteMaker.js"; +import SqlEscaper from "../../sqlEscaper.js"; +import Statement from "../../statementMaker.js"; +import QueryKind from "../../types/QueryKind.js"; +import type UsingTable from "../../types/UsingTable.js"; +import DmlQueryDefinition from "./dmlQueryDefinition.js"; + +/** + * DeleteQuery class represents a SQL DELETE query. + * It provides methods to build and manipulate the query, including specifying the table to delete from, + * adding USING clauses, WHERE conditions, RETURNING fields, and Common Table Expressions (CTEs). + * The class supports cloning, resetting, and building the final SQL query string with parameters. + */ +export default class DeleteQuery extends DmlQueryDefinition { + /** The table from which records will be deleted. */ + private deletingFrom: string; + /** An optional alias for the table being deleted from. */ + private deletingFromAlias: string | null = null; + /** Tables to be used in the USING clause. */ + private usingTables: UsingTable[] = []; + /** The fields to be returned after the delete operation. */ + private returningFields: string[] = []; + /** Flag indicating whether to return all fields. */ + private returnAll: boolean = false; + + /** + * Creates an instance of DeleteQuery. + * @param from - The name of the table from which records will be deleted. + * @param alias - An optional alias for the table. + */ + constructor(from?: string, alias: string | null = null) { + super(); + this.deletingFrom = from + ? SqlEscaper.escapeTableName(from, this.flavor) + : ""; + this.deletingFromAlias = alias; + } + + /** + * Adds Common Table Expressions (CTEs) to the query. + * Accepts a CteMaker instance, a single Cte, or an array of Ctes. + * @param ctes - The CTEs to be added to the query. + * @returns The current DeleteQuery instance for method chaining. + */ + public with(ctes: CteMaker | Cte | Cte[]): this { + if (ctes instanceof CteMaker) { + this.ctes = ctes; + } else if (Array.isArray(ctes)) { + this.ctes = new CteMaker(...ctes); + } else { + this.ctes = new CteMaker(ctes); + } + return this; + } + + /** + * Specifies the table from which records will be deleted, with an optional alias. + * @param table - The name of the table. + * @param alias - An optional alias for the table. + * @returns The current DeleteQuery instance for method chaining. + */ + public from(table: string, alias: string | null = null): this { + this.deletingFrom = SqlEscaper.escapeTableName(table, this.flavor); + this.deletingFromAlias = alias; + return this; + } + + /** + * Adds tables to the USING clause. Accepts a string, a UsingTable object, or an array of UsingTable objects. + * @param tables - The table(s) to be added to the USING clause. + * @returns The current DeleteQuery instance for method chaining. + * @throws Error if an invalid table name is provided in string format. + */ + public using(tables: string | UsingTable | UsingTable[]): this { + if (Array.isArray(tables)) { + this.usingTables.push( + ...tables.map((t) => ({ + table: SqlEscaper.escapeTableName(t.table, this.flavor), + alias: t.alias || null, + })), + ); + } else if (typeof tables === "string") { + const tableParts = tables.split(" "); + if (tableParts[0] && tableParts[0]?.trim() !== "") { + this.usingTables.push({ + table: SqlEscaper.escapeTableName(tableParts[0], this.flavor), + alias: tableParts[1] || null, + }); + } else { + throw new Error("Invalid table name provided to USING clause."); + } + } else { + this.usingTables.push({ + table: SqlEscaper.escapeTableName(tables.table, this.flavor), + alias: tables.alias || null, + }); + } + return this; + } + + /** + * Specifies the WHERE clause for the DELETE query. + * Accepts either a Statement object or a raw SQL string with optional parameters. + * @param statement - The WHERE clause as a Statement or raw SQL string. + * @param values - Optional parameters for the raw SQL string. + * @returns The current DeleteQuery instance for method chaining. + */ + public where(statement: Statement | string, ...values: any[]): this { + if (typeof statement === "string") { + statement = new Statement().raw("", statement, ...values); + } + + this.whereStatement = statement; + return this; + } + + /** + * Allows building the WHERE clause using a callback function that receives a Statement object. + * This provides a more fluent interface for constructing complex WHERE conditions. + * @param statement - A callback function that takes a Statement object and returns a modified Statement. + * @returns The current DeleteQuery instance for method chaining. + */ + public useStatement( + statement: (stmt: Statement) => Statement | undefined | void, + ): this { + const stmt = new Statement(); + const newStmt = statement(stmt) || stmt; + return this.where(newStmt); + } + + /** + * Specifies that all fields should be returned after the delete operation. + * @returns The current DeleteQuery instance for method chaining. + */ + public returnAllFields(): this { + this.returnAll = true; + this.returningFields = []; + return this; + } + + /** + * Specifies the fields to be returned after the delete operation. + * Accepts a string or an array of strings. + * @param fields - The field(s) to be returned. + * @returns The current DeleteQuery instance for method chaining. + */ + public returning(fields: string | string[]): this { + this.returningRaw( + SqlEscaper.escapeSelectIdentifiers( + Array.isArray(fields) ? fields : [fields], + this.flavor, + ), + ); + return this; + } + + /** + * Adds fields to the existing RETURNING clause. + * Accepts a string or an array of strings. + * @param fields - The field(s) to be returned. + * @returns The current DeleteQuery instance for method chaining. + */ + public addReturning(fields: string | string[]): this { + this.addReturningRaw( + SqlEscaper.escapeSelectIdentifiers( + Array.isArray(fields) ? fields : [fields], + this.flavor, + ), + ); + return this; + } + + /** + * Specifies raw fields to be returned after the delete operation without any escaping. + * Accepts a string or an array of strings. + * @param fields - The raw field(s) to be returned. + * @returns The current DeleteQuery instance for method chaining. + */ + public returningRaw(fields: string | string[]): this { + this.returnAll = false; + if (Array.isArray(fields)) { + this.returningFields = fields; + } else { + this.returningFields = [fields]; + } + return this; + } + + /** + * Adds raw fields to the existing RETURNING clause without any escaping. + * Accepts a string or an array of strings. + * @param field - The raw field(s) to be added to the RETURNING clause. + * @returns The current DeleteQuery instance for method chaining. + */ + public addReturningRaw(field: string | string[]): this { + this.returnAll = false; + if (Array.isArray(field)) { + this.returningFields.push(...field); + } else { + this.returningFields.push(field); + } + return this; + } + + /** + * Creates a deep clone of the current DeleteQuery instance. + * This is useful for creating variations of the query without modifying the original. + * @returns A new DeleteQuery instance with the same properties as the original. + */ + public clone(): DeleteQuery { + const cloned = new DeleteQuery(); + cloned.schemas = [...this.schemas]; + cloned.deletingFrom = this.deletingFrom; + cloned.deletingFromAlias = this.deletingFromAlias; + cloned.usingTables = JSON.parse(JSON.stringify(this.usingTables)); + cloned.whereStatement = this.whereStatement + ? this.whereStatement.clone() + : null; + cloned.returningFields = [...this.returningFields]; + cloned.ctes = this.ctes ? new CteMaker(...this.ctes["ctes"]) : null; + cloned.returnAll = this.returnAll; + return cloned; + } + + /** + * Resets the state of the DeleteQuery instance, clearing all configurations. + * This allows reusing the instance for building a new query from scratch. + * @returns void + */ + public reset(): void { + this.deletingFrom = ""; + this.schemas = []; + this.deletingFromAlias = null; + this.usingTables = []; + this.whereStatement = null; + this.returningFields = []; + this.ctes = null; + this.builtQuery = null; + this.returnAll = false; + } + + /** + * This a DELETE query. + * @returns The kind of SQL operation, which is 'DELETE' for this class. + */ + public get kind() { + return QueryKind.DELETE; + } + + /** + * Invalidates the current state of the query, forcing a rebuild on the next operation. + * @returns void + */ + public override invalidate(): void { + this.builtQuery = null; + if (this.whereStatement) this.whereStatement.invalidate(); + if (this.ctes) { + for (const cte of this.ctes["ctes"]) { + cte["query"].invalidate(); + } + } + } + + /** + * Builds the SQL DELETE query and returns an object containing the query text and its parameters. + * The optional deepAnalysis parameter can be used to control the depth of analysis during the build process. + * @param deepAnalysis - If true, performs a deeper analysis for duplicate parameters. + * @returns An object containing the query text and its parameters. + * @throws Error if no table is specified for the DELETE query. + */ + public build(deepAnalysis: boolean = false): { text: string; values: any[] } { + if (!this.deletingFrom.trim()) { + throw new Error("No table specified for DELETE query."); + } + + this.whereStatement = this.whereStatement || new Statement(); + this.whereStatement.enableWhere(); + + let ctesClause = ""; + if (this.ctes) { + const ctesBuilt = this.ctes.build(); + ctesClause = ctesBuilt.text; + this.whereStatement.addParams(ctesBuilt.values); + } + + let deleteClause = `DELETE FROM ${this.deletingFrom}`; + if (this.deletingFromAlias) { + deleteClause += ` AS ${this.deletingFromAlias}`; + } + + let usingClause = ""; + if (this.usingTables.length > 0) { + const usingParts = this.usingTables.map((t) => + t.alias ? `${t.table} AS ${t.alias}` : t.table, + ); + usingClause = `USING ${usingParts.join(",\n ")}`; + } + + let whereClause = ""; + let values: any[] = []; + if (this.whereStatement) { + const stmt = this.whereStatement.build(false); + whereClause = stmt.statement; + values = stmt.values; + } + + let returningClause = ""; + if (this.returningFields.length > 0) { + returningClause = `RETURNING ${this.returningFields.join(", ")}`; + } + + this.builtQuery = [ + ctesClause, + deleteClause, + usingClause, + whereClause, + returningClause || (this.returnAll ? "RETURNING *" : ""), + ] + .filter((part) => part !== "") + .join("\n "); + + this.builtQuery = SqlEscaper.appendSchemas(this.builtQuery, this.schemas); + + const analyzed = this.reAnalyzeParsedQueryForDuplicateParams( + this.builtQuery, + values, + deepAnalysis, + ); + this.builtQuery = analyzed.text; + this.builtParams = analyzed.values; + return { text: this.builtQuery, values: this.builtParams }; + } + + /** + * Builds the SQL DELETE query and returns the query text as a string. + * This method is useful for obtaining the raw SQL query without parameters. + * @returns The SQL DELETE query as a string. + */ + public toSQL(): string { + if (!this.builtQuery) this.build(); + if (!this.builtQuery) throw new Error("Failed to build query."); + return this.builtQuery; + } + + /** + * Builds the SQL DELETE query and returns the parameters as an array. + * This method is useful for obtaining the parameters to be used with the SQL query. + * @returns An array of parameters for the SQL DELETE query. + */ + public getParams(): any[] { + if (!this.builtQuery) this.build(); + if (!this.builtQuery) throw new Error("Failed to build query."); + return this.builtParams || []; + } +} diff --git a/src/queryKinds/query.test.ts b/src/queryKinds/dml/dmlQueryDefinition.test.ts similarity index 56% rename from src/queryKinds/query.test.ts rename to src/queryKinds/dml/dmlQueryDefinition.test.ts index 7bad285..d0baec9 100644 --- a/src/queryKinds/query.test.ts +++ b/src/queryKinds/dml/dmlQueryDefinition.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import SelectQuery from "./select.js"; -import sqlFlavor from "../types/sqlFlavor.js"; +import sqlFlavor from "../../types/sqlFlavor.js"; import UpdateQuery from "./update.js"; import InsertQuery from "./insert.js"; import DeleteQuery from "./delete.js"; @@ -9,7 +9,7 @@ import { IsDefined, IsNumber, IsString } from "class-validator"; import { Transform } from "class-transformer"; -describe('Query Definition', () => { +describe('DML Query Definition', () => { it('should be able to set sql flavor', () => { const query = new SelectQuery() .from('users') @@ -200,4 +200,125 @@ describe('Query Definition', () => { await queryClass.execute(executorFunction); }).rejects.toThrowError(); }); + + it('should handle execution with strange runner outputs', async () => { + const query = new InsertQuery('users') + .values({ name: 'John', age: 30 }) + .returning(['id', 'name', 'age']); + + + const executorFunctionUndefined = async (text: string, values: any[]) => { + expect(text).toBe('INSERT INTO "users" ("name", "age") VALUES ($1, $2)\nRETURNING "id", "name", "age"'); + expect(values).toEqual(['John', 30]); + return [ + [{ id: 1, name: 'John', age: 30 }], 1 + ]; + } + + const resultUndefined = await query.execute(executorFunctionUndefined); + expect(resultUndefined).toEqual([{ id: 1, name: 'John', age: 30 }]); + + const executorFunctionNoArray = async (text: string, values: any[]): Promise => { + expect(text).toBe('INSERT INTO "users" ("name", "age") VALUES ($1, $2)\nRETURNING "id", "name", "age"'); + expect(values).toEqual(['John', 30]); + return { id: 1, name: 'John', age: 30 }; + } + + const resultNoArray = await query.execute(executorFunctionNoArray); + expect(resultNoArray).toEqual([{ id: 1, name: 'John', age: 30 }]); + + const executorFunctionNull = async (text: string, values: any[]): Promise => { + expect(text).toBe('INSERT INTO "users" ("name", "age") VALUES ($1, $2)\nRETURNING "id", "name", "age"'); + expect(values).toEqual(['John', 30]); + return null; + } + + const resultNull = await query.execute(executorFunctionNull); + expect(resultNull).toEqual([]); + + const executorFunctionRowsUndefined = async (text: string, values: any[]): Promise => { + expect(text).toBe('INSERT INTO "users" ("name", "age") VALUES ($1, $2)\nRETURNING "id", "name", "age"'); + expect(values).toEqual(['John', 30]); + return { rows: undefined }; + } + + const resultRowsUndefined = await query.execute(executorFunctionRowsUndefined); + expect(resultRowsUndefined).toEqual([]); + + const executorFunctionRowsNoArray = async (text: string, values: any[]): Promise => { + expect(text).toBe('INSERT INTO "users" ("name", "age") VALUES ($1, $2)\nRETURNING "id", "name", "age"'); + expect(values).toEqual(['John', 30]); + return { rows: { id: 1, name: 'John', age: 30 } }; + } + + const resultRowsNoArray = await query.execute(executorFunctionRowsNoArray); + expect(resultRowsNoArray).toEqual([ + { id: 1, name: 'John', age: 30 } + ]); + + const executorFunctionRows1 = async (text: string, values: any[]): Promise => { + expect(text).toBe('INSERT INTO "users" ("name", "age") VALUES ($1, $2)\nRETURNING "id", "name", "age"'); + expect(values).toEqual(['John', 30]); + return { rows: 1 }; + } + + await expect(async () => { + await query.execute(executorFunctionRows1); + }).rejects.toThrowError('Invalid rows property in result from query executor function.'); + + const executorFunction1 = async (text: string, values: any[]): Promise => { + expect(text).toBe('INSERT INTO "users" ("name", "age") VALUES ($1, $2)\nRETURNING "id", "name", "age"'); + expect(values).toEqual(['John', 30]); + return 1; + } + + await expect(async () => { + await query.execute(executorFunction1); + }).rejects.toThrowError('Invalid result from query executor function.'); + }); + + it('should support getting one and getting many', async () => { + const query = new InsertQuery('users') + .values({ name: 'John', age: 30 }) + .returning(['id', 'name', 'age']); + + const executorFunction = async (text: string, values: any[]) => { + expect(text).toBe('INSERT INTO "users" ("name", "age") VALUES ($1, $2)\nRETURNING "id", "name", "age"'); + expect(values).toEqual(['John', 30]); + return [{ id: 1, name: 'John', age: 30 }, { id: 2, name: 'Jane', age: 25 }]; + } + + const resultMany = await query.getMany(executorFunction); + expect(resultMany).toEqual([{ id: 1, name: 'John', age: 30 }, { id: 2, name: 'Jane', age: 25 }]); + + const resultOne = await query.getOne(executorFunction); + expect(resultOne).toEqual({ id: 1, name: 'John', age: 30 }); + + const executorFunctionEmpty = async (text: string, values: any[]) => { + expect(text).toBe('INSERT INTO "users" ("name", "age") VALUES ($1, $2)\nRETURNING "id", "name", "age"'); + expect(values).toEqual(['John', 30]); + return []; + } + + const resultManyEmpty = await query.getMany(executorFunctionEmpty); + expect(resultManyEmpty).toEqual([]); + + const resultOneEmpty = await query.getOne(executorFunctionEmpty); + expect(resultOneEmpty).toBeNull(); + + // In select queries it should add limit of 1 + const selectQuery = new SelectQuery() + .from('users') + .select(['id', 'name', 'age']) + .where('age > ?', 18); + + const executorFunctionSelect = async (text: string, values: any[]) => { + expect(text).toBe('SELECT\n "id",\n "name",\n "age"\nFROM "users"\nWHERE (age > $1)\nLIMIT 1'); + expect(values).toEqual([18]); + return [{ id: 1, name: 'John', age: 30 }, { id: 2, name: 'Jane', age: 25 }]; + } + + const resultOneSelect = await selectQuery.getOne(executorFunctionSelect); + expect(resultOneSelect).toEqual({ id: 1, name: 'John', age: 30 }); + }); }); diff --git a/src/queryKinds/dml/dmlQueryDefinition.ts b/src/queryKinds/dml/dmlQueryDefinition.ts new file mode 100644 index 0000000..758e51c --- /dev/null +++ b/src/queryKinds/dml/dmlQueryDefinition.ts @@ -0,0 +1,612 @@ +import type { ValidatorOptions } from "class-validator"; +// Import types only since they are used for type checking only +// and zod is optional peer dependency +import type z from "zod"; +import type CteMaker from "../../cteMaker.js"; +import deepEqual from "../../deepEqual.js"; +import { getClassValidator, getZod } from "../../getOptionalPackages.js"; +import SqlEscaper from "../../sqlEscaper.js"; +import type Statement from "../../statementMaker.js"; +import type Join from "../../types/Join.js"; +import { isJoinTable } from "../../types/Join.js"; +import type QueryKind from "../../types/QueryKind.js"; +import sqlFlavor from "../../types/sqlFlavor.js"; + +/** + * An array of function names that can be used to execute SQL queries. + * These functions are commonly found in database client libraries. + */ +const functionNames = ["execute", "query", "run", "all", "get"] as const; + +/** + * FunctionDeclarationReturnType type defines the possible return types for functions that execute SQL queries. + * It can be an array of results, an object containing a rows property with the results, + * or a tuple containing an array of results and a number (e.g., for affected rows). + */ +type FunctionDeclarationReturnType = T[] | { rows: T[] } | [T[], number]; + +/** + * FunctionDeclaration type defines the signature for functions that execute SQL queries. + * It takes a query string and an array of parameters, and returns a promise that resolves + * with a result of type FunctionDeclarationReturnType or directly a result of that type. + */ +type FunctionDeclaration = ( + query: string, + params: any[], +) => + | Promise> + | FunctionDeclarationReturnType; + +/** + * QueryExecutorObject interface defines the structure for an object that can execute SQL queries. + * It includes optional methods for executing queries in different ways, as well as an optional manager property. + */ +interface QueryExecutorObject { + execute?: FunctionDeclaration; + query?: FunctionDeclaration; + run?: FunctionDeclaration; + all?: FunctionDeclaration; + get?: FunctionDeclaration; + manager?: QueryExecutor; +} + +/** + * QueryExecutor type can be either a QueryExecutorObject or a function that executes a query. + */ +export type QueryExecutor = QueryExecutorObject | FunctionDeclaration; + +/** + * SchemaType is a conditional type that infers the type of data based on the provided schema. + * It supports both Zod schemas and class-validator classes. + */ +type SchemaType = S extends { safeParse: Function } + ? z.infer + : S extends { new (): infer U } + ? U + : never; + +/** + * OmittingReturnFromValidate is a utility type that modifies the type T by omitting + * the methods 'execute', 'getOne', and 'getMany', and adding the DmlQueryDefinition with the inferred schema type. + */ +type ReturnFromValidate = DmlQueryDefinition> & + This; + +/** + * Abstract class DmlQueryDefinition serves as a blueprint for different types of SQL query definitions. + * It defines the essential methods and properties that any concrete query class must implement. + * This includes methods for building the SQL query, executing it, cloning the query definition, + * resetting its state, and checking if the query is complete. + * The class also provides a method to re-analyze the query for duplicate parameters to optimize parameter usage. + */ +export default abstract class DmlQueryDefinition { + /** + * Converts the query definition to its SQL string representation. + */ + public abstract toSQL(): string; + + /** + * Retrieves the parameters associated with the query. + */ + public abstract getParams(): any[]; + + /** + * Builds the SQL query and returns an object containing the query text and its parameters. + * The optional deepAnalysis parameter can be used to control the depth of analysis during the build process. + */ + public abstract build(deepAnalysis?: boolean): { + /** The SQL query text. */ + text: string; + /** The parameters for the SQL query. */ + values: any[]; + }; + + /** + * Creates a deep copy of the current query definition. + */ + public abstract clone(): DmlQueryDefinition; + + /** + * Resets the query definition to its initial state. + */ + public abstract reset(): void; + + /** + * Indicates whether the query is complete and ready for execution. + * @returns True if the query has been built and has parameters, false otherwise. + */ + public get isDone(): boolean { + return this.builtQuery !== null && this.builtParams !== null; + } + + /** + * Parses a Join object to ensure proper escaping and cloning of subqueries. + * @param join The Join object to parse. + * @returns The parsed Join object. + */ + protected parseJoinObject(join: Join): Join { + if (isJoinTable(join)) { + return { + ...join, + table: SqlEscaper.escapeTableName(join.table, this.flavor), + }; + } else { + return { + ...join, + subQuery: join.subQuery.clone(), + }; + } + } + + /** + * The kind of SQL operation represented by the query definition. + * It can be one of 'INSERT', 'UPDATE', 'DELETE', or 'SELECT'. + */ + public abstract get kind(): QueryKind; + + /** + * Provides access to the current query definition instance. + * @returns The current DmlQueryDefinition instance. + */ + public get query(): DmlQueryDefinition { + return this; + } + + /** + * Utility method to add spaces to each line of a given string. + * This is useful for formatting SQL queries for better readability. + * @param str The string to format. + * @param spaces The number of spaces to add to the beginning of each line (default is 0). + * @returns The formatted string with added spaces. + */ + protected spaceLines(str: string, spaces: number = 0): string { + const space = " ".repeat(spaces); + return str + .split("\n") + .map((line) => space + line) + .join("\n"); + } + + /** + * The SQL flavor to use for escaping identifiers. + * Default is PostgreSQL. + */ + protected flavor: sqlFlavor = sqlFlavor.postgres; + + /** + * Schemas to be used in the query. + * This is useful for databases that support multiple schemas. + * NOTICE: SQL Injection is not checked in schema names. Be sure to use only trusted schema names. + */ + protected schemas: string[] = []; + + /** + * The built SQL query string. + * Null if the query has not been built yet or has been invalidated. + */ + protected builtQuery: string | null = null; + + /** + * The parameters for the built SQL query. + * Null if the query has not been built yet or has been invalidated. + */ + protected builtParams: any[] | null = null; + + /** Optional Common Table Expressions (CTEs) for the query. */ + protected ctes: CteMaker | null = null; + + /** The WHERE clause statement. */ + protected whereStatement: Statement | null = null; + + /** + * Invalidates the current state of the query, forcing a rebuild on the next operation. + * @returns void + */ + public invalidate(): void { + this.builtQuery = null; + this.builtParams = null; + if (this.whereStatement) this.whereStatement.invalidate(); + if (this.ctes) { + for (const cte of this.ctes["ctes"]) { + cte["query"].invalidate(); + } + } + } + + /** + * Sets the SQL flavor for escaping identifiers. + * @param flavor The SQL flavor to set. + * @returns The current DmlQueryDefinition instance for chaining. + */ + public sqlFlavor(flavor: sqlFlavor): this { + this.flavor = flavor; + return this; + } + + /** + * Set schemas to be used in the query. + * This is useful for databases that support multiple schemas. + * NOTICE: SQL Injection is not checked in schema names. Be sure to use only trusted schema names. + * @param schemas The schemas to set. + * @returns The current SelectQuery instance for chaining. + */ + public schema(...schemas: string[]): this { + this.schemas = schemas; + return this; + } + + /** + * Adds schemas to the existing list of schemas. + * This is useful for databases that support multiple schemas. + * NOTICE: SQL Injection is not checked in schema names. Be sure to use only trusted schema names. + * @param schemas The schemas to add. + * @returns The current SelectQuery instance for chaining. + */ + public addSchema(...schemas: string[]): this { + this.schemas.push(...schemas); + return this; + } + + /** + * True if the schema to validate against is a Zod schema, false otherwise. + */ + private isZodSchema: boolean = false; + + /** + * True if the schema to validate against is a class-validator schema, false otherwise. + */ + private isClassValidatorSchema: boolean = false; + + /** + * The schema to validate against, can be either a Zod schema or a class-validator class. + * Null if no schema is set. + */ + private validatorSchema: any = null; + + /** + * Options for class-validator validation. + * Default options are set to whitelist properties, forbid non-whitelisted properties, + * and exclude the target object from validation errors. + */ + private classValidatorOptions: ValidatorOptions = { + whitelist: true, + forbidNonWhitelisted: false, + validationError: { target: false }, + }; + + /** + * Use zod or class-validator + class-transformer to validate the input schema. + * @param schema The schema to validate against. + * @returns A promise that resolves if the schema is valid, or rejects with validation errors. + * @throws An error if no validation library is available. + */ + public validate< + T extends { safeParse: Function } | (new (...args: any[]) => any), + >(schema: T): ReturnFromValidate { + if ("safeParse" in schema) { + this.isZodSchema = true; + } else { + this.isClassValidatorSchema = true; + } + this.validatorSchema = schema; + + return this as ReturnFromValidate; + } + + /** + * Configures options for class-validator validation. + * @param config The configuration options for class-validator. + * @returns The current DmlQueryDefinition instance for method chaining. + */ + public classValidatorConfig(config: ValidatorOptions): this { + this.classValidatorOptions = config; + return this; + } + + /** + * Handles validation of the input data against the set schema. + * Supports both Zod schemas and class-validator classes. + * @param input The data to validate. + * @returns A promise that resolves if the data is valid, or rejects with validation errors. + * @throws An error if no validation library is available. + */ + private async handleValidation(input: any): Promise { + input = Array.isArray(input) ? input : [input]; + + input = + input !== null + ? Array.isArray(input) && + input.length === 2 && + Array.isArray(input?.[0]) && + typeof input?.[1] === "number" + ? (input[0] ?? null) + : (input ?? null) + : input; + + try { + if (this.validatorSchema) { + if (this.isZodSchema) { + const zod = await getZod(); + input = await zod.array(this.validatorSchema).parseAsync(input); + } else if (this.isClassValidatorSchema) { + const { classValidator, classTransformer } = + await getClassValidator(); + const { validateOrReject } = classValidator; + input = classTransformer.plainToInstance(this.validatorSchema, input); + for (const item of input as any[]) { + // Use class-validator to validate each item + await validateOrReject(item as any, this.classValidatorOptions); + } + } + } + } catch (error) { + console.group("Validation Error"); + console.error("Error during validation:", error); + console.debug("Input data:", input); + console.debug("Validator schema:", this.validatorSchema); + console.groupEnd(); + + throw new Error("Validation failed. See console for details.", { + cause: { + originalError: error, + input, + schema: this.validatorSchema, + }, + }); + } + + return input; + } + + /** + * Builds the SQL query and re-analyzes it for duplicate parameters. + * This method ensures that the query is optimized by removing redundant parameters. + * @returns An object containing the optimized query text and its parameters. + * @throws An error if the build process fails. + */ + public buildReanalyze(): { text: string; values: any[] } { + const query = this.build(); + return this.reAnalyzeParsedQueryForDuplicateParams( + query.text, + query.values, + ); + } + + /** + * Executes the built SQL query using the provided query executor. + * The query executor can be a function or an object with methods to execute the query. + * The optional noManager parameter can be used to bypass the manager property if present. + * @param queryExecutor The executor to run the SQL query. + * @param noManager If true, bypasses the manager property of the executor object. + * @returns A promise that resolves with the result of the query execution. + * @throws An error if the provided query executor is invalid or if validation fails. + */ + public async execute( + queryExecutor: QueryExecutor, + noManager: boolean = false, + ): Promise { + if (typeof queryExecutor === "function") { + const builtQuery = this.build(); + const result = await queryExecutor(builtQuery.text, builtQuery.values); + if (result === undefined || result === null) return []; + + if (typeof result !== "object") { + throw new Error("Invalid result from query executor function."); + } + + if ("rows" in result) { + if (result.rows === undefined || result.rows === null) return []; + + if (typeof result.rows !== "object") { + throw new Error( + "Invalid rows property in result from query executor function.", + ); + } + + return await this.handleValidation(result.rows); + } else { + return await this.handleValidation(result); + } + } + + if ( + !noManager && + queryExecutor?.manager && + typeof queryExecutor?.manager === "object" + ) { + for (const functionName of functionNames) { + if (typeof queryExecutor.manager[functionName] === "function") { + const builtQuery = this.build(); + const result = await queryExecutor.manager[functionName]!( + builtQuery.text, + builtQuery.values, + ); + if (result === undefined || result === null) return []; + + if (typeof result !== "object") { + throw new Error( + "Invalid result from query executor manager function.", + ); + } + + if ("rows" in result) { + if (result.rows === undefined || result.rows === null) return []; + + if (typeof result.rows !== "object") { + throw new Error( + "Invalid rows property in result from query executor manager function.", + ); + } + + return await this.handleValidation(result.rows); + } else { + return await this.handleValidation(result); + } + } + } + } else { + for (const functionName of functionNames) { + if (typeof queryExecutor[functionName] === "function") { + const builtQuery = this.build(); + const result = await queryExecutor[functionName]!( + builtQuery.text, + builtQuery.values, + ); + if (!result) return []; + + if (typeof result !== "object") { + throw new Error( + "Invalid result from query executor object function.", + ); + } + + if ("rows" in result) { + if (result.rows === undefined || result.rows === null) return []; + + if (typeof result.rows !== "object") { + throw new Error( + "Invalid rows property in result from query executor manager function.", + ); + } + + return await this.handleValidation(result.rows); + } else { + return await this.handleValidation(result); + } + } + } + } + + throw new Error("Invalid query executor provided."); + } + + /** + * Executes the built SQL query and returns a single result or null if no result is found. + * This method ensures that only one result is returned by applying a limit if necessary. + * @param queryExecutor The executor to run the SQL query. + * @param noManager If true, bypasses the manager property of the executor object. + * @returns A promise that resolves with a single result or null. + * @throws An error if the provided query executor is invalid or if validation fails. + */ + public async getOne( + queryExecutor: QueryExecutor, + noManager: boolean = false, + ): Promise { + if ( + "limit" in this && + typeof this.limit === "function" && + this.limit instanceof Function && + this.limit(1) !== this + ) { + this.limit(1); + } + + const result = await this.execute(queryExecutor, noManager); + + return result !== null + ? Array.isArray(result) && + result.length === 2 && + Array.isArray(result?.[0]) && + typeof result?.[1] === "number" + ? ((result[0][0] as T) ?? null) + : ((result[0] as T) ?? null) + : null; + } + + /** + * Executes the built SQL query and returns multiple results. + * @param queryExecutor The executor to run the SQL query. + * @param noManager If true, bypasses the manager property of the executor object. + * @returns A promise that resolves with an array of results. + * @throws An error if the provided query executor is invalid or if validation fails. + */ + public async getMany( + queryExecutor: QueryExecutor, + noManager: boolean = false, + ): Promise { + return this.execute(queryExecutor, noManager); + } + + /** + * Builds the SQL query with EXPLAIN ANALYZE prefix for performance analysis. + * This method is useful for debugging and optimizing SQL queries. + */ + public buildExplainAnalyze(): { text: string; values: any[] } { + const query = this.build(); + return { + text: `EXPLAIN ANALYZE ${query.text}`, + values: query.values, + }; + } + + /** + * Builds the SQL query with EXPLAIN prefix for query plan analysis. + * This method is useful for understanding how the database will execute the query. + */ + public buildExplain(): { text: string; values: any[] } { + const query = this.build(); + return { + text: `EXPLAIN ${query.text}`, + values: query.values, + }; + } + + /** + * Re-analyzes the parsed SQL query to identify and consolidate duplicate parameters. + * This method helps optimize the query by reducing the number of parameters used. + * It can perform deep equality checks if specified. + */ + protected reAnalyzeParsedQueryForDuplicateParams( + query: string, + values: any[], + useDeepEqual: boolean = false, + ): { text: string; values: any[] } { + return DmlQueryDefinition.reAnalyzeParsedQueryForDuplicateParams( + query, + values, + useDeepEqual, + ); + } + + /** + * Static method to re-analyze a parsed SQL query for duplicate parameters. + * This method can be used independently of any instance of DmlQueryDefinition. + */ + public static reAnalyzeParsedQueryForDuplicateParams( + query: string, + values: any[], + useDeepEqual: boolean = false, + ): { text: string; values: any[] } { + const valueMap: Map = new Map(); + let paramIndex = 1; + const newValues: any[] = []; + + const newQuery = query.replace(/\$(\d+)/g, (_, p1) => { + const originalValue = values[parseInt(p1, 10) - 1]; + let foundKey: any = null; + + if (useDeepEqual) { + for (const [key] of valueMap) { + if (deepEqual(key, originalValue)) { + foundKey = key; + break; + } + } + } else { + if (valueMap.has(originalValue)) { + foundKey = originalValue; + } + } + + if (foundKey !== null) { + return `$${valueMap.get(foundKey)}`; + } else { + valueMap.set(originalValue, paramIndex); + newValues.push(originalValue); + return `$${paramIndex++}`; + } + }); + + return { text: newQuery, values: newValues }; + } +} diff --git a/src/queryKinds/dml/index.ts b/src/queryKinds/dml/index.ts new file mode 100644 index 0000000..8d72849 --- /dev/null +++ b/src/queryKinds/dml/index.ts @@ -0,0 +1,12 @@ +export * from "./delete.js"; +export { default as DeleteQuery } from "./delete.js"; +export * from "./dmlQueryDefinition.js"; +export { default as DmlQueryDefinition } from "./dmlQueryDefinition.js"; +export * from "./insert.js"; +export { default as InsertQuery } from "./insert.js"; +export * from "./select.js"; +export { default as SelectQuery } from "./select.js"; +export * from "./union.js"; +export { default as Union } from "./union.js"; +export * from "./update.js"; +export { default as UpdateQuery } from "./update.js"; diff --git a/src/queryKinds/insert.test.ts b/src/queryKinds/dml/insert.test.ts similarity index 94% rename from src/queryKinds/insert.test.ts rename to src/queryKinds/dml/insert.test.ts index 886730f..78e6c17 100644 --- a/src/queryKinds/insert.test.ts +++ b/src/queryKinds/dml/insert.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import InsertQuery from "./insert.js"; import SelectQuery from "./select.js"; -import { Cte } from "../cteMaker.js"; +import { Cte } from "../../cteMaker.js"; describe('Insert Query', () => { @@ -197,4 +197,14 @@ describe('Insert Query', () => { expect(secondBuild.values).toEqual(['2024-01-01']); }); + it('should support returning all with returnAllFields', () => { + const query = new InsertQuery('users') + .values({ name: 'Alice', age: 28 }) + .returnAllFields() + .build(); + + expect(query.text).toBe('INSERT INTO "users" ("name", "age") VALUES ($1, $2)\nRETURNING *'); + expect(query.values).toEqual(['Alice', 28]); + }); + }); diff --git a/src/queryKinds/dml/insert.ts b/src/queryKinds/dml/insert.ts new file mode 100644 index 0000000..3beb932 --- /dev/null +++ b/src/queryKinds/dml/insert.ts @@ -0,0 +1,359 @@ +import CteMaker, { type Cte } from "../../cteMaker.js"; +import SqlEscaper from "../../sqlEscaper.js"; +import type ColumnValue from "../../types/ColumnValue.js"; +import QueryKind from "../../types/QueryKind.js"; +import DmlQueryDefinition from "./dmlQueryDefinition.js"; +import type SelectQuery from "./select.js"; + +/** + * InsertQuery helps in constructing SQL INSERT queries. + * It supports inserting values directly or from a SELECT query. + * It also supports Common Table Expressions (CTEs) and RETURNING clauses. + */ +export default class InsertQuery extends DmlQueryDefinition { + /** The table into which records will be inserted. */ + private table: string; + /** The column-value pairs to be inserted. */ + private columnValues: ColumnValue[] = []; + /** An optional SELECT query to insert data from. */ + private selectQuery: SelectQuery | null = null; + /** The fields to be returned after the insert operation. */ + private returningFields: string[] = []; + /** Flag indicating whether the query returns all fields. */ + private returnAll: boolean = false; + + /** + * Creates an instance of InsertQuery. + * @param table - The name of the table into which records will be inserted. + */ + constructor(table?: string) { + super(); + this.table = table ? SqlEscaper.escapeTableName(table, this.flavor) : ""; + } + + /** + * Adds Common Table Expressions (CTEs) to the query. + * Accepts a CteMaker instance, a single Cte, or an array of Ctes. + * @param ctes - The CTEs to be added to the query. + * @returns The current InsertQuery instance for method chaining. + */ + public with(ctes: CteMaker | Cte | Cte[]): this { + if (ctes instanceof CteMaker) { + this.ctes = ctes; + } else if (Array.isArray(ctes)) { + this.ctes = new CteMaker(...ctes); + } else { + this.ctes = new CteMaker(ctes); + } + return this; + } + + /** + * Specifies the table into which records will be inserted. + * @param table - The name of the table. + * @returns The current InsertQuery instance for method chaining. + */ + public into(table: string): this { + this.table = SqlEscaper.escapeTableName(table, this.flavor); + return this; + } + + /** + * Specifies the column-value pairs to be inserted. + * Accepts either an array of ColumnValue objects or an object mapping column names to values. + * @param columnValues - The column-value pairs to be inserted. + * @returns The current InsertQuery instance for method chaining. + */ + public values(columnValues: ColumnValue[] | { [column: string]: any }): this { + if (Array.isArray(columnValues)) { + this.columnValues = columnValues + .filter((v) => v.value !== undefined) + .map((v) => ({ + column: SqlEscaper.escapeIdentifier(v.column, this.flavor), + value: v.value ?? null, + })); + } else { + this.columnValues = Object.entries(columnValues) + .filter(([, value]) => value !== undefined) + .map(([column, value]) => ({ + column: SqlEscaper.escapeIdentifier(column, this.flavor), + value: value ?? null, + })); + } + return this; + } + + /** + * Specifies the columns to be inserted without associated values. + * This is useful when inserting data from a SELECT query. + * @param columns - The names of the columns to be inserted. + * @returns The current InsertQuery instance for method chaining. + */ + public columns(...columns: string[]): this { + this.columnValues = columns.map((column) => ({ + column: SqlEscaper.escapeIdentifier(column, this.flavor), + value: undefined, + })); + return this; + } + + /** + * Specifies a SELECT query to insert data from. + * This allows inserting records based on the results of another query. + * @param query - The SELECT query to insert data from. + * @returns The current InsertQuery instance for method chaining. + */ + public fromSelect(query: SelectQuery): this { + this.selectQuery = query; + return this; + } + + /** + * Indicates that all fields should be returned after the insert operation. + * This is equivalent to using RETURNING * in SQL. + * @returns The current InsertQuery instance for method chaining. + */ + public returnAllFields(): this { + this.returnAll = true; + this.returningFields = []; + return this; + } + + /** + * Specifies the fields to be returned after the insert operation. + * @param fields - A single field or an array of fields to be returned. + * @returns The current InsertQuery instance for method chaining. + */ + public returning(fields: string | string[]): this { + this.returningRaw( + SqlEscaper.escapeSelectIdentifiers( + Array.isArray(fields) ? fields : [fields], + this.flavor, + ), + ); + return this; + } + + /** + * Adds fields to the existing RETURNING clause. + * @param fields - A single field or an array of fields to be added to the RETURNING clause. + * @returns The current InsertQuery instance for method chaining. + */ + public addReturning(fields: string | string[]): this { + this.addReturningRaw( + SqlEscaper.escapeSelectIdentifiers( + Array.isArray(fields) ? fields : [fields], + this.flavor, + ), + ); + return this; + } + + /** + * Specifies raw fields to be returned after the insert operation without escaping. + * @param fields - A single field or an array of fields to be returned. + * @returns The current InsertQuery instance for method chaining. + */ + public returningRaw(fields: string | string[]): this { + this.returnAll = false; + if (Array.isArray(fields)) { + this.returningFields = fields; + } else { + this.returningFields = [fields]; + } + return this; + } + + /** + * Adds raw fields to the existing RETURNING clause without escaping. + * @param field - A single field or an array of fields to be added to the RETURNING clause. + * @returns The current InsertQuery instance for method chaining. + */ + public addReturningRaw(field: string | string[]): this { + this.returnAll = false; + if (Array.isArray(field)) { + this.returningFields.push(...field); + } else { + this.returningFields.push(field); + } + return this; + } + + /** + * Creates a deep clone of the current InsertQuery instance. + * @returns A new InsertQuery instance with the same properties as the original. + */ + public clone(): InsertQuery { + const cloned = new InsertQuery(); + cloned.table = this.table; + cloned.schemas = [...this.schemas]; + cloned.columnValues = JSON.parse(JSON.stringify(this.columnValues)); + cloned.selectQuery = this.selectQuery ? this.selectQuery.clone() : null; + cloned.returningFields = [...this.returningFields]; + cloned.ctes = this.ctes ? new CteMaker(...this.ctes["ctes"]) : null; + cloned.returnAll = this.returnAll; + return cloned; + } + + /** + * This an INSERT query. + * @returns The kind of SQL operation, which is 'INSERT' for this class. + */ + public get kind() { + return QueryKind.INSERT; + } + + /** + * Invalidates the current state of the query, forcing a rebuild on the next operation. + * @returns void + */ + public override invalidate(): void { + this.builtQuery = null; + this.selectQuery?.invalidate(); + if (this.ctes) { + for (const cte of this.ctes["ctes"]) { + cte["query"].invalidate(); + } + } + } + + /** + * Resets the query to its initial state. + * @returns void + */ + public reset(): void { + this.table = ""; + this.schemas = []; + this.columnValues = []; + this.selectQuery = null; + this.returningFields = []; + this.builtQuery = null; + this.ctes = null; + this.returnAll = false; + } + + /** + * Retrieves the parameters associated with the query. + * @returns An array of parameters for the query. + */ + private getInternalParams(): any[] { + if (!this.builtQuery) this.build(); + let params: any[] = []; + if (this.columnValues.length > 0) { + params = this.columnValues.map((cv) => cv.value); + } else if (this.selectQuery) { + params = this.selectQuery.getParams(); + } + if (this.ctes) { + params = [...this.ctes.build().values, ...params]; + } + return params; + } + + /** + * Retrieves the parameters associated with the query, building the query if necessary. + * @returns An array of parameters for the query. + */ + public getParams(): any[] { + if (!this.builtQuery) this.build(); + if (!this.builtParams) throw new Error("Failed to build query parameters."); + return this.builtParams; + } + + /** + * Builds the SQL INSERT query and returns an object containing the query text and its parameters. + * The optional deepAnalysis parameter can be used to control the depth of analysis during the build process. + * @param deepAnalysis - Whether to perform deep analysis during the build process. + * @returns An object containing the query text and its parameters. + * @throws Error if no table is specified or if neither values nor a SELECT query is provided. + */ + public build(deepAnalysis: boolean = false): { text: string; values: any[] } { + if (!this.table) { + throw new Error("No table specified for INSERT query."); + } + if (this.columnValues.length === 0 && !this.selectQuery) { + throw new Error("No values or SELECT query specified for INSERT query."); + } + + let ctesClause = ""; + let cteValues: any[] = []; + if (this.ctes) { + const ctesBuilt = this.ctes.build(); + ctesClause = ctesBuilt.text; + cteValues = ctesBuilt.values; + this.selectQuery?.addWhereOffset(cteValues.length); + } + + const columns = this.columnValues.map((cv) => cv.column); + let insertClause = `INSERT INTO ${this.table} (${columns.join(", ")})`; + if (this.columnValues.length > 0) { + if (this.columnValues.some((cv) => cv.value !== undefined)) { + const valuePlaceholders = this.columnValues.map( + (_, idx) => `$${idx + 1}`, + ); + insertClause += ` VALUES (${valuePlaceholders.join(", ")})`; + } + } else if (this.selectQuery) { + // Use columns from select query if not specified + const selectColumns = this.selectQuery.columns; + if (selectColumns.length > 0 && columns.length === 0) { + const parsedColumns = selectColumns.map((col) => { + const regex = + /^(?:(?:"?[\w$]+"?\.)?"?([\w$]+)"?(?:\s+AS\s+"?([\w$]+)"?)?)$/i; + const match = col.match(regex); + if (match) { + return SqlEscaper.escapeIdentifier( + match[2]! || match[1]!, + this.flavor, + ); + } + return SqlEscaper.escapeIdentifier(col, this.flavor); + }); + insertClause = `INSERT INTO ${this.table} (${parsedColumns.join(", ")})`; + } + } + + if (this.selectQuery) { + const selectBuilt = this.selectQuery.build(); + insertClause += `\n${selectBuilt.text}`; + cteValues = [...cteValues, ...selectBuilt.values]; + } + + let returningClause = ""; + if (this.returningFields.length > 0) { + returningClause = `RETURNING ${this.returningFields.join(", ")}`; + } + + const text = [ + ctesClause ? `${ctesClause} ` : "", + insertClause, + returningClause || (this.returnAll ? "RETURNING *" : ""), + ] + .join("\n") + .trim(); + this.builtQuery = text; + + this.builtQuery = SqlEscaper.appendSchemas(this.builtQuery, this.schemas); + + const analyzed = this.reAnalyzeParsedQueryForDuplicateParams( + this.builtQuery, + [...cteValues, ...this.getInternalParams()], + deepAnalysis, + ); + + this.builtQuery = analyzed.text; + this.builtParams = analyzed.values; + return { text: this.builtQuery, values: this.builtParams }; + } + + /** + * Converts the built SQL query to a string. + * @returns The SQL query string. + */ + public toSQL(): string { + if (!this.builtQuery) this.build(); + if (!this.builtQuery) throw new Error("Failed to build query."); + + return this.builtQuery; + } +} diff --git a/src/queryKinds/select.test.ts b/src/queryKinds/dml/select.test.ts similarity index 70% rename from src/queryKinds/select.test.ts rename to src/queryKinds/dml/select.test.ts index 3d04a19..26453a8 100644 --- a/src/queryKinds/select.test.ts +++ b/src/queryKinds/dml/select.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import SelectQuery from "./select.js"; -import Statement from "../statementMaker.js"; -import { Cte } from "../cteMaker.js"; +import Statement from "../../statementMaker.js"; +import CteMaker, { Cte } from "../../cteMaker.js"; describe('Select Query', () => { it('should generate correct SELECT SQL', () => { @@ -468,4 +468,170 @@ describe('Select Query', () => { expect(query.build().values).toEqual([18]); }); + + it('should support subQuery for joins', () => { + const subQuery = new SelectQuery('orders', 'o') + .select(['o.user_id']) + .addRawSelect('COUNT(o.id) AS order_count') + .where('o.status = ?', 'completed') + .groupBy('o.user_id'); + + const mainQuery = new SelectQuery('users', 'u') + .select(['u.id', 'u.name', 'sq.order_count']) + .join({ + type: 'LEFT', + subQuery: subQuery, + alias: 'sq', + on: 'u.id = sq.user_id' + }) + .where('u.active = ?', true) + .orderBy({ field: 'sq.order_count', direction: 'DESC' }) + .limit(10); + + const built = mainQuery.build(); + + expect(built.text).toBe('SELECT\n "u"."id",\n "u"."name",\n "sq"."order_count"\nFROM "users" AS u\nLEFT JOIN (\n SELECT\n "o"."user_id",\n COUNT(o.id) AS order_count\n FROM "orders" AS o\n WHERE (o.status = $1)\n GROUP BY "o"."user_id"\n) sq\n ON u.id = sq.user_id\nWHERE (u.active = $2)\nORDER BY "sq"."order_count" DESC\nLIMIT 10'); + expect(built.values).toEqual(['completed', true]); + + + const subQuery2 = new SelectQuery('products', 'p') + .select(['p.category_id']) + .addRawSelect('AVG(p.price) AS avg_price') + .where('p.stock > ?', 0) + .groupBy('p.category_id'); + + const mainQuery2 = new SelectQuery('categories', 'c') + .select(['c.id', 'c.name', 'sq.avg_price']) + .join({ + type: 'INNER', + subQuery: subQuery2, + alias: 'sq', + on: new Statement().and('c.id = sq.category_id').and('sq.avg_price > ?', 50) + }) + .where('c.active = ?', true) + .orderBy({ field: 'sq.avg_price', direction: 'DESC' }) + .limit(5); + + const built2 = mainQuery2.build(); + + expect(built2.text).toBe('SELECT\n "c"."id",\n "c"."name",\n "sq"."avg_price"\nFROM "categories" AS c\nINNER JOIN (\n SELECT\n "p"."category_id",\n AVG(p.price) AS avg_price\n FROM "products" AS p\n WHERE (p.stock > $1)\n GROUP BY "p"."category_id"\n) sq\n ON (c.id = sq.category_id) AND (sq.avg_price > $2)\nWHERE (c.active = $3)\nORDER BY "sq"."avg_price" DESC\nLIMIT 5'); + expect(built2.values).toEqual([0, 50, true]); + }); + + + it('should support multiple subQuery joins', () => { + const ordersSubQuery = new SelectQuery('orders', 'o') + .select(['o.user_id']) + .addRawSelect('COUNT(o.id) AS order_count') + .where('o.status = ?', 'completed') + .groupBy('o.user_id'); + + const reviewsSubQuery = new SelectQuery('reviews', 'r') + .select(['r.user_id']) + .addRawSelect('AVG(r.rating) AS avg_rating') + .where('r.approved = ?', true) + .groupBy('r.user_id'); + + const mainQuery = new SelectQuery('users', 'u') + .select(['u.id', 'u.name', 'osq.order_count', 'rsq.avg_rating']) + .join({ + type: 'LEFT', + subQuery: ordersSubQuery, + alias: 'osq', + on: 'u.id = osq.user_id' + }) + .join({ + type: 'LEFT', + subQuery: reviewsSubQuery, + alias: 'rsq', + on: 'u.id = rsq.user_id' + }) + .where('u.active = ?', true) + .orderBy([ + { field: 'osq.order_count', direction: 'DESC' }, + { field: 'rsq.avg_rating', direction: 'DESC' } + ]) + .limit(10); + + const built = mainQuery.build(); + + expect(built.text).toBe('SELECT\n "u"."id",\n "u"."name",\n "osq"."order_count",\n "rsq"."avg_rating"\nFROM "users" AS u\nLEFT JOIN (\n SELECT\n "o"."user_id",\n COUNT(o.id) AS order_count\n FROM "orders" AS o\n WHERE (o.status = $1)\n GROUP BY "o"."user_id"\n) osq\n ON u.id = osq.user_id\nLEFT JOIN (\n SELECT\n "r"."user_id",\n AVG(r.rating) AS avg_rating\n FROM "reviews" AS r\n WHERE (r.approved = $2)\n GROUP BY "r"."user_id"\n) rsq\n ON u.id = rsq.user_id\nWHERE (u.active = $2)\nORDER BY "osq"."order_count" DESC, "rsq"."avg_rating" DESC\nLIMIT 10'); + expect(built.values).toEqual(['completed', true]); + }); + + it('should support multiple CTEs', () => { + const activeUsersCte = new Cte( + 'active_users', + new SelectQuery('users').where('active = ?', true).select(['id', 'name']), + false + ); + + const recentOrdersCte = new Cte( + 'recent_orders', + new SelectQuery('orders').where('created_at > ?', '2023-01-01').select(['id', 'user_id', 'total']), + false + ); + + const mainQuery = new SelectQuery('active_users', 'au') + .with([activeUsersCte, recentOrdersCte]) + .join({ + type: 'INNER', + table: 'recent_orders', + alias: 'ro', + on: 'au.id = ro.user_id' + }) + .select(['au.id', 'au.name', 'ro.total']) + .orderBy({ field: 'ro.total', direction: 'DESC' }) + .limit(10); + + const built = mainQuery.build(); + + expect(built.text).toBe('WITH active_users AS (\nSELECT\n "id",\n "name"\nFROM "users"\nWHERE (active = $1)\n), recent_orders AS (\nSELECT\n "id",\n "user_id",\n "total"\nFROM "orders"\nWHERE (created_at > $2)\n)\nSELECT\n "au"."id",\n "au"."name",\n "ro"."total"\nFROM "active_users" AS au\nINNER JOIN "recent_orders" ro\n ON au.id = ro.user_id\nORDER BY "ro"."total" DESC\nLIMIT 10'); + expect(built.values).toEqual([true, '2023-01-01']); + }); + + + it('should support multipe CTEs with subQuery joins', () => { + const activeUsersCte = new Cte( + 'active_users', + new SelectQuery('users').where('active = ?', true).select(['id', 'name']), + false + ); + + const recentOrdersCte = new Cte( + 'recent_orders', + new SelectQuery('orders').where('created_at > ?', '2023-01-01').select(['id', 'user_id', 'total']), + false + ); + + const ordersSubQuery = new SelectQuery('orders', 'o') + .select(['o.user_id']) + .addRawSelect('COUNT(o.id) AS order_count') + .where('o.status = ?', 'completed') + .groupBy('o.user_id'); + + const mainQuery = new SelectQuery('active_users', 'au') + .with([activeUsersCte, recentOrdersCte]) + .join({ + type: 'INNER', + subQuery: ordersSubQuery, + alias: 'osq', + on: 'au.id = osq.user_id' + }) + .join({ + type: 'INNER', + table: 'recent_orders', + alias: 'ro', + on: 'au.id = ro.user_id' + }) + .select(['au.id', 'au.name', 'osq.order_count', 'ro.total']) + .orderBy({ field: 'ro.total', direction: 'DESC' }) + .limit(10); + + const built = mainQuery.build(); + + expect(built.text).toBe('WITH active_users AS (\nSELECT\n "id",\n "name"\nFROM "users"\nWHERE (active = $1)\n), recent_orders AS (\nSELECT\n "id",\n "user_id",\n "total"\nFROM "orders"\nWHERE (created_at > $2)\n)\nSELECT\n "au"."id",\n "au"."name",\n "osq"."order_count",\n "ro"."total"\nFROM "active_users" AS au\nINNER JOIN (\n SELECT\n "o"."user_id",\n COUNT(o.id) AS order_count\n FROM "orders" AS o\n WHERE (o.status = $3)\n GROUP BY "o"."user_id"\n) osq\n ON au.id = osq.user_id\nINNER JOIN "recent_orders" ro\n ON au.id = ro.user_id\nORDER BY "ro"."total" DESC\nLIMIT 10'); + expect(built.values).toEqual([true, '2023-01-01', 'completed']); + }); + }); diff --git a/src/queryKinds/dml/select.ts b/src/queryKinds/dml/select.ts new file mode 100644 index 0000000..7ae9433 --- /dev/null +++ b/src/queryKinds/dml/select.ts @@ -0,0 +1,691 @@ +import CteMaker, { type Cte } from "../../cteMaker.js"; +import SqlEscaper from "../../sqlEscaper.js"; +import Statement from "../../statementMaker.js"; +import type Join from "../../types/Join.js"; +import { isJoinTable } from "../../types/Join.js"; +import type OrderBy from "../../types/OrderBy.js"; +import { isOrderByField } from "../../types/OrderBy.js"; +import QueryKind from "../../types/QueryKind.js"; +import DmlQueryDefinition from "./dmlQueryDefinition.js"; +import Union from "./union.js"; + +/** + * SelectQuery class represents a SQL SELECT query. + * It includes methods to build various parts of the query such as SELECT fields, WHERE conditions, JOINs, ORDER BY, LIMIT, OFFSET, GROUP BY, and CTEs. + * The class provides functionality to build the final SQL query string and manage query parameters. + */ +export default class SelectQuery extends DmlQueryDefinition { + /** + * The table to select from. + */ + private table: string; + + /** + * An optional alias for the table. + */ + private tableAlias: string | null = null; + + /** + * Indicates whether the SELECT is DISTINCT. + */ + private distinctSelect: boolean = false; + + /** + * The fields to select. + */ + private selectFields: string[]; + + /** + * The HAVING clause statement. + */ + private havingStatement: Statement | null = null; + + /** + * The JOIN clauses. + */ + private joins: Join[] = []; + + /** + * The ORDER BY clauses. + */ + private orderBys: OrderBy[] = []; + + /** + * The LIMIT count. + */ + private limitCount: number | null = null; + + /** + * The OFFSET count. + */ + private offsetCount: number | null = null; + + /** + * The GROUP BY fields. + */ + private groupBys: string[] = []; + + /** + * If true, automatically includes all selected fields in the GROUP BY clause. + */ + private groupBySelectFields: boolean = false; + + /** + * If true, disables deep analysis of the query for duplicate parameters. + */ + private disabledAnalysis: boolean = false; + + /** + * Creates a new SelectQuery instance. + * @param from The table to select from. + * @param alias An optional alias for the table. + * @param groupBySelectFields If true, automatically includes all selected fields in the GROUP BY clause. + */ + constructor( + from?: string, + alias: string | null = null, + groupBySelectFields: boolean = false, + ) { + super(); + const escapedFrom = from + ? SqlEscaper.escapeTableName(from, this.flavor) + : ""; + this.table = escapedFrom; + this.tableAlias = alias; + this.selectFields = ["*"]; + this.groupBySelectFields = groupBySelectFields; + } + + /** + * Gets the selected columns. + * @returns A readonly array of selected column names. + */ + public get columns(): Readonly { + return this.selectFields; + } + + /** + * Add an offset to the WHERE clause parameters. + * This is useful when combining multiple statements to ensure parameter indices are correct. + * @param offset The offset to add to the parameter indices. + * @returns The current SelectQuery instance for chaining. + */ + public addWhereOffset(offset: number): this { + if (this.whereStatement) { + this.whereStatement.addOffset(offset); + this.whereStatement.invalidate(); + } + return this; + } + + /** + * Resets the WHERE clause parameter offset to zero. + * This is useful when reusing the query in different contexts. + * @return The current SelectQuery instance for chaining. + */ + public resetWhereOffset(): this { + if (this.whereStatement) { + this.whereStatement.setOffset(1); + this.whereStatement.invalidate(); + } + return this; + } + + /** + * Invalidates the current state of the query, forcing a rebuild on the next operation. + * @returns void + */ + public override invalidate(): void { + super.invalidate(); + if (this.havingStatement) this.havingStatement.invalidate(); + } + + /** + * Adds CTEs to the query. + * Can accept a CteMaker instance, a single Cte object, or an array of Cte objects. + * @param ctes The CTEs to add. + * @returns The current SelectQuery instance for chaining. + */ + public with(ctes: CteMaker | Cte | Cte[]): this { + if (ctes instanceof CteMaker) { + this.ctes = ctes; + } else if (Array.isArray(ctes)) { + this.ctes = new CteMaker(...ctes); + } else { + this.ctes = new CteMaker(ctes); + } + return this; + } + + /** + * Sets the table to select from, with an optional alias. + * @param table The table name. + * @param alias An optional alias for the table. + * @returns The current SelectQuery instance for chaining. + */ + public from(table: string, alias: string | null = null): this { + const escapedTable = SqlEscaper.escapeTableName(table, this.flavor); + this.table = escapedTable; + this.tableAlias = alias; + return this; + } + + /** + * Enables DISTINCT selection. + * @returns The current SelectQuery instance for chaining. + */ + public distinct(): this { + this.distinctSelect = true; + return this; + } + + /** + * Sets the fields to select from. + * Can accept a single field as a string or multiple fields as an array of strings. + * @param fields The fields to select. + * @returns The current SelectQuery instance for chaining. + */ + public select(fields: string | string[]): this { + if (Array.isArray(fields)) { + this.rawSelect(SqlEscaper.escapeSelectIdentifiers(fields, this.flavor)); + } else { + this.rawSelect(SqlEscaper.escapeSelectIdentifiers([fields], this.flavor)); + } + return this; + } + + /** + * Sets raw SQL fields to select from, without any escaping. + * Can accept a single field as a string or multiple fields as an array of strings. + * @param rawFields The raw SQL fields to select. + * @returns The current SelectQuery instance for chaining. + */ + public rawSelect(rawFields: string | string[]): this { + if (Array.isArray(rawFields)) { + this.selectFields = [...rawFields]; + } else { + this.selectFields = [rawFields]; + } + return this; + } + + /** + * Adds raw SQL fields to the existing selection, without any escaping. + * Can accept a single field as a string or multiple fields as an array of strings. + * @param rawFields The raw SQL fields to add to the selection. + * @returns The current SelectQuery instance for chaining. + */ + public addRawSelect(rawFields: string | string[]): this { + if (Array.isArray(rawFields)) { + this.selectFields.push(...rawFields); + } else { + this.selectFields.push(rawFields); + } + return this; + } + + /** + * Adds fields to the existing selection. + * Can accept a single field as a string or multiple fields as an array of strings. + * @param fields The fields to add to the selection. + * @returns The current SelectQuery instance for chaining. + */ + public addSelect(fields: string | string[]): this { + if (Array.isArray(fields)) { + this.addRawSelect( + SqlEscaper.escapeSelectIdentifiers(fields, this.flavor), + ); + } else { + this.addRawSelect( + SqlEscaper.escapeSelectIdentifiers([fields], this.flavor), + ); + } + return this; + } + + /** + * Adds a Statement or raw SQL string as the WHERE clause. + * If a string is provided, it will be converted into a raw Statement. + * @param statement The WHERE clause as a Statement or raw SQL string. + * @param values Optional values for parameterized queries. + * @returns The current SelectQuery instance for chaining. + */ + public where(statement: Statement | string, ...values: any[]): this { + if (typeof statement === "string") { + statement = new Statement().raw("", statement, ...values); + } + + this.whereStatement = statement; + return this; + } + + /** + * Uses a callback to build the WHERE clause statement. + * The callback receives a Statement instance to build upon. + * @param statement A callback function that receives a Statement instance. + * @returns The current SelectQuery instance for chaining. + */ + public useStatement( + statement: (stmt: Statement) => Statement | undefined | void, + ): this { + const stmt = new Statement(); + const newStmt = statement(stmt) || stmt; + return this.where(newStmt); + } + + /** + * Adds a Statement or raw SQL string as the HAVING clause. + * If a string is provided, it will be converted into a raw Statement. + * @param statement The HAVING clause as a Statement or raw SQL string. + * @param values Optional values for parameterized queries. + * @returns The current SelectQuery instance for chaining. + */ + public having(statement: Statement | string, ...values: any[]): this { + if (typeof statement === "string") { + statement = new Statement().raw("", statement, ...values); + } + + this.havingStatement = statement; + return this; + } + + /** + * Uses a callback to build the HAVING clause statement. + * The callback receives a Statement instance to build upon. + * @param statement A callback function that receives a Statement instance. + * @returns The current SelectQuery instance for chaining. + */ + public useHavingStatement( + statement: (stmt: Statement) => Statement | undefined | void, + ): this { + const stmt = new Statement(); + const newStmt = statement(stmt) || stmt; + return this.having(newStmt); + } + + /** + * Adds JOIN clauses to the query, + * either as a single Join object or an array of Join objects. + * @param join The JOIN clause(s) to add. + * @returns The current SelectQuery instance for chaining. + */ + public join(join: Join | Join[]): this { + if (Array.isArray(join)) { + this.joins.push(...join.map((j) => this.parseJoinObject(j))); + } else { + this.joins.push(this.parseJoinObject(join)); + } + return this; + } + + public parseOrderByObject(orderBy: OrderBy): OrderBy { + let field = ""; + if (isOrderByField(orderBy)) field = orderBy.field; + else field = orderBy.column; + + return { + ...orderBy, + field: SqlEscaper.escapeSelectIdentifiers([field], this.flavor)[0]!, + } as OrderBy; + } + + /** + * Adds ORDER BY clauses to the query, + * either as a single OrderBy object or an array of OrderBy objects. + * @param orderBy The ORDER BY clause(s) to add. + * @returns The current SelectQuery instance for chaining. + */ + public orderBy(orderBy: OrderBy | OrderBy[]): this { + if (Array.isArray(orderBy)) { + this.orderBys.push(...orderBy.map((ob) => this.parseOrderByObject(ob))); + } else { + this.orderBys.push(this.parseOrderByObject(orderBy)); + } + return this; + } + + /** + * Sets the LIMIT for the query. + * @param count The maximum number of records to return. + * @returns The current SelectQuery instance for chaining. + */ + public limit(count: number): this { + if (typeof count !== "number" || count < 0 || !Number.isInteger(count)) { + throw new Error("Limit must be a non-negative integer."); + } + this.limitCount = count; + return this; + } + + /** + * Sets the OFFSET for the query. + * @param count The number of records to skip. + * @returns The current SelectQuery instance for chaining. + */ + public offset(count: number): this { + if (typeof count !== "number" || count < 0 || !Number.isInteger(count)) { + throw new Error("Offset must be a non-negative integer."); + } + this.offsetCount = count; + return this; + } + + /** + * Sets both LIMIT and OFFSET for the query. + * @param limit The maximum number of records to return. + * @param offset The number of records to skip. + * @returns The current SelectQuery instance for chaining. + */ + public limitAndOffset(limit: number, offset: number): this { + if (typeof limit !== "number" || limit < 0 || !Number.isInteger(limit)) { + throw new Error("Limit must be a non-negative integer."); + } + + if (typeof offset !== "number" || offset < 0 || !Number.isInteger(offset)) { + throw new Error("Offset must be a non-negative integer."); + } + + this.limitCount = limit; + this.offsetCount = offset; + return this; + } + + /** + * Resets both LIMIT and OFFSET to null. + * @returns The current SelectQuery instance for chaining. + */ + public resetLimitOffset(): this { + this.limitCount = null; + this.offsetCount = null; + return this; + } + + /** + * Resets the entire query to its initial state. + * This includes clearing the table, selected fields, WHERE clause, JOINs, ORDER BY, LIMIT, OFFSET, GROUP BY, CTEs, and any built query. + * @returns void + */ + public reset(): void { + this.table = ""; + this.tableAlias = null; + this.distinctSelect = false; + this.selectFields = ["*"]; + this.whereStatement = null; + this.joins = []; + this.orderBys = []; + this.limitCount = null; + this.offsetCount = null; + this.groupBys = []; + this.groupBySelectFields = false; + this.builtQuery = null; + this.builtParams = null; + this.ctes = null; + this.havingStatement = null; + this.disabledAnalysis = false; + this.schemas = []; + } + + /** + * Adds fields to the GROUP BY clause, + * either as a single field or an array of fields. + * @param fields The field(s) to add to the GROUP BY clause. + * @returns The current SelectQuery instance for chaining. + */ + public groupBy(fields: string | string[]): this { + if (Array.isArray(fields)) { + this.groupBys.push( + ...SqlEscaper.escapeSelectIdentifiers(fields, this.flavor), + ); + } else { + this.groupBys.push( + ...SqlEscaper.escapeSelectIdentifiers([fields], this.flavor), + ); + } + return this; + } + + /** + * Enable grouping by all selected fields. + * This automatically adds all selected fields to the GROUP BY clause. + * @returns The current SelectQuery instance for chaining. + */ + public enableGroupBySelectFields(): this { + this.groupBySelectFields = true; + return this; + } + + /** + * This is a SELECT query. + * @returns 'SELECT' The kind of query. + */ + public get kind(): QueryKind { + return QueryKind.SELECT; + } + + /** + * Get params for the built query. + * If the query is not built yet, it will build it first. + * @returns any[] The parameters for the built query. + */ + public getParams(): any[] { + if (!this.builtParams) this.build(); + if (!this.builtParams) throw new Error("Failed to build query."); + return this.builtParams; + } + + /** + * Creates a deep clone of the current SelectQuery instance. + * This is useful for creating variations of a query without modifying the original. + * @returns A new SelectQuery instance that is a clone of the current instance. + */ + public clone(): SelectQuery { + const cloned = new SelectQuery(); + cloned.table = this.table; + cloned.tableAlias = this.tableAlias; + this.groupBySelectFields = this.groupBySelectFields; + cloned.flavor = this.flavor; + cloned.schemas = [...this.schemas]; + cloned.distinctSelect = this.distinctSelect; + cloned.selectFields = [...this.selectFields]; + cloned.whereStatement = this.whereStatement + ? this.whereStatement.clone() + : null; + cloned.joins = this.joins.map( + (j) => + ({ + type: j.type, + table: j.table, + alias: j.alias, + on: typeof j.on === "string" ? `${j.on}` : j.on.clone(), + }) as Join, + ); + cloned.orderBys = [...this.orderBys]; + cloned.limitCount = this.limitCount; + cloned.offsetCount = this.offsetCount; + cloned.groupBys = [...this.groupBys]; + cloned.ctes = this.ctes ? new CteMaker(...this.ctes["ctes"]) : null; + return cloned; + } + + /** + * Combines the current SELECT query with another SELECT query using UNION ALL. + * @param query The SELECT query to combine with. + * @returns A new Union instance representing the combined queries. + */ + public unionAll(query: SelectQuery): Union { + return new Union().add(this).add(query, "UNION ALL"); + } + + /** + * Combines the current SELECT query with another SELECT query using UNION. + * @param query The SELECT query to combine with. + * @returns A new Union instance representing the combined queries. + */ + public union(query: SelectQuery): Union { + return new Union().add(this).add(query, "UNION"); + } + + /** + * Builds the final SQL SELECT query string and returns it along with the associated parameter values. + * If deepAnalysis is true, it will perform a deep analysis to identify and consolidate duplicate parameters. + * Throws an error if the table name is not set. + * @param deepAnalysis Whether to perform deep analysis for duplicate parameters. Default is false. + * @returns An object containing the SQL query string and an array of parameter values. + */ + public build(deepAnalysis: boolean = false): { text: string; values: any[] } { + if (!this.table) { + throw new Error("Table name is required for SELECT query."); + } + + this.whereStatement = this.whereStatement || new Statement(); + + let ctesClause = ""; + let cteValues: any[] = []; + if (this.ctes) { + const ctesBuilt = this.ctes.build(); + ctesClause = ctesBuilt.text; + cteValues = ctesBuilt.values; + this.whereStatement.addOffset(cteValues.length); + } + + let selectClause = + this.selectFields.length > 0 ? this.selectFields.join(",\n ") : "*"; + if (this.groupBySelectFields && this.selectFields[0] !== "*") { + this.groupBys = Array.from( + new Set([...this.groupBys, ...this.selectFields]), + ); + } + + if (this.distinctSelect) selectClause = ` DISTINCT ${selectClause}`; + else selectClause = ` ${selectClause}`; + + let fromClause = `FROM ${this.table}`; + if (this.tableAlias) fromClause += ` AS ${this.tableAlias}`; + + let joinClauses = ""; + let currentOffset = cteValues.length; + const parametersToAdd: any = []; + for (const join of this.joins) { + if (isJoinTable(join)) { + const onClause = + typeof join.on === "string" + ? join.on + : (() => { + join.on.disableWhere(); + join.on.addOffset(currentOffset); + const stmt = join.on.build(false); + currentOffset += stmt.values.length; + parametersToAdd.push(...stmt.values); + return stmt.statement; + })(); + joinClauses += `${joinClauses ? "\n" : ""}${join.type.toUpperCase()} JOIN ${join.table} ${join.alias}\n ON ${onClause}`; + } else { + join.subQuery.resetWhereOffset(); + join.subQuery.addWhereOffset(currentOffset); + join.subQuery.disabledAnalysis = true; + const subQueryBuilt = join.subQuery.build(deepAnalysis); + join.subQuery.disabledAnalysis = false; + currentOffset += subQueryBuilt.values.length; + parametersToAdd.push(...subQueryBuilt.values); + const onClause = + typeof join.on === "string" + ? join.on + : (() => { + join.on.disableWhere(); + join.on.addOffset(currentOffset); + const stmt = join.on.build(false); + currentOffset += stmt.values.length; + parametersToAdd.push(...stmt.values); + return stmt.statement; + })(); + subQueryBuilt.text = this.spaceLines(subQueryBuilt.text, 1); + joinClauses += `${joinClauses ? "\n" : ""}${join.type.toUpperCase()} JOIN (\n${subQueryBuilt.text}\n) ${join.alias}\n ON ${onClause}`; + } + } + + this.whereStatement.addParams(parametersToAdd); + + this.whereStatement.enableWhere(); + const stmt = this.whereStatement.build(); + const whereClause = stmt.statement; + const values = [...cteValues, ...stmt.values]; + + let groupByClause = ""; + if (this.groupBys.length > 0 && !this.groupBySelectFields) { + groupByClause = `GROUP BY ${this.groupBys.join(", ")}`; + } else if (this.groupBySelectFields) { + groupByClause = `GROUP BY ${this.selectFields.join(", ")}`; + } + + let havingClause = ""; + if (this.havingStatement) { + this.havingStatement.disableWhere(); + this.havingStatement.addOffset(values.length); + const havingStmt = this.havingStatement.build(); + havingClause = `HAVING ${havingStmt.statement}`; + values.push(...havingStmt.values); + } + + let orderByClause = ""; + if (this.orderBys.length > 0) { + const orders = this.orderBys.map((ob) => { + let field = ""; + if (isOrderByField(ob)) field = ob.field; + else field = ob.column; + + return `${field} ${ob.direction}`; + }); + orderByClause = `ORDER BY ${orders.join(", ")}`; + } + + let limitClause = ""; + if (this.limitCount !== null) limitClause = `LIMIT ${this.limitCount}`; + + let offsetClause = ""; + if (this.offsetCount !== null) offsetClause = `OFFSET ${this.offsetCount}`; + + const query = [ + ctesClause, + "SELECT", + selectClause, + fromClause, + joinClauses, + whereClause, + groupByClause, + havingClause, + orderByClause, + `${limitClause} ${offsetClause}`, + ] + .map((q) => (q.trim() ? q : null)) + .filter(Boolean) + .join("\n"); + + this.builtQuery = query.trim(); + + this.builtQuery = SqlEscaper.appendSchemas(this.builtQuery, this.schemas); + + if (this.disabledAnalysis) { + return { text: this.builtQuery, values }; + } else { + const analyzed = this.reAnalyzeParsedQueryForDuplicateParams( + this.builtQuery, + values, + deepAnalysis, + ); + this.builtQuery = analyzed.text; + this.builtParams = analyzed.values; + return { text: this.builtQuery, values: this.builtParams }; + } + } + + /** + * Returns the built SQL query string. + * If the query is not built yet, it will build it first. + * @returns string The SQL query string. + */ + public toSQL(): string { + if (!this.builtQuery) this.build(); + if (!this.builtQuery) throw new Error("Failed to build query."); + return this.builtQuery; + } +} diff --git a/src/queryKinds/union.test.ts b/src/queryKinds/dml/union.test.ts similarity index 81% rename from src/queryKinds/union.test.ts rename to src/queryKinds/dml/union.test.ts index 32cc01e..2eae0d3 100644 --- a/src/queryKinds/union.test.ts +++ b/src/queryKinds/dml/union.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import Union from "./union.js"; -import Query from "../queryMaker.js"; -import Statement from "../statementMaker.js"; +import Query from "../../queryMaker.js"; +import Statement from "../../statementMaker.js"; describe("Union Query", () => { it('should create a UNION query with two SELECT statements', () => { @@ -385,4 +385,106 @@ describe("Union Query", () => { expect(rebuiltOriginal).toEqual(built); }); + it('should support custom select clause', () => { + const select1 = Query.select + .from("table1") + .select(["column1", "column2"]) + .where("column1 = ?", "value1"); + + const select2 = Query.select + .from("table2") + .select(["column1", "column2"]) + .where("column2 = ?", "value2"); + + const unionQuery = new Union() + .addMany([ + { + query: select1, + type: 'union' + }, + { + query: select2, + type: 'union all' + } + ]) + .as('union_table') + .select('column1') + .addSelect('column2') + .addRawSelect('COUNT(column2) AS count_column2') + .groupBy('column1') + .build(); + + expect(unionQuery.text).toBe('SELECT\n "column1",\n "column2",\n COUNT(column2) AS count_column2\n FROM (\n (SELECT\n "column1",\n "column2"\n FROM "table1"\n WHERE (column1 = $1))\n\n UNION ALL\n\n (SELECT\n "column1",\n "column2"\n FROM "table2"\n WHERE (column2 = $2))\n) AS union_table\nGROUP BY "column1"'); + expect(unionQuery.values).toEqual(['value1', 'value2']); + }); + + it('should support raw select clause', () => { + const select1 = Query.select + .from("table1") + .select(["column1", "column2"]) + .where("column1 = ?", "value1"); + + const select2 = Query.select + .from("table2") + .select(["column1", "column2"]) + .where("column2 = ?", "value2"); + + const unionQuery = new Union() + .addMany([ + { + query: select1, + type: 'union' + }, + { + query: select2, + type: 'union all' + } + ]) + .as('union_table') + .rawSelect('column1') + .addSelect([ + 'column2', + ]) + .rawSelect([ + 'column1', + 'column2', + 'COUNT(column2) AS count_column2' + ]) + .addRawSelect('COUNT(column2) AS count_column2') + .select([ + 'column1', + 'column2' + ]) + .groupBy('column1') + .build(); + + expect(unionQuery.text).toBe('SELECT\n "column1",\n "column2"\n FROM (\n (SELECT\n "column1",\n "column2"\n FROM "table1"\n WHERE (column1 = $1))\n\n UNION ALL\n\n (SELECT\n "column1",\n "column2"\n FROM "table2"\n WHERE (column2 = $2))\n) AS union_table\nGROUP BY "column1"'); + expect(unionQuery.values).toEqual(['value1', 'value2']); + }); + + it('should throw if selects have different selected lengths', () => { + const select1 = Query.select + .from("table1") + .select(["column1", "column2"]) + .where("column1 = ?", "value1"); + + const select2 = Query.select + .from("table2") + .select(["column1", "column2", "column3"]) + .where("column2 = ?", "value2"); + + const union = new Union() + .addManyOfType([select1, select2], 'union all') + .as('union_table'); + + expect(() => union.build()).toThrowError('All SELECT queries must have the same number of fields. Query at index 1 differs.'); + expect(() => union.rawUnion()).toThrowError('All SELECT queries must have the same number of fields. Query at index 1 differs.'); + + try { + union.build(); + } catch (e: any) { + expect(e.cause?.selectQuery).toStrictEqual(select2); + } + }); + }); diff --git a/src/queryKinds/dml/union.ts b/src/queryKinds/dml/union.ts new file mode 100644 index 0000000..8b196b8 --- /dev/null +++ b/src/queryKinds/dml/union.ts @@ -0,0 +1,640 @@ +import SqlEscaper from "../../sqlEscaper.js"; +import Statement from "../../statementMaker.js"; +import type OrderBy from "../../types/OrderBy.js"; +import QueryKind from "../../types/QueryKind.js"; +import DmlQueryDefinition from "./dmlQueryDefinition.js"; +import type SelectQuery from "./select.js"; + +/** Allowed types for UnionType */ +export const UnionTypes = { + UNION: "UNION", + UNION_ALL: "UNION ALL", + INTERSECT: "INTERSECT", + INTERSECT_ALL: "INTERSECT ALL", + EXCEPT: "EXCEPT", + EXCEPT_ALL: "EXCEPT ALL", +} as const; + +/** Array of allowed union types for validation */ +const unionTypesArray = Object.values(UnionTypes); + +/** Base types for UnionType */ +type UnionTypeBase = (typeof UnionTypes)[keyof typeof UnionTypes]; + +/** + * UnionType represents the type of SQL UNION operation. + */ +export type UnionType = Lowercase | UnionTypeBase; + +/** + * Type representing a SELECT query along with its associated union type. + */ +export type SelectQueryWithUnionType = { + query: SelectQuery; + type: UnionType; +}; + +/** + * Union class represents a SQL UNION operation. + * It allows combining multiple SELECT queries into a single result set. + * It is basically a wrapper around multiple SelectQuery instances that + * creates a SELECT query that is the union of all the provided queries. + * It supports adding queries with different union types (UNION or UNION ALL) + * and can optionally assign an alias to the resulting union query. + */ +export default class Union extends DmlQueryDefinition { + /** The selected fields for the union query */ + private selectFields: string[] = []; + + /** Needed alias for the union query */ + private unionAlias: string | null = null; + + /** Limit count for the union query */ + private limitCount: number | null = null; + + /** Offset count for the union query */ + private offsetCount: number | null = null; + + /** Array of SelectQuery instances and their corresponding union types */ + private selectQueries: SelectQueryWithUnionType[] = []; + + /** Order by clauses for the union query */ + private orderBys: OrderBy[] = []; + + /** Group by clauses for the union query */ + private groupBys: string[] = []; + + /** Having statement for the union query */ + private havingStatement: Statement | null = null; + + /** Where statement for the union query */ + private disabledAnalysis: boolean = false; + + /** + * Checks if all added SELECT queries have the same number of fields. + * This is important for ensuring that the UNION operation is valid. + * @returns True if all SELECT queries have the same number of fields, + * or the index of the first query that differs in field count. + */ + private allSelectsHaveSameNumberOfFields(): number | null { + if (this.selectQueries.length === 0) return null; + + const selects = this.selectQueries.map((sq) => sq.query); + + const firstSelectFieldCount = selects[0]?.columns.length || 0; + + let indexThatDiffers: number | null = null; + const result = selects.every((select, i) => { + if (select.columns.length !== firstSelectFieldCount) { + indexThatDiffers = i; + return false; + } else return true; + }); + + return result ? null : indexThatDiffers; + } + + /** + * Make the union without selecting from it + * Useful when the raw union is needed as a subquery + * @throws Error if no SELECT queries have been added to the union + * @throws Error if the SELECT queries do not have the same number of fields + * @returns An object containing the raw SQL text of the union and its parameter values. + */ + public rawUnion(): { text: string; values: any[] } { + if (this.selectQueries.length === 0) { + throw new Error("No SELECT queries added to the UNION."); + } + + const differingIndex = this.allSelectsHaveSameNumberOfFields(); + if (differingIndex !== null) { + console.log("This is erroring out"); + throw new Error( + `All SELECT queries must have the same number of fields. Query at index ${differingIndex} differs.`, + { cause: { selectQuery: this.selectQueries[differingIndex]?.query } }, + ); + } + + let unionItself: string = ""; + const values: any[] = []; + + let paramOffset = 1; + for (const { query, type } of this.selectQueries) { + (query as any).disabledAnalysis = true; + query.resetWhereOffset(); + const builtQuery = query.addWhereOffset(paramOffset - 1).build(); + unionItself += `${unionItself ? `\n\n${type}\n\n` : ""}${builtQuery.text}`; + paramOffset += builtQuery.values.length; + values.push(...builtQuery.values); + } + + const analyzed = this.reAnalyzeParsedQueryForDuplicateParams( + unionItself, + values, + false, + ); + + return { + text: analyzed.text, + values: analyzed.values, + }; + } + + /** + * Specifies the fields to select in the union query. + * If not called, defaults to selecting all fields ('*'). + * @param fields A single field name or an array of field names to select. + * @returns The current Union instance for method chaining. + */ + public select(fields: string | string[]): Union { + if (Array.isArray(fields)) { + this.rawSelect(SqlEscaper.escapeSelectIdentifiers(fields, this.flavor)); + } else { + this.rawSelect(SqlEscaper.escapeSelectIdentifiers([fields], this.flavor)); + } + return this; + } + + /** + * Adds fields to the existing selection in the union query. + * If no fields have been selected yet, this behaves like the select() method. + * @param fields A single field name or an array of field names to add to the selection. + * @returns The current Union instance for method chaining. + */ + public addSelect(fields: string | string[]): Union { + if (Array.isArray(fields)) { + this.addRawSelect( + SqlEscaper.escapeSelectIdentifiers(fields, this.flavor), + ); + } else { + this.addRawSelect( + SqlEscaper.escapeSelectIdentifiers([fields], this.flavor), + ); + } + return this; + } + + /** + * Specifies raw fields to select in the union query without any escaping. + * Use this method with caution, as it does not perform any SQL injection protection. + * @param fields A single raw field string or an array of raw field strings to select. + * @returns The current Union instance for method chaining. + */ + public rawSelect(fields: string | string[]): Union { + if (Array.isArray(fields)) { + this.selectFields = fields; + } else { + this.selectFields = [fields]; + } + return this; + } + + /** + * Adds raw fields to the existing selection in the union query without any escaping. + * Use this method with caution, as it does not perform any SQL injection protection. + * If no fields have been selected yet, this behaves like the rawSelect() method. + * @param fields A single raw field string or an array of raw field strings to add to the selection. + * @returns The current Union instance for method chaining. + */ + public addRawSelect(fields: string | string[]): Union { + if (Array.isArray(fields)) { + this.selectFields.push(...fields); + } else { + this.selectFields.push(fields); + } + return this; + } + + /** + * Assigns an alias to the resulting union query. + * @param alias The alias to assign to the union query. + * @returns The current Union instance for method chaining. + */ + public as(alias: string): Union { + this.unionAlias = alias; + return this; + } + + /** + * Adds a SELECT query to the union with the specified union type. + * @param query The SelectQuery instance to add to the union. + * @param type The type of union operation ('UNION' or 'UNION ALL'). Defaults to 'UNION ALL'. + * @returns The current Union instance for method chaining. + * @throws Error if an invalid union type is provided. + */ + public add(query: SelectQuery, type: UnionType = "UNION ALL"): Union { + type = type.toUpperCase() as UnionTypeBase; + if (!unionTypesArray.includes(type)) + throw new Error( + "Invalid union type. Only 'UNION' and 'UNION ALL' are allowed.", + ); + + this.selectQueries.push({ query, type }); + return this; + } + + /** + * Adds multiple SELECT queries to the union. + * @param queries An array of objects containing SelectQuery instances and their corresponding union types. + * @returns The current Union instance for method chaining. + */ + public addMany(queries: SelectQueryWithUnionType[]): Union { + queries.forEach(({ query, type }) => { + this.add(query, type); + }); + return this; + } + + /** + * Adds multiple SELECT queries to the union of the same union type. + * @param queries An array of SelectQuery instances to add to the union. + * @param type The type of union operation ('UNION' or 'UNION ALL'). Defaults to 'UNION ALL'. + * @returns The current Union instance for method chaining. + */ + public addManyOfType( + queries: SelectQuery[], + type: UnionType = "UNION ALL", + ): Union { + queries.forEach((query) => { + this.add(query, type); + }); + return this; + } + + /** + * Adds a WHERE clause to the union query. + * @param statement The WHERE clause as a Statement instance or a raw SQL string. + * @param values Optional parameter values if a raw SQL string is provided. + * @returns The current Union instance for method chaining. + */ + public where(statement: Statement | string, ...values: any[]): Union { + if (typeof statement === "string") { + statement = new Statement().raw("", statement, ...values); + } + this.whereStatement = statement; + return this; + } + + /** + * Adds a WHERE clause to the union query using a callback function. + * The callback function receives a Statement instance to build the WHERE clause. + * @param statement A callback function that takes a Statement instance and returns a Statement or void. + * @returns The current Union instance for method chaining. + */ + public useStatement( + statement: (stmt: Statement) => Statement | undefined | void, + ): Union { + const stmt = new Statement(); + const newStatement = statement(stmt) || stmt; + + this.whereStatement = newStatement; + return this; + } + + /** + * Sets a LIMIT on the number of rows returned by the union query. + * @param limit The maximum number of rows to return. Must be a non-negative integer. + * @returns The current Union instance for method chaining. + * @throws Error if the limit is negative or not an integer. + */ + public limit(limit: number): Union { + if (limit < 0 || !Number.isInteger(limit)) { + throw new Error("Limit must be a non-negative integer."); + } + this.limitCount = limit; + return this; + } + + /** + * Sets an OFFSET for the union query. + * @param offset The number of rows to skip before starting to return rows. Must be a non-negative integer. + * @returns The current Union instance for method chaining. + * @throws Error if the offset is negative or not an integer. + */ + public offset(offset: number): Union { + if (offset < 0 || !Number.isInteger(offset)) { + throw new Error("Offset must be a non-negative integer."); + } + this.offsetCount = offset; + return this; + } + + /** + * Sets both LIMIT and OFFSET for the union query. + * @param limit The maximum number of rows to return. Must be a non-negative integer. + * @param offset The number of rows to skip before starting to return rows. Must be a non-negative integer. + * @returns The current Union instance for method chaining. + * @throws Error if the limit or offset is negative or not an integer. + */ + public limitAndOffset(limit: number, offset: number): Union { + return this.limit(limit).offset(offset); + } + + /** + * Sets the ORDER BY clauses for the union query, replacing any existing clauses. + * @param orderBy A single OrderBy object or an array of OrderBy objects. + * @returns The current Union instance for method chaining. + */ + public orderBy(orderBy: OrderBy | OrderBy[]): Union { + if (Array.isArray(orderBy)) { + this.orderBys = orderBy; + } else { + this.orderBys = [orderBy]; + } + return this; + } + + /** + * Adds ORDER BY clauses to the union query without replacing existing clauses. + * @param orderBy A single OrderBy object or an array of OrderBy objects to add. + * @returns The current Union instance for method chaining. + */ + public addOrderBy(orderBy: OrderBy | OrderBy[]): Union { + if (Array.isArray(orderBy)) { + this.orderBys.push(...orderBy); + } else { + this.orderBys.push(orderBy); + } + return this; + } + + /** + * Sets the GROUP BY clauses for the union query, replacing any existing clauses. + * @param field A single field name or an array of field names to group by. + * @returns The current Union instance for method chaining. + */ + public groupBy(field: string | string[]): Union { + if (Array.isArray(field)) { + this.groupBys = field; + } else { + this.groupBys = [field]; + } + return this; + } + + /** + * Adds GROUP BY clauses to the union query without replacing existing clauses. + * @param field A single field name or an array of field names to add to the GROUP BY clause. + * @returns The current Union instance for method chaining. + */ + public addGroupBy(field: string | string[]): Union { + if (Array.isArray(field)) { + this.groupBys.push(...field); + } else { + this.groupBys.push(field); + } + return this; + } + + /** + * Adds a HAVING clause to the union query. + * @param statement The HAVING clause as a Statement instance or a raw SQL string. + * @param values Optional parameter values if a raw SQL string is provided. + * @returns The current Union instance for method chaining. + */ + public having(statement: Statement | string, ...values: any[]): Union { + if (typeof statement === "string") { + statement = new Statement().raw("", statement, ...values); + } + this.havingStatement = statement; + return this; + } + + /** + * Adds a HAVING clause to the union query using a callback function. + * The callback function receives a Statement instance to build the HAVING clause. + * @param statement A callback function that takes a Statement instance and returns a Statement or void. + * @returns The current Union instance for method chaining. + */ + public useHavingStatement( + statement: (stmt: Statement) => Statement | undefined, + ): Union { + const stmt = new Statement(); + const newStatement = statement(stmt) || stmt; + + this.havingStatement = newStatement; + return this; + } + + /** + * Builds the final SQL query for the union operation. + * It combines all added SELECT queries with their respective union types, + * applies any WHERE, GROUP BY, HAVING, ORDER BY, LIMIT, and OFFSET clauses, + * and returns the complete SQL text along with the parameter values. + * @param deepAnalysis If true, performs a deep analysis to re-index parameters. Defaults to false. + * @returns An object containing the final SQL text and an array of parameter values. + * @throws Error if no SELECT queries have been added to the union. + */ + public build(deepAnalysis: boolean = false): { text: string; values: any[] } { + if (this.selectQueries.length === 0) { + throw new Error("No SELECT queries added to the UNION."); + } + + const differingIndex = this.allSelectsHaveSameNumberOfFields(); + if (differingIndex !== null) { + console.log("This is erroring out"); + throw new Error( + `All SELECT queries must have the same number of fields. Query at index ${differingIndex} differs.`, + { cause: { selectQuery: this.selectQueries[differingIndex]?.query } }, + ); + } + + let unionItself: string = ""; + const values: any[] = []; + + let selectClause = ""; + if (this.selectFields.length > 0) { + selectClause = this.selectFields.join(",\n "); + } else { + selectClause = "*"; + } + + // Add offset on each select query to ensure correct parameter indexing + let paramOffset = 1; + for (const { query, type: unionType } of this.selectQueries) { + (query as any).disabledAnalysis = true; + query.resetWhereOffset(); + const builtQuery = query.addWhereOffset(paramOffset - 1).build(); + builtQuery.text = this.spaceLines(`(${builtQuery.text})`, 1); + const type = this.spaceLines(unionType, 1); + unionItself += `${unionItself ? `\n\n${type}\n\n` : ""}${builtQuery.text}`; + paramOffset += builtQuery.values.length; + values.push(...builtQuery.values); + } + + let whereClause = ""; + let whereValues: any[] = []; + if (this.whereStatement) { + const builtWhere = this.whereStatement + .enableWhere() + .setOffset(paramOffset) + .build(); + whereClause = builtWhere.statement; + whereValues = builtWhere.values; + } + + let groupByClause = ""; + if (this.groupBys.length > 0) { + groupByClause = `GROUP BY ${this.groupBys.map((gb) => `"${gb}"`).join(", ")}`; + } + + let havingClause = ""; + let havingValues: any[] = []; + if (this.havingStatement) { + const builtHaving = this.havingStatement + .disableWhere() + .setOffset(paramOffset + whereValues.length) + .build(); + havingClause = `HAVING ${builtHaving.statement}`; + havingValues = builtHaving.values; + } + + let orderByClause = ""; + if (this.orderBys.length > 0) { + orderByClause = + "ORDER BY " + + this.orderBys + .map((ob) => { + const direction = ob.direction + ? ` ${ob.direction.toUpperCase()}` + : ""; + return `"${(ob as any).field || (ob as any).column}"${direction}`; + }) + .join(", "); + } + + let limitClause = ""; + if (this.limitCount !== null) { + limitClause = `LIMIT ${this.limitCount}`; + } + + let offsetClause = ""; + if (this.offsetCount !== null) { + offsetClause = `OFFSET ${this.offsetCount}`; + } + + const firstLine = `SELECT${selectClause.length > 1 ? "\n" : ""} ${selectClause}${selectClause.length > 1 ? "\n" : ""} FROM (`; + + const union = [ + firstLine, + `${unionItself}\n) AS ${this.unionAlias || "union_subquery"}`, + whereClause, + groupByClause, + havingClause, + orderByClause, + limitClause, + offsetClause, + ] + .filter((part) => part.trim() !== "") + .join("\n"); + + const finalValues = [...values, ...whereValues, ...havingValues]; + + if (!this.disabledAnalysis) { + const analyzed = this.reAnalyzeParsedQueryForDuplicateParams( + union, + finalValues, + deepAnalysis, + ); + + this.builtQuery = analyzed.text; + this.builtParams = analyzed.values; + + return { + text: this.builtQuery, + values: this.builtParams, + }; + } else { + this.builtQuery = union; + this.builtParams = finalValues; + + return { + text: this.builtQuery, + values: this.builtParams, + }; + } + } + + /** + * Generates the SQL string for the union query. + * If the query has not been built yet, it will build it first. + * @returns The SQL string of the union query. + * @throws Error if the query fails to build. + */ + public toSQL(): string { + if (!this.builtQuery) this.build(); + if (!this.builtQuery) throw new Error("Failed to build the query."); + return this.builtQuery; + } + + /** + * Retrieves the parameter values for the union query. + * If the query has not been built yet, it will build it first. + * @returns An array of parameter values for the union query. + * @throws Error if the query fails to build. + */ + public getParams(): any[] { + if (!this.builtParams) this.build(); + if (!this.builtParams) throw new Error("Failed to build the query."); + return this.builtParams; + } + + /** + * Creates a deep clone of the current Union instance. + * This includes cloning all properties and nested objects to ensure + * that modifications to the clone do not affect the original instance. + * @returns A new Union instance that is a deep clone of the current instance. + */ + public clone(): Union { + const newUnion = new Union(); + newUnion.selectFields = [...this.selectFields]; + newUnion.flavor = this.flavor; + newUnion.unionAlias = this.unionAlias; + newUnion.limitCount = this.limitCount; + newUnion.offsetCount = this.offsetCount; + newUnion.schemas = [...this.schemas]; + newUnion.selectQueries = this.selectQueries.map((sq) => ({ + query: sq.query.clone() as SelectQuery, + type: sq.type, + })); + newUnion.orderBys = [...this.orderBys]; + newUnion.groupBys = [...this.groupBys]; + newUnion.havingStatement = this.havingStatement + ? this.havingStatement.clone() + : null; + newUnion.whereStatement = this.whereStatement + ? this.whereStatement.clone() + : null; + return newUnion; + } + + /** + * Resets the internal state of the Union instance. + * This clears all properties, including the union alias, limit, offset, + * select queries, order by clauses, group by clauses, having statement, + * where statement, built query, built parameters, and schemas. + * After calling this method, the Union instance will be in its initial state. + */ + public reset(): void { + this.selectFields = []; + this.unionAlias = null; + this.limitCount = null; + this.offsetCount = null; + this.selectQueries = []; + this.orderBys = []; + this.groupBys = []; + this.havingStatement = null; + this.whereStatement = null; + this.builtQuery = null; + this.builtParams = null; + this.schemas = []; + } + + /** + * This is a UNION query. + * @returns The kind of SQL operation, which is 'UNION' for this class. + */ + public get kind(): QueryKind { + return QueryKind.UNION; + } +} diff --git a/src/queryKinds/update.test.ts b/src/queryKinds/dml/update.test.ts similarity index 60% rename from src/queryKinds/update.test.ts rename to src/queryKinds/dml/update.test.ts index 2ddef62..7d9153a 100644 --- a/src/queryKinds/update.test.ts +++ b/src/queryKinds/dml/update.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import UpdateQuery from "./update.js"; -import Statement from "../statementMaker.js"; -import { Cte } from "../cteMaker.js"; +import Statement from "../../statementMaker.js"; +import CteMaker, { Cte } from "../../cteMaker.js"; import SelectQuery from "./select.js"; describe('Update Query', () => { @@ -320,4 +320,186 @@ describe('Update Query', () => { expect(secondBuild.values).toEqual([false, 'inactive']); }); + it('should support subQuery in joins', () => { + const subQuery = new SelectQuery('departments', 'd') + .select(['d.id', 'd.name']) + .where('d.active = ?', true); + + const query = new UpdateQuery('employees', 'e') + .using('companies', 'c') + .join({ + subQuery, + alias: 'dept', + type: 'INNER', + on: 'e.department_id = dept.id' + }) + .set({ 'e.status': 'active' }) + .where( + new Statement() + .and('e.company_id = c.id') + .and('c.active = ?', true) + .and('dept.name = ?', 'Engineering') + ) + .build(); + + expect(query.text).toBe('UPDATE "employees" e\nSET "e"."status" = $1\nFROM "companies" c\nINNER JOIN (\n SELECT\n "d"."id",\n "d"."name"\n FROM "departments" AS d\n WHERE (d.active = $2)\n) dept\n ON e.department_id = dept.id\nWHERE (e.company_id = c.id)\n AND (c.active = $2)\n AND (dept.name = $3)'); + expect(query.values).toEqual(['active', true, 'Engineering']); + + const subQuery2 = new SelectQuery('departments', 'd') + .select(['d.id', 'd.name']) + .where('d.active = ?', true); + + const query2 = new UpdateQuery('employees', 'e') + .using('companies', 'c') + .join({ + subQuery: subQuery2, + alias: 'dept', + type: 'INNER', + on: new Statement() + .and('e.department_id = dept.id') + .and('dept.name = ?', 'Engineering') + }) + .set({ 'e.status': 'active' }) + .where( + new Statement() + .and('e.company_id = c.id') + .and('c.active = ?', true) + ) + .build(); + + expect(query2.text).toBe('UPDATE "employees" e\nSET "e"."status" = $1\nFROM "companies" c\nINNER JOIN (\n SELECT\n "d"."id",\n "d"."name"\n FROM "departments" AS d\n WHERE (d.active = $2)\n) dept\n ON (e.department_id = dept.id) AND (dept.name = $3)\nWHERE (e.company_id = c.id)\n AND (c.active = $2)'); + expect(query2.values).toEqual(['active', true, 'Engineering']); + }); + + it('should support multiple joins with subQueries', () => { + const regionSubQuery = new SelectQuery('regions', 'r') + .select(['r.id', 'r.name']) + .where('r.active = ?', true); + + const departmentSubQuery = new SelectQuery('departments', 'd') + .select(['d.id', 'd.name']) + .where('d.active = ?', true); + + const query = new UpdateQuery('employees', 'e') + .using('companies', 'c') + .join([ + { + subQuery: regionSubQuery, + alias: 'reg', + type: 'INNER', + on: 'e.region_id = reg.id' + }, + { + subQuery: departmentSubQuery, + alias: 'dept', + type: 'LEFT', + on: new Statement() + .and('e.department_id = dept.id') + .and('dept.name = ?', 'Engineering') + } + ]) + .set({ 'e.status': 'active' }) + .where( + new Statement() + .and('e.company_id = c.id') + .and('c.active = ?', true) + .and('reg.name = ?', 'North') + ) + .build(); + + expect(query.text).toBe('UPDATE "employees" e\nSET "e"."status" = $1\nFROM "companies" c\nINNER JOIN (\n SELECT\n "r"."id",\n "r"."name"\n FROM "regions" AS r\n WHERE (r.active = $2)\n) reg\n ON e.region_id = reg.id\nLEFT JOIN (\n SELECT\n "d"."id",\n "d"."name"\n FROM "departments" AS d\n WHERE (d.active = $2)\n) dept\n ON (e.department_id = dept.id) AND (dept.name = $3)\nWHERE (e.company_id = c.id)\n AND (c.active = $2)\n AND (reg.name = $4)'); + expect(query.values).toEqual(['active', true, 'Engineering', 'North']); + }); + + it('should support multiple CTEs', () => { + const query = new UpdateQuery('employees', 'e') + .with([ + new Cte( + 'active_companies', + new SelectQuery('companies', 'c') + .select(['c.id', 'c.name']) + .where('c.active = ?', true), + false + ), + new Cte( + 'active_departments', + new SelectQuery('departments', 'd') + .select(['d.id', 'd.name']) + .where('d.active = ?', true), + false + ) + ]) + .using('active_companies', 'ac') + .join({ + table: 'active_departments', + alias: 'ad', + type: 'INNER', + on: new Statement() + .and('e.department_id = ad.id') + .and('ad.name = ?', 'Engineering') + }) + .set({ 'e.status': 'active' }) + .where( + new Statement() + .and('e.company_id = ac.id') + .and('ac.name = ?', 'TechCorp') + ) + .build(); + + expect(query.text).toBe('WITH active_companies AS (\nSELECT\n "c"."id",\n "c"."name"\nFROM "companies" AS c\nWHERE (c.active = $1)\n), active_departments AS (\nSELECT\n "d"."id",\n "d"."name"\nFROM "departments" AS d\nWHERE (d.active = $1)\n)\nUPDATE "employees" e\nSET "e"."status" = $2\nFROM "active_companies" ac\nINNER JOIN "active_departments" ad\n ON (e.department_id = ad.id) AND (ad.name = $3)\nWHERE (e.company_id = ac.id)\n AND (ac.name = $4)'); + expect(query.values).toEqual([true, 'active', 'Engineering', 'TechCorp']); + }); + + it('should support CTEs with CTEMaker directly', () => { + const cteMaker = new CteMaker( + new Cte( + 'active_companies', + new SelectQuery('companies', 'c') + .select(['c.id', 'c.name']) + .where('c.active = ?', true), + false + ), + new Cte( + 'active_departments', + new SelectQuery('departments', 'd') + .select(['d.id', 'd.name']) + .where('d.active = ?', true), + false + ) + ); + + const query = new UpdateQuery('employees', 'e') + .with(cteMaker) + .using('active_companies', 'ac') + .join({ + table: 'active_departments', + alias: 'ad', + type: 'INNER', + on: new Statement() + .and('e.department_id = ad.id') + .and('ad.name = ?', 'Engineering') + }) + .set({ 'e.status': 'active' }) + .where( + new Statement() + .and('e.company_id = ac.id') + .and('ac.name = ?', 'TechCorp') + ) + .build(); + + expect(query.text).toBe('WITH active_companies AS (\nSELECT\n "c"."id",\n "c"."name"\nFROM "companies" AS c\nWHERE (c.active = $1)\n), active_departments AS (\nSELECT\n "d"."id",\n "d"."name"\nFROM "departments" AS d\nWHERE (d.active = $1)\n)\nUPDATE "employees" e\nSET "e"."status" = $2\nFROM "active_companies" ac\nINNER JOIN "active_departments" ad\n ON (e.department_id = ad.id) AND (ad.name = $3)\nWHERE (e.company_id = ac.id)\n AND (ac.name = $4)'); + expect(query.values).toEqual([true, 'active', 'Engineering', 'TechCorp']); + }); + + it('should support returning all columns with returnAllFields', () => { + const query = new UpdateQuery('users') + .set({ name: 'Alice', age: 28 }) + .returnAllFields() + .where('id = ?', 3) + .build(); + + expect(query.text).toBe('UPDATE "users"\nSET "name" = $1, "age" = $2\nWHERE (id = $3)\nRETURNING *'); + expect(query.values).toEqual(['Alice', 28, 3]); + }); + }); diff --git a/src/queryKinds/dml/update.ts b/src/queryKinds/dml/update.ts new file mode 100644 index 0000000..399f49e --- /dev/null +++ b/src/queryKinds/dml/update.ts @@ -0,0 +1,504 @@ +import CteMaker, { type Cte } from "../../cteMaker.js"; +import SqlEscaper from "../../sqlEscaper.js"; +import Statement from "../../statementMaker.js"; +import type Join from "../../types/Join.js"; +import { isJoinTable } from "../../types/Join.js"; +import QueryKind from "../../types/QueryKind.js"; +import type SetValue from "../../types/SetValue.js"; +import DmlQueryDefinition from "./dmlQueryDefinition.js"; + +/** + * UpdateQuery class is used to build SQL UPDATE queries. + * It provides methods to specify the table to update, set values, add joins, and define conditions. + * The class supports Common Table Expressions (CTEs) and returning clauses. + * It extends the DmlQueryDefinition class to inherit common query functionalities. + */ +export default class UpdateQuery extends DmlQueryDefinition { + /** The table to update. */ + private table: string; + /** Optional alias for the table. */ + private tableAlias: string | null = null; + /** Optional USING clause table. */ + private usingTable: string | null = null; + /** Optional alias for the USING table. */ + private usingAlias: string | null = null; + + /** JOIN clauses for the update. */ + private joins: Join[] = []; + /** SET values for the update. */ + private setValues: SetValue[] = []; + /** RETURNING fields. */ + private returningFields: string[] = []; + /** Flag to indicate that all fields should be returned. */ + private returnAll: boolean = false; + /** + * Creates an instance of UpdateQuery. + * @param table - The name of the table to update. + * @param alias - An optional alias for the table. + */ + constructor(table?: string, alias?: string) { + super(); + this.table = table ? SqlEscaper.escapeTableName(table, this.flavor) : ""; + this.tableAlias = alias || null; + } + + /** + * Adds Common Table Expressions (CTEs) to the query. + * Accepts a CteMaker instance, a single Cte, or an array of Ctes. + * @param ctes - The CTEs to be added to the query. + * @returns The current UpdateQuery instance for method chaining. + */ + public with(ctes: CteMaker | Cte | Cte[]): this { + if (ctes instanceof CteMaker) { + this.ctes = ctes; + } else if (Array.isArray(ctes)) { + this.ctes = new CteMaker(...ctes); + } else { + this.ctes = new CteMaker(ctes); + } + return this; + } + + /** + * Specifies the table to update and an optional alias. + * @param table - The name of the table. + * @param alias - An optional alias for the table. + * @returns The current UpdateQuery instance for method chaining. + */ + public from(table: string, alias: string | null = null): this { + this.table = SqlEscaper.escapeTableName(table, this.flavor); + this.tableAlias = alias; + return this; + } + + /** + * Specifies the USING clause table and an optional alias. + * @param table - The name of the table for the USING clause. + * @param alias - An optional alias for the USING table. + * @returns The current UpdateQuery instance for method chaining. + */ + public using(table: string, alias: string | null = null): this { + this.usingTable = SqlEscaper.escapeTableName(table, this.flavor); + this.usingAlias = alias; + return this; + } + + /** + * Adds JOIN clauses to the update query. + * Accepts either a single Join object or an array of Join objects. + * @param join - The JOIN clause(s) to be added. + * @returns The current UpdateQuery instance for method chaining. + */ + public join(join: Join | Join[]): this { + if (Array.isArray(join)) { + this.joins.push(...join.map((j) => this.parseJoinObject(j))); + } else { + this.joins.push(this.parseJoinObject(join)); + } + return this; + } + + /** + * Specifies the SET values for the update. + * Accepts either a single SetValue object or an array of SetValue objects. + * @param values - The SET value(s) to be added. + * @returns The current UpdateQuery instance for method chaining. + */ + public set(values: SetValue | SetValue[] | { [key: string]: any }): this { + if (Array.isArray(values)) { + this.setValues = values + .filter((v) => v.value !== undefined) + .map((v) => ({ + setColumn: v.setColumn + ? SqlEscaper.escapeTableName(v.setColumn, this.flavor) + : "", + from: v.from + ? SqlEscaper.escapeTableName(v.from, this.flavor) + : (undefined as any), + value: v.value ?? null, + })); + } else if ( + values?.setColumn && + (values?.from || values?.value !== undefined) + ) { + this.setValues = [ + { + setColumn: values.setColumn + ? SqlEscaper.escapeTableName(values.setColumn, this.flavor) + : "", + from: values.from + ? SqlEscaper.escapeTableName(values.from, this.flavor) + : (undefined as any), + value: values.value ?? null, + }, + ]; + } else if (typeof values === "object" && !Array.isArray(values)) { + this.setValues = Object.entries(values) + .filter(([, val]) => { + return val !== undefined; + }) + .map(([key, val]) => ({ + setColumn: SqlEscaper.escapeTableName(key, this.flavor), + value: val, + })); + } + return this; + } + + /** + * Adds a SET clause with a value from another column or expression. + * Example: addSet('column1', 'column2 + 1') results in "SET column1 = column2 + 1". + * @param column - The column to be set. + * @param from - The column or expression to set the value from. + * @returns The current UpdateQuery instance for method chaining. + */ + public addSet(column: string, from: string): this { + this.setValues.push({ + setColumn: SqlEscaper.escapeTableName(column, this.flavor), + from: SqlEscaper.escapeTableName(from, this.flavor), + }); + return this; + } + + /** + * Adds a SET clause with a direct value. + * Example: addSetValue('column1', 42) results in "SET column1 = $1" with 42 as a parameter. + * @param column - The column to be set. + * @param value - The value to set the column to. + * @returns The current UpdateQuery instance for method chaining. + */ + public addSetValue(column: string, value: any): this { + this.setValues.push({ + setColumn: SqlEscaper.escapeTableName(column, this.flavor), + value, + }); + return this; + } + + /** + * Specifies the WHERE clause for the update. + * Accepts either a Statement object or a raw SQL string with optional parameters. + * @param statement - The WHERE clause as a Statement or raw SQL string. + * @param values - Optional parameters for the raw SQL string. + * @returns The current UpdateQuery instance for method chaining. + */ + public where(statement: Statement | string, ...values: any[]): this { + if (typeof statement === "string") { + statement = new Statement().raw("", statement, ...values); + } + + this.whereStatement = statement; + return this; + } + + /** + * Allows using a callback to build the WHERE clause with a Statement object. + * Example: useStatement(stmt => stmt.raw('id = $1', 42)) results in "WHERE id = $1" with 42 as a parameter. + * @param statement - A callback function that receives a Statement object. + * @returns The current UpdateQuery instance for method chaining. + */ + public useStatement( + statement: (stmt: Statement) => Statement | undefined | void, + ): this { + const stmt = new Statement(); + const newStmt = statement(stmt) || stmt; + return this.where(newStmt); + } + + /** + * Specifies that all fields should be returned after the update. + * This sets the RETURNING clause to '*'. + * @returns The current UpdateQuery instance for method chaining. + */ + public returnAllFields(): this { + this.returnAll = true; + this.returningFields = []; + return this; + } + + /** + * Specifies the RETURNING fields for the update. + * Accepts either a single field name or an array of field names. + * @param fields - The field(s) to be returned after the update. + * @returns The current UpdateQuery instance for method chaining. + */ + public returning(fields: string | string[]): this { + this.returningRaw( + SqlEscaper.escapeSelectIdentifiers( + Array.isArray(fields) ? fields : [fields], + this.flavor, + ), + ); + return this; + } + + /** + * Adds additional RETURNING fields to the update. + * Accepts either a single field name or an array of field names. + * @param field - The field(s) to be added to the RETURNING clause. + * @returns The current UpdateQuery instance for method chaining. + */ + public addReturning(fields: string | string[]): this { + this.addReturningRaw( + SqlEscaper.escapeSelectIdentifiers( + Array.isArray(fields) ? fields : [fields], + this.flavor, + ), + ); + return this; + } + + /** + * Specifies raw RETURNING fields for the update without escaping. + * Accepts either a single raw field string or an array of raw field strings. + * Use with caution to avoid SQL injection. + * @param fields - The raw field(s) to be returned after the update. + * @returns The current UpdateQuery instance for method chaining. + */ + public returningRaw(fields: string | string[]): this { + this.returnAll = false; + if (Array.isArray(fields)) { + this.returningFields = fields; + } else { + this.returningFields = [fields]; + } + return this; + } + + /** + * Adds additional raw RETURNING fields to the update without escaping. + * Accepts either a single raw field string or an array of raw field strings. + * Use with caution to avoid SQL injection. + * @param field - The raw field(s) to be added to the RETURNING clause. + * @returns The current UpdateQuery instance for method chaining. + */ + public addReturningRaw(field: string | string[]): this { + this.returnAll = false; + if (Array.isArray(field)) { + this.returningFields.push(...field); + } else { + this.returningFields.push(field); + } + return this; + } + + /** + * Builds the final SQL UPDATE query string and collects the parameters. + * It handles CTEs, SET clauses, JOINs, WHERE conditions, and RETURNING fields. + * The method ensures proper parameter indexing and returns the query text and values. + * @param deepAnalysis - If true, performs a deeper analysis for duplicate parameters. + * @returns An object containing the built query text and its parameters. + * @throws Error if no table or SET values are specified. + */ + public build(deepAnalysis: boolean = false): { text: string; values: any[] } { + if (this.table.trim() === "") { + throw new Error("No table specified for UPDATE query."); + } + + if (this.setValues.length === 0) { + throw new Error("No SET values specified for UPDATE query."); + } + + this.whereStatement = this.whereStatement || new Statement(); + this.whereStatement?.setOffset(1); + + let ctesClause = ""; + let cteValues: any[] = []; + let offset = 0; + if (this.ctes) { + const ctesBuilt = this.ctes.build(); + ctesClause = ctesBuilt.text; + cteValues = ctesBuilt.values; + this.whereStatement?.addOffset(cteValues.length); + offset += cteValues.length; + } + + let updateClause = `UPDATE ${this.table}`; + if (this.tableAlias) { + updateClause += ` ${this.tableAlias}`; + } + + let setClause = "SET "; + const setParts: string[] = []; + const setValues: any[] = []; + this.setValues.forEach((sv) => { + if (sv.value !== undefined) { + offset += 1; + setParts.push(`${sv.setColumn} = $${offset}`); + this.whereStatement?.addOffset(1); + setValues.push(sv.value); + } else if (sv.from !== undefined) { + setParts.push(`${sv.setColumn} = ${sv.from}`); + } else { + throw new Error( + `SET value for column ${sv.setColumn} must have either 'value' or 'from' defined.`, + ); + } + }); + setClause += setParts.join(", "); + + let usingClause = ""; + if (this.usingTable) { + usingClause = `FROM ${this.usingTable}`; + if (this.usingAlias) { + usingClause += ` ${this.usingAlias}`; + } + } + + if (usingClause === "" && this.joins.length > 0) { + throw new Error("JOINs require a USING clause in UPDATE queries."); + } + + let joinClauses = ""; + let currentOffset = setValues.length + cteValues.length; + const parametersToAdd: any = []; + for (const join of this.joins) { + if (isJoinTable(join)) { + const onClause = + typeof join.on === "string" + ? join.on + : (() => { + join.on.disableWhere(); + join.on.addOffset(currentOffset); + const stmt = join.on.build(false); + currentOffset += stmt.values.length; + parametersToAdd.push(...stmt.values); + return stmt.statement; + })(); + joinClauses += `${joinClauses ? "\n" : ""}${join.type.toUpperCase()} JOIN ${join.table} ${join.alias}\n ON ${onClause}`; + } else { + join.subQuery.resetWhereOffset(); + join.subQuery.addWhereOffset(currentOffset); + (join.subQuery as any).disabledAnalysis = true; + const subQueryBuilt = join.subQuery.build(deepAnalysis); + (join.subQuery as any).disabledAnalysis = false; + currentOffset += subQueryBuilt.values.length; + parametersToAdd.push(...subQueryBuilt.values); + const onClause = + typeof join.on === "string" + ? join.on + : (() => { + join.on.disableWhere(); + join.on.addOffset(currentOffset); + const stmt = join.on.build(false); + currentOffset += stmt.values.length; + parametersToAdd.push(...stmt.values); + return stmt.statement; + })(); + subQueryBuilt.text = this.spaceLines(subQueryBuilt.text, 1); + joinClauses += `${joinClauses ? "\n" : ""}${join.type.toUpperCase()} JOIN (\n${subQueryBuilt.text}\n) ${join.alias}\n ON ${onClause}`; + } + } + + this.whereStatement.addParams(parametersToAdd); + + this.whereStatement.enableWhere(); + const stmt = this.whereStatement.build(); + const whereClause = stmt.statement; + const values = stmt.values; + + let returningClause = ""; + if (this.returningFields.length > 0) { + returningClause = `RETURNING ${this.returningFields.join(", ")}`; + } + + this.builtQuery = [ + ctesClause, + updateClause, + setClause, + usingClause, + joinClauses, + whereClause, + returningClause || (this.returnAll ? "RETURNING *" : ""), + ] + .filter((part) => part !== "") + .join("\n"); + + this.builtQuery = SqlEscaper.appendSchemas(this.builtQuery, this.schemas); + + const allValues = [...cteValues, ...setValues, ...values]; + + const analyzed = this.reAnalyzeParsedQueryForDuplicateParams( + this.builtQuery, + allValues, + deepAnalysis, + ); + this.builtQuery = analyzed.text; + this.builtParams = analyzed.values; + return { text: this.builtQuery, values: this.builtParams }; + } + + /** + * Returns the built SQL query string. + * If the query is not yet built, it triggers the build process. + * @returns The SQL query string. + */ + public toSQL(): string { + if (!this.builtQuery) this.build(); + if (!this.builtQuery) throw new Error("Failed to build the SQL query."); + return this.builtQuery; + } + + /** + * This an UPDATE query. + * @returns The string 'UPDATE'. + */ + public get kind() { + return QueryKind.UPDATE; + } + + /** + * Resets the query definition to its initial state. + * Clears all properties related to the query configuration. + * @returns void + */ + public reset(): void { + this.table = ""; + this.tableAlias = null; + this.usingTable = null; + this.usingAlias = null; + this.joins = []; + this.setValues = []; + this.whereStatement = null; + this.returningFields = []; + this.builtQuery = null; + this.ctes = null; + this.returnAll = false; + this.schemas = []; + } + + /** + * Retrieves the parameters associated with the query. + * If the query is not yet built, it triggers the build process. + * @returns An array of parameters for the query. + */ + public getParams(): any[] { + if (!this.builtParams) this.build(); + if (!this.builtParams) throw new Error("Failed to build the SQL query."); + return this.builtParams; + } + + /** + * Creates a deep copy of the current UpdateQuery instance. + * This is useful for preserving the current state of the query while making modifications to a clone. + * @returns A new UpdateQuery instance that is a clone of the current instance. + */ + public clone(): UpdateQuery { + const cloned = new UpdateQuery(); + cloned.table = this.table; + cloned.tableAlias = this.tableAlias; + cloned.flavor = this.flavor; + cloned.schemas = [...this.schemas]; + cloned.usingTable = this.usingTable; + cloned.usingAlias = this.usingAlias; + cloned.joins = JSON.parse(JSON.stringify(this.joins)); + cloned.setValues = JSON.parse(JSON.stringify(this.setValues)); + cloned.whereStatement = this.whereStatement + ? this.whereStatement.clone() + : null; + cloned.returningFields = [...this.returningFields]; + cloned.ctes = this.ctes ? new CteMaker(...this.ctes["ctes"]) : null; + cloned.returnAll = this.returnAll; + return cloned; + } +} diff --git a/src/queryKinds/insert.ts b/src/queryKinds/insert.ts deleted file mode 100644 index 90fc76f..0000000 --- a/src/queryKinds/insert.ts +++ /dev/null @@ -1,304 +0,0 @@ -import CteMaker, { Cte } from "../cteMaker.js"; -import SqlEscaper from "../sqlEscaper.js"; -import ColumnValue from "../types/ColumnValue.js"; -import QueryKind from "../types/QueryKind.js"; -import QueryDefinition from "./query.js"; -import SelectQuery from "./select.js"; - -/** - * InsertQuery helps in constructing SQL INSERT queries. - * It supports inserting values directly or from a SELECT query. - * It also supports Common Table Expressions (CTEs) and RETURNING clauses. - */ -export default class InsertQuery extends QueryDefinition { - /** The table into which records will be inserted. */ - private table: string; - /** The column-value pairs to be inserted. */ - private columnValues: ColumnValue[] = []; - /** An optional SELECT query to insert data from. */ - private selectQuery: SelectQuery | null = null; - /** The fields to be returned after the insert operation. */ - private returningFields: string[] = []; - - /** - * Creates an instance of InsertQuery. - * @param table - The name of the table into which records will be inserted. - */ - constructor(table?: string) { - super(); - this.table = table ? SqlEscaper.escapeTableName(table, this.flavor) : ''; - } - - /** - * Adds Common Table Expressions (CTEs) to the query. - * Accepts a CteMaker instance, a single Cte, or an array of Ctes. - * @param ctes - The CTEs to be added to the query. - * @returns The current InsertQuery instance for method chaining. - */ - public with(ctes: CteMaker | Cte | Cte[]): this { - if (ctes instanceof CteMaker) { - this.ctes = ctes; - } else if (Array.isArray(ctes)) { - this.ctes = new CteMaker(...ctes); - } else { - this.ctes = new CteMaker(ctes); - } - return this; - } - - /** - * Specifies the table into which records will be inserted. - * @param table - The name of the table. - * @returns The current InsertQuery instance for method chaining. - */ - public into(table: string): this { - this.table = SqlEscaper.escapeTableName(table, this.flavor); - return this; - } - - /** - * Specifies the column-value pairs to be inserted. - * Accepts either an array of ColumnValue objects or an object mapping column names to values. - * @param columnValues - The column-value pairs to be inserted. - * @returns The current InsertQuery instance for method chaining. - */ - public values(columnValues: ColumnValue[] | { [column: string]: any }): this { - if (Array.isArray(columnValues)) { - this.columnValues = columnValues - .filter(v => v.value !== undefined) - .map(v => ({ - column: SqlEscaper.escapeIdentifier(v.column, this.flavor), value: v.value ?? null - })); - } else { - this.columnValues = Object.entries(columnValues) - .filter(([, value]) => value !== undefined) - .map(([column, value]) => ({ - column: SqlEscaper.escapeIdentifier(column, this.flavor), - value: value ?? null - })); - } - return this; - } - - /** - * Specifies the columns to be inserted without associated values. - * This is useful when inserting data from a SELECT query. - * @param columns - The names of the columns to be inserted. - * @returns The current InsertQuery instance for method chaining. - */ - public columns(...columns: string[]): this { - this.columnValues = columns.map(column => ({ - column: SqlEscaper.escapeIdentifier(column, this.flavor), value: undefined - })); - return this; - } - - /** - * Specifies a SELECT query to insert data from. - * This allows inserting records based on the results of another query. - * @param query - The SELECT query to insert data from. - * @returns The current InsertQuery instance for method chaining. - */ - public fromSelect(query: SelectQuery): this { - this.selectQuery = query; - return this; - } - - /** - * Specifies the fields to be returned after the insert operation. - * @param fields - A single field or an array of fields to be returned. - * @returns The current InsertQuery instance for method chaining. - */ - public returning(fields: string | string[]): this { - if (Array.isArray(fields)) { - this.returningFields = SqlEscaper.escapeSelectIdentifiers(fields, this.flavor); - } else { - this.returningFields = SqlEscaper.escapeSelectIdentifiers([fields], this.flavor); - } - return this; - } - - /** - * Adds fields to the existing RETURNING clause. - * @param fields - A single field or an array of fields to be added to the RETURNING clause. - * @returns The current InsertQuery instance for method chaining. - */ - public addReturning(fields: string | string[]): this { - if (Array.isArray(fields)) { - this.returningFields.push(...SqlEscaper.escapeSelectIdentifiers(fields, this.flavor)); - } else { - this.returningFields.push(...SqlEscaper.escapeSelectIdentifiers([fields], this.flavor)); - } - return this; - } - - /** - * Creates a deep clone of the current InsertQuery instance. - * @returns A new InsertQuery instance with the same properties as the original. - */ - public clone(): InsertQuery { - const cloned = new InsertQuery(); - cloned.table = this.table; - cloned.schemas = [...this.schemas]; - cloned.columnValues = JSON.parse(JSON.stringify(this.columnValues)); - cloned.selectQuery = this.selectQuery ? this.selectQuery.clone() : null; - cloned.returningFields = [...this.returningFields]; - cloned.ctes = this.ctes ? new CteMaker(...this.ctes['ctes']) : null; - return cloned; - } - - /** - * This an INSERT query. - * @returns The kind of SQL operation, which is 'INSERT' for this class. - */ - public get kind() { - return QueryKind.INSERT; - } - - /** - * Invalidates the current state of the query, forcing a rebuild on the next operation. - * @returns void - */ - public invalidate(): void { - this.builtQuery = null; - this.selectQuery?.invalidate(); - if (this.ctes) { - for (const cte of this.ctes['ctes']) { - cte['query'].invalidate(); - } - } - } - - /** - * Resets the query to its initial state. - * @returns void - */ - public reset(): void { - this.table = ''; - this.schemas = []; - this.columnValues = []; - this.selectQuery = null; - this.returningFields = []; - this.builtQuery = null; - this.ctes = null; - } - - /** - * Retrieves the parameters associated with the query. - * @returns An array of parameters for the query. - */ - private getInternalParams(): any[] { - if (!this.builtQuery) this.build(); - let params: any[] = []; - if (this.columnValues.length > 0) { - params = this.columnValues.map(cv => cv.value); - } else if (this.selectQuery) { - params = this.selectQuery.getParams(); - } - if (this.ctes) { - params = [...this.ctes.build().values, ...params]; - } - return params; - } - - /** - * Retrieves the parameters associated with the query, building the query if necessary. - * @returns An array of parameters for the query. - */ - public getParams(): any[] { - if (!this.builtQuery) this.build(); - if (!this.builtParams) throw new Error('Failed to build query parameters.'); - return this.builtParams; - } - - /** - * Builds the SQL INSERT query and returns an object containing the query text and its parameters. - * The optional deepAnalysis parameter can be used to control the depth of analysis during the build process. - * @param deepAnalysis - Whether to perform deep analysis during the build process. - * @returns An object containing the query text and its parameters. - * @throws Error if no table is specified or if neither values nor a SELECT query is provided. - */ - public build(deepAnalysis: boolean = false): { text: string; values: any[] } { - if (!this.table) { - throw new Error('No table specified for INSERT query.'); - } - if (this.columnValues.length === 0 && !this.selectQuery) { - throw new Error('No values or SELECT query specified for INSERT query.'); - } - - let ctesClause = ''; - let cteValues: any[] = []; - if (this.ctes) { - const ctesBuilt = this.ctes.build(); - ctesClause = ctesBuilt.text; - cteValues = ctesBuilt.values; - this.selectQuery?.addWhereOffset(cteValues.length); - } - - const columns = this.columnValues.map(cv => cv.column); - let insertClause = `INSERT INTO ${this.table} (${columns.join(', ')})`; - if (this.columnValues.length > 0) { - if (this.columnValues.some(cv => cv.value !== undefined)) { - const valuePlaceholders = this.columnValues.map((_, idx) => `$${idx + 1}`); - insertClause += ` VALUES (${valuePlaceholders.join(', ')})`; - } - } else if (this.selectQuery) { - // Use columns from select query if not specified - const selectColumns = this.selectQuery.columns; - if (selectColumns.length > 0 && columns.length === 0) { - const parsedColumns = selectColumns.map(col => { - const regex = /^(?:(?:"?[\w$]+"?\.)?"?([\w$]+)"?(?:\s+AS\s+"?([\w$]+)"?)?)$/i; - const match = col.match(regex); - if (match) { - return SqlEscaper.escapeIdentifier(match[2]! || match[1]!, this.flavor); - } - return SqlEscaper.escapeIdentifier(col, this.flavor); - }); - insertClause = `INSERT INTO ${this.table} (${parsedColumns.join(', ')})`; - } - } - - if (this.selectQuery) { - const selectBuilt = this.selectQuery.build(); - insertClause += `\n${selectBuilt.text}`; - cteValues = [...cteValues, ...selectBuilt.values]; - } - - let returningClause = ''; - if (this.returningFields.length > 0) { - returningClause = `RETURNING ${this.returningFields.join(', ')}`; - } - - const text = [ - ctesClause ? `${ctesClause} ` : '', - insertClause, - returningClause - ].join('\n').trim(); - this.builtQuery = text; - - this.builtQuery = SqlEscaper.appendSchemas( - this.builtQuery, this.schemas - ); - - const analyzed = this.reAnalyzeParsedQueryForDuplicateParams( - this.builtQuery, - [...cteValues, ...this.getInternalParams()], - deepAnalysis - ); - - this.builtQuery = analyzed.text; - this.builtParams = analyzed.values; - return { text: this.builtQuery, values: this.builtParams }; - } - - /** - * Converts the built SQL query to a string. - * @returns The SQL query string. - */ - public toSQL(): string { - if (!this.builtQuery) this.build(); - if (!this.builtQuery) throw new Error('Failed to build query.'); - - return this.builtQuery; - } -} diff --git a/src/queryKinds/query.ts b/src/queryKinds/query.ts deleted file mode 100644 index ef2ca82..0000000 --- a/src/queryKinds/query.ts +++ /dev/null @@ -1,429 +0,0 @@ -import { ValidatorOptions } from "class-validator"; -import deepEqual from "../deepEqual.js"; -import { getClassValidator, getZod } from "../getOptionalPackages.js"; -// Import types only since they are used for type checking only -// and zod is optional peer dependency -import type z from "zod"; -import type { ZodObject } from "zod"; -import sqlFlavor from "../types/sqlFlavor.js"; -import CteMaker from "../cteMaker.js"; -import Statement from "../statementMaker.js"; -import QueryKind from "../types/QueryKind.js"; - -/** - * An array of function names that can be used to execute SQL queries. - * These functions are commonly found in database client libraries. - */ -const functionNames = ['execute', 'query', 'run', 'all', 'get'] as const; - -/** - * FunctionDeclaration type defines the signature for functions that execute SQL queries. - * It takes a query string and an array of parameters, and returns a promise that resolves - * to either an array of results or an object containing a rows property with the results. - */ -type FunctionDeclaration = (query: string, params: any[]) => Promise; - -/** - * QueryExecutorObject interface defines the structure for an object that can execute SQL queries. - * It includes optional methods for executing queries in different ways, as well as an optional manager property. - */ -interface QueryExecutorObject { - execute?: FunctionDeclaration; - query?: FunctionDeclaration; - run?: FunctionDeclaration; - all?: FunctionDeclaration; - get?: FunctionDeclaration; - manager?: QueryExecutor; -} - -/** - * QueryExecutor type can be either a QueryExecutorObject or a function that executes a query. - */ -type QueryExecutor = - QueryExecutorObject - | FunctionDeclaration; - -/** - * SchemaType is a conditional type that infers the type of data based on the provided schema. - * It supports both Zod schemas and class-validator classes. - */ -type SchemaType = - S extends ZodObject ? z.infer : - S extends { new(): infer U } ? U : - never; - -/** - * Abstract class QueryDefinition serves as a blueprint for different types of SQL query definitions. - * It defines the essential methods and properties that any concrete query class must implement. - * This includes methods for building the SQL query, executing it, cloning the query definition, - * resetting its state, and checking if the query is complete. - * The class also provides a method to re-analyze the query for duplicate parameters to optimize parameter usage. - */ -export default abstract class QueryDefinition { - - /** - * Converts the query definition to its SQL string representation. - */ - public abstract toSQL(): string; - - /** - * Retrieves the parameters associated with the query. - */ - public abstract getParams(): any[]; - - /** - * Builds the SQL query and returns an object containing the query text and its parameters. - * The optional deepAnalysis parameter can be used to control the depth of analysis during the build process. - */ - public abstract build(deepAnalysis?: boolean): { - /** The SQL query text. */ - text: string; - /** The parameters for the SQL query. */ - values: any[]; - }; - - /** - * Creates a deep copy of the current query definition. - */ - public abstract clone(): QueryDefinition; - - /** - * Resets the query definition to its initial state. - */ - public abstract reset(): void; - - /** - * Indicates whether the query is complete and ready for execution. - * @returns True if the query has been built and has parameters, false otherwise. - */ - public get isDone(): boolean { - return this.builtQuery !== null && this.builtParams !== null; - } - - /** - * The kind of SQL operation represented by the query definition. - * It can be one of 'INSERT', 'UPDATE', 'DELETE', or 'SELECT'. - */ - public abstract get kind(): QueryKind; - - /** - * Provides access to the current query definition instance. - * @returns The current QueryDefinition instance. - */ - public get query(): QueryDefinition { - return this; - } - - protected spaceLines(str: string, spaces: number = 0): string { - const space = ' '.repeat(spaces); - return str.split('\n').map(line => space + line).join('\n'); - } - - /** - * The SQL flavor to use for escaping identifiers. - * Default is PostgreSQL. - */ - protected flavor: sqlFlavor = sqlFlavor.postgres; - - /** - * Schemas to be used in the query. - * This is useful for databases that support multiple schemas. - * NOTICE: SQL Injection is not checked in schema names. Be sure to use only trusted schema names. - */ - protected schemas: string[] = []; - - /** - * The built SQL query string. - * Null if the query has not been built yet or has been invalidated. - */ - protected builtQuery: string | null = null; - - /** - * The parameters for the built SQL query. - * Null if the query has not been built yet or has been invalidated. - */ - protected builtParams: any[] | null = null; - - /** Optional Common Table Expressions (CTEs) for the query. */ - protected ctes: CteMaker | null = null; - - /** The WHERE clause statement. */ - protected whereStatement: Statement | null = null; - - /** - * Invalidates the current state of the query, forcing a rebuild on the next operation. - * @returns void - */ - public invalidate(): void { - this.builtQuery = null; - this.builtParams = null; - if (this.whereStatement) this.whereStatement.invalidate(); - if (this.ctes) { - for (const cte of this.ctes['ctes']) { - cte['query'].invalidate(); - } - } - } - - /** - * Sets the SQL flavor for escaping identifiers. - * @param flavor The SQL flavor to set. - * @returns The current QueryDefinition instance for chaining. - */ - public sqlFlavor(flavor: sqlFlavor) { - this.flavor = flavor; - return this; - } - - /** - * Set schemas to be used in the query. - * This is useful for databases that support multiple schemas. - * NOTICE: SQL Injection is not checked in schema names. Be sure to use only trusted schema names. - * @param schemas The schemas to set. - * @returns The current SelectQuery instance for chaining. - */ - public schema(...schemas: string[]): this { - this.schemas = schemas; - return this; - } - - /** - * Adds schemas to the existing list of schemas. - * This is useful for databases that support multiple schemas. - * NOTICE: SQL Injection is not checked in schema names. Be sure to use only trusted schema names. - * @param schemas The schemas to add. - * @returns The current SelectQuery instance for chaining. - */ - public addSchema(...schemas: string[]): this { - this.schemas.push(...schemas); - return this; - } - - /** - * True if the schema to validate against is a Zod schema, false otherwise. - */ - private isZodSchema: boolean = false; - - /** - * True if the schema to validate against is a class-validator schema, false otherwise. - */ - private isClassValidatorSchema: boolean = false; - - /** - * The schema to validate against, can be either a Zod schema or a class-validator class. - * Null if no schema is set. - */ - private validatorSchema: any = null; - - /** - * Options for class-validator validation. - * Default options are set to whitelist properties, forbid non-whitelisted properties, - * and exclude the target object from validation errors. - */ - private classValidatorOptions: ValidatorOptions = { - whitelist: true, - forbidNonWhitelisted: false, - validationError: { target: false } - }; - - /** - * Use zod or class-validator + class-transformer to validate the input schema. - * @param schema The schema to validate against. - * @returns A promise that resolves if the schema is valid, or rejects with validation errors. - * @throws An error if no validation library is available. - */ - public validate any)>( - schema: T - ): QueryDefinition> { - if ((schema as any).safeParse) { - this.isZodSchema = true; - } else { - this.isClassValidatorSchema = true; - } - this.validatorSchema = schema; - - return this; - } - - /** - * Configures options for class-validator validation. - * @param config The configuration options for class-validator. - * @returns The current QueryDefinition instance for method chaining. - */ - public classValidatorConfig( - config: ValidatorOptions - ) { - this.classValidatorOptions = config; - return this; - } - - /** - * Handles validation of the input data against the set schema. - * Supports both Zod schemas and class-validator classes. - * @param input The data to validate. - * @returns A promise that resolves if the data is valid, or rejects with validation errors. - * @throws An error if no validation library is available. - */ - private async handleValidation(input: any) { - if (this.validatorSchema) { - if (this.isZodSchema) { - const zod = await getZod(); - return await zod.array(this.validatorSchema).parseAsync(input); - } else if (this.isClassValidatorSchema) { - const { classValidator, classTransformer } = await getClassValidator(); - const { validateOrReject } = classValidator; - const transformed = classTransformer.plainToInstance(this.validatorSchema, input); - for (const item of transformed) { - // Use class-validator to validate each item - await validateOrReject(item as any, this.classValidatorOptions); - } - - return transformed; - } - } - - return input; - } - - /** - * Builds the SQL query and re-analyzes it for duplicate parameters. - * This method ensures that the query is optimized by removing redundant parameters. - * @returns An object containing the optimized query text and its parameters. - * @throws An error if the build process fails. - */ - public buildReanalyze(): { text: string; values: any[] } { - const query = this.build(); - return this.reAnalyzeParsedQueryForDuplicateParams(query.text, query.values); - } - - /** - * Executes the built SQL query using the provided query executor. - * The query executor can be a function or an object with methods to execute the query. - * The optional noManager parameter can be used to bypass the manager property if present. - * @param queryExecutor The executor to run the SQL query. - * @param noManager If true, bypasses the manager property of the executor object. - * @returns A promise that resolves with the result of the query execution. - * @throws An error if the provided query executor is invalid or if validation fails. - */ - public async execute( - queryExecutor: QueryExecutor, - noManager: boolean = false - ): Promise { - if (typeof queryExecutor === 'function') { - const builtQuery = this.build(); - const result = await queryExecutor(builtQuery.text, builtQuery.values); - if ((result as any)?.rows) { - return await this.handleValidation((result as any).rows); - } else { - return await this.handleValidation(result); - } - } - - if (!noManager && queryExecutor?.manager && typeof queryExecutor?.manager === 'object') { - for (const functionName of functionNames) { - if (typeof queryExecutor.manager[functionName] === 'function') { - const builtQuery = this.build(); - const result = await queryExecutor.manager[functionName]!(builtQuery.text, builtQuery.values); - if ((result as any)?.rows) { - return await this.handleValidation((result as any).rows); - } else { - return await this.handleValidation(result); - } - } - } - } else { - for (const functionName of functionNames) { - if (typeof queryExecutor[functionName] === 'function') { - const builtQuery = this.build(); - const result = await queryExecutor[functionName]!(builtQuery.text, builtQuery.values); - if ((result as any)?.rows) { - return await this.handleValidation((result as any).rows); - } else { - return await this.handleValidation(result); - } - } - } - } - - throw new Error('Invalid query executor provided.'); - } - - /** - * Builds the SQL query with EXPLAIN ANALYZE prefix for performance analysis. - * This method is useful for debugging and optimizing SQL queries. - */ - public buildExplainAnalyze() { - const query = this.build(); - return { - text: `EXPLAIN ANALYZE ${query.text}`, - values: query.values - } - } - - /** - * Builds the SQL query with EXPLAIN prefix for query plan analysis. - * This method is useful for understanding how the database will execute the query. - */ - public buildExplain() { - const query = this.build(); - return { - text: `EXPLAIN ${query.text}`, - values: query.values - } - } - - /** - * Re-analyzes the parsed SQL query to identify and consolidate duplicate parameters. - * This method helps optimize the query by reducing the number of parameters used. - * It can perform deep equality checks if specified. - */ - protected reAnalyzeParsedQueryForDuplicateParams( - query: string, - values: any[], - useDeepEqual: boolean = false - ): { text: string; values: any[] } { - return QueryDefinition.reAnalyzeParsedQueryForDuplicateParams(query, values, useDeepEqual); - } - - /** - * Static method to re-analyze a parsed SQL query for duplicate parameters. - * This method can be used independently of any instance of QueryDefinition. - */ - public static reAnalyzeParsedQueryForDuplicateParams( - query: string, - values: any[], - useDeepEqual: boolean = false - ): { text: string; values: any[] } { - const valueMap: Map = new Map(); - let paramIndex = 1; - let newValues: any[] = []; - - const newQuery = query.replace(/\$(\d+)/g, (_, p1) => { - const originalValue = values[parseInt(p1) - 1]; - let foundKey: any = null; - - if (useDeepEqual) { - for (let [key] of valueMap) { - if (deepEqual(key, originalValue)) { - foundKey = key; - break; - } - } - } else { - if (valueMap.has(originalValue)) { - foundKey = originalValue; - } - } - - if (foundKey !== null) { - return `$${valueMap.get(foundKey)}`; - } else { - valueMap.set(originalValue, paramIndex); - newValues.push(originalValue); - return `$${paramIndex++}`; - } - }); - - return { text: newQuery, values: newValues }; - } -} diff --git a/src/queryKinds/select.ts b/src/queryKinds/select.ts deleted file mode 100644 index c40ba59..0000000 --- a/src/queryKinds/select.ts +++ /dev/null @@ -1,674 +0,0 @@ -import CteMaker, { Cte } from "../cteMaker.js"; -import SqlEscaper from "../sqlEscaper.js"; -import Statement from "../statementMaker.js"; -import Join from "../types/Join.js"; -import QueryKind from "../types/QueryKind.js"; -import OrderBy, { isOrderByField } from "../types/OrderBy.js"; -import QueryDefinition from "./query.js"; -import Union from "./union.js"; - -/** - * SelectQuery class represents a SQL SELECT query. - * It includes methods to build various parts of the query such as SELECT fields, WHERE conditions, JOINs, ORDER BY, LIMIT, OFFSET, GROUP BY, and CTEs. - * The class provides functionality to build the final SQL query string and manage query parameters. - */ -export default class SelectQuery extends QueryDefinition { - /** - * The table to select from. - */ - private table: string; - - /** - * An optional alias for the table. - */ - private tableAlias: string | null = null; - - /** - * Indicates whether the SELECT is DISTINCT. - */ - private distinctSelect: boolean = false; - - /** - * The fields to select. - */ - private selectFields: string[]; - - /** - * The HAVING clause statement. - */ - private havingStatement: Statement | null = null; - - /** - * The JOIN clauses. - */ - private joins: Join[] = []; - - /** - * The ORDER BY clauses. - */ - private orderBys: OrderBy[] = []; - - /** - * The LIMIT count. - */ - private limitCount: number | null = null; - - /** - * The OFFSET count. - */ - private offsetCount: number | null = null; - - /** - * The GROUP BY fields. - */ - private groupBys: string[] = []; - - /** - * If true, automatically includes all selected fields in the GROUP BY clause. - */ - private groupBySelectFields: boolean = false; - - /** - * If true, disables deep analysis of the query for duplicate parameters. - */ - private disabledAnalysis: boolean = false; - - /** - * Creates a new SelectQuery instance. - * @param from The table to select from. - * @param alias An optional alias for the table. - * @param groupBySelectFields If true, automatically includes all selected fields in the GROUP BY clause. - */ - constructor( - from?: string, - alias: string | null = null, - groupBySelectFields: boolean = false - ) { - super(); - const escapedFrom = from ? SqlEscaper.escapeTableName(from, this.flavor) : ''; - this.table = escapedFrom; - this.tableAlias = alias; - this.selectFields = ['*']; - this.groupBySelectFields = groupBySelectFields; - } - - /** - * Gets the selected columns. - * @returns A readonly array of selected column names. - */ - public get columns(): Readonly { - return this.selectFields; - } - - /** - * Add an offset to the WHERE clause parameters. - * This is useful when combining multiple statements to ensure parameter indices are correct. - * @param offset The offset to add to the parameter indices. - * @returns The current SelectQuery instance for chaining. - */ - public addWhereOffset(offset: number): this { - if (this.whereStatement) { - this.whereStatement.addOffset(offset); - this.whereStatement.invalidate(); - } - return this; - } - - /** - * Resets the WHERE clause parameter offset to zero. - * This is useful when reusing the query in different contexts. - * @return The current SelectQuery instance for chaining. - */ - public resetWhereOffset(): this { - if (this.whereStatement) { - this.whereStatement.setOffset(1); - this.whereStatement.invalidate(); - } - return this; - } - - /** - * Invalidates the current state of the query, forcing a rebuild on the next operation. - * @returns void - */ - public override invalidate(): void { - super.invalidate(); - if(this.havingStatement) this.havingStatement.invalidate(); - } - - /** - * Adds CTEs to the query. - * Can accept a CteMaker instance, a single Cte object, or an array of Cte objects. - * @param ctes The CTEs to add. - * @returns The current SelectQuery instance for chaining. - */ - public with(ctes: CteMaker | Cte | Cte[]): this { - if (ctes instanceof CteMaker) { - this.ctes = ctes; - } else if (Array.isArray(ctes)) { - this.ctes = new CteMaker(...ctes); - } else { - this.ctes = new CteMaker(ctes); - } - return this; - } - - /** - * Sets the table to select from, with an optional alias. - * @param table The table name. - * @param alias An optional alias for the table. - * @returns The current SelectQuery instance for chaining. - */ - public from(table: string, alias: string | null = null): this { - const escapedTable = SqlEscaper.escapeTableName(table, this.flavor); - this.table = escapedTable; - this.tableAlias = alias; - return this; - } - - /** - * Enables DISTINCT selection. - * @returns The current SelectQuery instance for chaining. - */ - public distinct(): this { - this.distinctSelect = true; - return this; - } - - /** - * Sets the fields to select from. - * Can accept a single field as a string or multiple fields as an array of strings. - * @param fields The fields to select. - * @returns The current SelectQuery instance for chaining. - */ - public select(fields: string | string[]): this { - if (Array.isArray(fields)) { - this.selectFields = - SqlEscaper.escapeSelectIdentifiers(fields, this.flavor); - } else { - this.selectFields = - SqlEscaper.escapeSelectIdentifiers([fields], this.flavor); - } - return this; - } - - /** - * Sets raw SQL fields to select from, without any escaping. - * Can accept a single field as a string or multiple fields as an array of strings. - * @param rawFields The raw SQL fields to select. - * @returns The current SelectQuery instance for chaining. - */ - public rawSelect(rawFields: string | string[]): this { - if (Array.isArray(rawFields)) { - this.selectFields = [...rawFields]; - } else { - this.selectFields = [rawFields]; - } - return this; - } - - /** - * Adds raw SQL fields to the existing selection, without any escaping. - * Can accept a single field as a string or multiple fields as an array of strings. - * @param rawFields The raw SQL fields to add to the selection. - * @returns The current SelectQuery instance for chaining. - */ - public addRawSelect(rawFields: string | string[]): this { - if (Array.isArray(rawFields)) { - this.selectFields.push(...rawFields); - } else { - this.selectFields.push(rawFields); - } - return this; - } - - /** - * Adds fields to the existing selection. - * Can accept a single field as a string or multiple fields as an array of strings. - * @param fields The fields to add to the selection. - * @returns The current SelectQuery instance for chaining. - */ - public addSelect(fields: string | string[]): this { - if (Array.isArray(fields)) { - const escaped = - SqlEscaper.escapeSelectIdentifiers(fields, this.flavor); - this.selectFields.push(...escaped); - } else { - const escaped = - SqlEscaper.escapeSelectIdentifiers([fields], this.flavor); - this.selectFields.push(...escaped); - } - return this; - } - - /** - * Adds a Statement or raw SQL string as the WHERE clause. - * If a string is provided, it will be converted into a raw Statement. - * @param statement The WHERE clause as a Statement or raw SQL string. - * @param values Optional values for parameterized queries. - * @returns The current SelectQuery instance for chaining. - */ - public where(statement: Statement | string, ...values: any[]): this { - if (typeof statement === 'string') { - statement = new Statement().raw('', statement, ...values); - } - - this.whereStatement = statement; - return this; - } - - /** - * Uses a callback to build the WHERE clause statement. - * The callback receives a Statement instance to build upon. - * @param statement A callback function that receives a Statement instance. - * @returns The current SelectQuery instance for chaining. - */ - public useStatement(statement: (stmt: Statement) => Statement | void): this { - const stmt = new Statement(); - const newStmt = statement(stmt) || stmt; - return this.where(newStmt); - } - - /** - * Adds a Statement or raw SQL string as the HAVING clause. - * If a string is provided, it will be converted into a raw Statement. - * @param statement The HAVING clause as a Statement or raw SQL string. - * @param values Optional values for parameterized queries. - * @returns The current SelectQuery instance for chaining. - */ - public having(statement: Statement | string, ...values: any[]): this { - if (typeof statement === 'string') { - statement = new Statement().raw('', statement, ...values); - } - - this.havingStatement = statement; - return this; - } - - /** - * Uses a callback to build the HAVING clause statement. - * The callback receives a Statement instance to build upon. - * @param statement A callback function that receives a Statement instance. - * @returns The current SelectQuery instance for chaining. - */ - public useHavingStatement(statement: (stmt: Statement) => Statement | void): this { - const stmt = new Statement(); - const newStmt = statement(stmt) || stmt; - return this.having(newStmt); - } - - /** - * Adds JOIN clauses to the query, - * either as a single Join object or an array of Join objects. - * @param join The JOIN clause(s) to add. - * @returns The current SelectQuery instance for chaining. - */ - public join( - join: Join | Join[] - ): this { - if (Array.isArray(join)) { - this.joins.push(...join.map(j => ({ - ...j, - table: SqlEscaper.escapeTableName(j.table, this.flavor), - }))); - } else { - this.joins.push({ - ...join, - table: SqlEscaper.escapeTableName(join.table, this.flavor), - }); - } - return this; - } - - /** - * Adds ORDER BY clauses to the query, - * either as a single OrderBy object or an array of OrderBy objects. - * @param orderBy The ORDER BY clause(s) to add. - * @returns The current SelectQuery instance for chaining. - */ - public orderBy( - orderBy: OrderBy | OrderBy[] - ): this { - if (Array.isArray(orderBy)) { - this.orderBys.push( - ...orderBy.map(ob => { - let field = ''; - if(isOrderByField(ob)) field = ob.field; - else field = ob.column; - - return { - ...ob, - field: SqlEscaper.escapeSelectIdentifiers([field], this.flavor)[0]! - } - }) - ); - } else { - let field = ''; - if(isOrderByField(orderBy)) field = orderBy.field; - else field = orderBy.column; - - this.orderBys.push({ - ...orderBy, - field: SqlEscaper.escapeSelectIdentifiers([field], this.flavor)[0]! - }); - } - return this; - } - - /** - * Sets the LIMIT for the query. - * @param count The maximum number of records to return. - * @returns The current SelectQuery instance for chaining. - */ - public limit(count: number): this { - if (typeof count !== 'number' || count < 0 || !Number.isInteger(count)) { - throw new Error("Limit must be a non-negative integer."); - } - this.limitCount = count; - return this; - } - - /** - * Sets the OFFSET for the query. - * @param count The number of records to skip. - * @returns The current SelectQuery instance for chaining. - */ - public offset(count: number): this { - if (typeof count !== 'number' || count < 0 || !Number.isInteger(count)) { - throw new Error("Offset must be a non-negative integer."); - } - this.offsetCount = count; - return this; - } - - /** - * Sets both LIMIT and OFFSET for the query. - * @param limit The maximum number of records to return. - * @param offset The number of records to skip. - * @returns The current SelectQuery instance for chaining. - */ - public limitAndOffset(limit: number, offset: number): this { - if (typeof limit !== 'number' || limit < 0 || !Number.isInteger(limit)) { - throw new Error("Limit must be a non-negative integer."); - } - - if (typeof offset !== 'number' || offset < 0 || !Number.isInteger(offset)) { - throw new Error("Offset must be a non-negative integer."); - } - - this.limitCount = limit; - this.offsetCount = offset; - return this; - } - - /** - * Resets both LIMIT and OFFSET to null. - * @returns The current SelectQuery instance for chaining. - */ - public resetLimitOffset(): this { - this.limitCount = null; - this.offsetCount = null; - return this; - } - - /** - * Resets the entire query to its initial state. - * This includes clearing the table, selected fields, WHERE clause, JOINs, ORDER BY, LIMIT, OFFSET, GROUP BY, CTEs, and any built query. - * @returns void - */ - public reset(): void { - this.table = ''; - this.tableAlias = null; - this.distinctSelect = false; - this.selectFields = ['*']; - this.whereStatement = null; - this.joins = []; - this.orderBys = []; - this.limitCount = null; - this.offsetCount = null; - this.groupBys = []; - this.groupBySelectFields = false; - this.builtQuery = null; - this.builtParams = null; - this.ctes = null; - this.havingStatement = null; - this.disabledAnalysis = false; - this.schemas = []; - } - - /** - * Adds fields to the GROUP BY clause, - * either as a single field or an array of fields. - * @param fields The field(s) to add to the GROUP BY clause. - * @returns The current SelectQuery instance for chaining. - */ - public groupBy(fields: string | string[]): this { - if (Array.isArray(fields)) { - this.groupBys.push(...SqlEscaper.escapeSelectIdentifiers(fields, this.flavor)); - } else { - this.groupBys.push(...SqlEscaper.escapeSelectIdentifiers([fields], this.flavor)); - } - return this; - } - - /** - * Enable grouping by all selected fields. - * This automatically adds all selected fields to the GROUP BY clause. - * @returns The current SelectQuery instance for chaining. - */ - public enableGroupBySelectFields(): this { - this.groupBySelectFields = true; - return this; - } - - /** - * This is a SELECT query. - * @returns 'SELECT' The kind of query. - */ - public get kind() { - return QueryKind.SELECT; - } - - /** - * Get params for the built query. - * If the query is not built yet, it will build it first. - * @returns any[] The parameters for the built query. - */ - public getParams(): any[] { - if (!this.builtParams) this.build(); - if (!this.builtParams) throw new Error("Failed to build query."); - return this.builtParams; - } - - /** - * Creates a deep clone of the current SelectQuery instance. - * This is useful for creating variations of a query without modifying the original. - * @returns A new SelectQuery instance that is a clone of the current instance. - */ - public clone(): SelectQuery { - const cloned = new SelectQuery(); - cloned.table = this.table; - cloned.tableAlias = this.tableAlias; - this.groupBySelectFields = this.groupBySelectFields; - cloned.flavor = this.flavor; - cloned.schemas = [...this.schemas]; - cloned.distinctSelect = this.distinctSelect; - cloned.selectFields = [...this.selectFields]; - cloned.whereStatement = this.whereStatement ? this.whereStatement.clone() : null; - cloned.joins = this.joins.map(j => ({ - type: j.type, - table: j.table, - alias: j.alias, - on: typeof j.on === "string" ? `${j.on}` : j.on.clone() - }) as Join); - cloned.orderBys = [...this.orderBys]; - cloned.limitCount = this.limitCount; - cloned.offsetCount = this.offsetCount; - cloned.groupBys = [...this.groupBys]; - cloned.ctes = this.ctes ? new CteMaker(...this.ctes['ctes']) : null; - return cloned; - } - - /** - * Combines the current SELECT query with another SELECT query using UNION ALL. - * @param query The SELECT query to combine with. - * @returns A new Union instance representing the combined queries. - */ - public unionAll(query: SelectQuery): Union { - return new Union() - .add(this) - .add(query, 'UNION ALL'); - } - - /** - * Combines the current SELECT query with another SELECT query using UNION. - * @param query The SELECT query to combine with. - * @returns A new Union instance representing the combined queries. - */ - public union(query: SelectQuery): Union { - return new Union() - .add(this) - .add(query, 'UNION'); - } - - /** - * Builds the final SQL SELECT query string and returns it along with the associated parameter values. - * If deepAnalysis is true, it will perform a deep analysis to identify and consolidate duplicate parameters. - * Throws an error if the table name is not set. - * @param deepAnalysis Whether to perform deep analysis for duplicate parameters. Default is false. - * @returns An object containing the SQL query string and an array of parameter values. - */ - public build(deepAnalysis: boolean = false): { text: string; values: any[]; } { - if (!this.table) { - throw new Error("Table name is required for SELECT query."); - } - - this.whereStatement = this.whereStatement || new Statement(); - - let ctesClause = ''; - if (this.ctes) { - const ctesBuilt = this.ctes.build(); - ctesClause = ctesBuilt.text; - this.whereStatement.addParams(ctesBuilt.values); - } - - let selectClause = this.selectFields.length > 0 ? this.selectFields.join(',\n ') : '*'; - if (this.groupBySelectFields && this.selectFields[0] !== '*') { - this.groupBys = Array.from(new Set([...this.groupBys, ...this.selectFields])); - } - - if(this.distinctSelect) - selectClause = ` DISTINCT ${selectClause}` - else selectClause = ` ${selectClause}`; - - let fromClause = `FROM ${this.table}`; - if (this.tableAlias) fromClause += ` AS ${this.tableAlias}`; - - let joinClauses = ''; - let currentOffset = 0; - let parametersToAdd: any = []; - for (const join of this.joins) { - const onClause = - typeof join.on === 'string' ? join.on - : (() => { - join.on.disableWhere(); - join.on.addOffset(currentOffset); - const stmt = join.on.build(false); - currentOffset += stmt.values.length; - parametersToAdd.push(...stmt.values); - return stmt.statement; - })(); - joinClauses += - `${joinClauses ? '\n' : ''}${join.type.toUpperCase()} JOIN ${join.table} ${join.alias}\n ON ${onClause}`; - } - - this.whereStatement.addParams(parametersToAdd); - - this.whereStatement.enableWhere(); - const stmt = this.whereStatement.build(); - const whereClause = stmt.statement; - const values = stmt.values; - - let groupByClause = ''; - if ( - this.groupBys.length > 0 && - !this.groupBySelectFields - ) { - groupByClause = `GROUP BY ${this.groupBys.join(', ')}`; - } else if (this.groupBySelectFields) { - groupByClause = `GROUP BY ${this.selectFields.join(', ')}`; - } - - let havingClause = ''; - if (this.havingStatement) { - this.havingStatement.disableWhere(); - this.havingStatement.addOffset(values.length); - const havingStmt = this.havingStatement.build(); - havingClause = `HAVING ${havingStmt.statement}`; - values.push(...havingStmt.values); - } - - let orderByClause = ''; - if (this.orderBys.length > 0) { - const orders = this.orderBys.map(ob => { - let field = ''; - if(isOrderByField(ob)) field = ob.field - else field = ob.column; - - return `${field} ${ob.direction}`; - }); - orderByClause = `ORDER BY ${orders.join(', ')}`; - } - - let limitClause = ''; - if (this.limitCount !== null) limitClause = `LIMIT ${this.limitCount}`; - - let offsetClause = ''; - if (this.offsetCount !== null) offsetClause = `OFFSET ${this.offsetCount}`; - - const query = [ - ctesClause, - 'SELECT', - selectClause, - fromClause, - joinClauses, - whereClause, - groupByClause, - havingClause, - orderByClause, - `${limitClause} ${offsetClause}` - ].map(q => q.trim() ? q : null) - .filter(Boolean) - .join('\n'); - - this.builtQuery = query.trim(); - - this.builtQuery = SqlEscaper.appendSchemas( - this.builtQuery, this.schemas - ); - - if (this.disabledAnalysis) { - return { text: this.builtQuery, values }; - } else { - const analyzed = this.reAnalyzeParsedQueryForDuplicateParams( - this.builtQuery, - values, - deepAnalysis - ); - this.builtQuery = analyzed.text; - this.builtParams = analyzed.values; - return { text: this.builtQuery, values: this.builtParams }; - } - } - - /** - * Returns the built SQL query string. - * If the query is not built yet, it will build it first. - * @returns string The SQL query string. - */ - public toSQL(): string { - if (!this.builtQuery) this.build(); - if (!this.builtQuery) throw new Error("Failed to build query."); - return this.builtQuery; - } -} diff --git a/src/queryKinds/union.ts b/src/queryKinds/union.ts deleted file mode 100644 index 9bcb85e..0000000 --- a/src/queryKinds/union.ts +++ /dev/null @@ -1,484 +0,0 @@ -import Statement from "../statementMaker.js"; -import QueryKind from "../types/QueryKind.js"; -import OrderBy from "../types/OrderBy.js"; -import QueryDefinition from "./query.js"; -import SelectQuery from "./select.js"; - -/** Allowed types for UnionType */ -export const UnionTypes = { - UNION: "UNION", - UNION_ALL: "UNION ALL", - INTERSECT: "INTERSECT", - INTERSECT_ALL: "INTERSECT ALL", - EXCEPT: "EXCEPT", - EXCEPT_ALL: "EXCEPT ALL", -} as const; - -/** Array of allowed union types for validation */ -const unionTypesArray = Object.values(UnionTypes); - -/** Base types for UnionType */ -type UnionTypeBase = typeof UnionTypes[keyof typeof UnionTypes]; - -/** - * UnionType represents the type of SQL UNION operation. - */ -export type UnionType = Lowercase | UnionTypeBase; - -/** - * Type representing a SELECT query along with its associated union type. - */ -export type SelectQueryWithUnionType = { - query: SelectQuery; - type: UnionType; -}; - -/** - * Union class represents a SQL UNION operation. - * It allows combining multiple SELECT queries into a single result set. - * It is basically a wrapper around multiple SelectQuery instances that - * creates a SELECT query that is the union of all the provided queries. - * It supports adding queries with different union types (UNION or UNION ALL) - * and can optionally assign an alias to the resulting union query. - */ -export default class Union extends QueryDefinition { - /** Needed alias for the union query */ - private unionAlias: string | null = null; - - /** Limit count for the union query */ - private limitCount: number | null = null; - - /** Offset count for the union query */ - private offsetCount: number | null = null; - - /** Array of SelectQuery instances and their corresponding union types */ - private selectQueries: SelectQueryWithUnionType[] = []; - - /** Order by clauses for the union query */ - private orderBys: OrderBy[] = []; - - /** Group by clauses for the union query */ - private groupBys: string[] = []; - - /** Having statement for the union query */ - private havingStatement: Statement | null = null; - - /** - * Make the union without selecting from it - * Useful when the raw union is needed as a subquery - * @returns An object containing the raw SQL text of the union and its parameter values. - */ - public rawUnion(): { text: string; values: any[] } { - if (this.selectQueries.length === 0) { - throw new Error('No SELECT queries added to the UNION.'); - } - - let unionItself: string = ''; - const values: any[] = []; - - let paramOffset = 1; - for (const { query, type } of this.selectQueries) { - (query as any).disabledAnalysis = true; - query.resetWhereOffset(); - let builtQuery = query.addWhereOffset(paramOffset - 1).build(); - unionItself += (unionItself ? `\n\n${type}\n\n` : '') + `${builtQuery.text}`; - paramOffset += builtQuery.values.length; - values.push(...builtQuery.values); - } - - const analyzed = this.reAnalyzeParsedQueryForDuplicateParams( - unionItself, - values, - false - ); - - return { - text: analyzed.text, - values: analyzed.values - }; - } - - /** - * Assigns an alias to the resulting union query. - * @param alias The alias to assign to the union query. - * @returns The current Union instance for method chaining. - */ - public as(alias: string): Union { - this.unionAlias = alias; - return this; - } - - /** - * Adds a SELECT query to the union with the specified union type. - * @param query The SelectQuery instance to add to the union. - * @param type The type of union operation ('UNION' or 'UNION ALL'). Defaults to 'UNION ALL'. - * @returns The current Union instance for method chaining. - * @throws Error if an invalid union type is provided. - */ - public add(query: SelectQuery, type: UnionType = 'UNION ALL'): Union { - type = type.toUpperCase() as UnionTypeBase; - if (!unionTypesArray.includes(type)) - throw new Error("Invalid union type. Only 'UNION' and 'UNION ALL' are allowed."); - - this.selectQueries.push({ query, type }); - return this; - } - - /** - * Adds multiple SELECT queries to the union. - * @param queries An array of objects containing SelectQuery instances and their corresponding union types. - * @returns The current Union instance for method chaining. - */ - public addMany(queries: SelectQueryWithUnionType[]): Union { - queries.forEach(({ query, type }) => { - this.add(query, type); - }); - return this; - } - - /** - * Adds multiple SELECT queries to the union of the same union type. - * @param queries An array of SelectQuery instances to add to the union. - * @param type The type of union operation ('UNION' or 'UNION ALL'). Defaults to 'UNION ALL'. - * @returns The current Union instance for method chaining. - */ - public addManyOfType(queries: SelectQuery[], type: UnionType = 'UNION ALL'): Union { - queries.forEach((query) => { - this.add(query, type); - }); - return this; - } - - /** - * Adds a WHERE clause to the union query. - * @param statement The WHERE clause as a Statement instance or a raw SQL string. - * @param values Optional parameter values if a raw SQL string is provided. - * @returns The current Union instance for method chaining. - */ - public where(statement: Statement | string, ...values: any[]): Union { - if (typeof statement === 'string') { - statement = new Statement().raw('', statement, ...values); - } - this.whereStatement = statement; - return this; - } - - /** - * Adds a WHERE clause to the union query using a callback function. - * The callback function receives a Statement instance to build the WHERE clause. - * @param statement A callback function that takes a Statement instance and returns a Statement or void. - * @returns The current Union instance for method chaining. - */ - public useStatement(statement: (stmt: Statement) => Statement | void): Union { - const stmt = new Statement(); - const newStatement = statement(stmt) || stmt; - - this.whereStatement = newStatement; - return this; - } - - /** - * Sets a LIMIT on the number of rows returned by the union query. - * @param limit The maximum number of rows to return. Must be a non-negative integer. - * @returns The current Union instance for method chaining. - * @throws Error if the limit is negative or not an integer. - */ - public limit(limit: number): Union { - if (limit < 0 || !Number.isInteger(limit)) { - throw new Error('Limit must be a non-negative integer.'); - } - this.limitCount = limit; - return this; - } - - /** - * Sets an OFFSET for the union query. - * @param offset The number of rows to skip before starting to return rows. Must be a non-negative integer. - * @returns The current Union instance for method chaining. - * @throws Error if the offset is negative or not an integer. - */ - public offset(offset: number): Union { - if (offset < 0 || !Number.isInteger(offset)) { - throw new Error('Offset must be a non-negative integer.'); - } - this.offsetCount = offset; - return this; - } - - /** - * Sets both LIMIT and OFFSET for the union query. - * @param limit The maximum number of rows to return. Must be a non-negative integer. - * @param offset The number of rows to skip before starting to return rows. Must be a non-negative integer. - * @returns The current Union instance for method chaining. - * @throws Error if the limit or offset is negative or not an integer. - */ - public limitAndOffset(limit: number, offset: number): Union { - return this.limit(limit).offset(offset); - } - - /** - * Sets the ORDER BY clauses for the union query, replacing any existing clauses. - * @param orderBy A single OrderBy object or an array of OrderBy objects. - * @returns The current Union instance for method chaining. - */ - public orderBy(orderBy: OrderBy | OrderBy[]): Union { - if (Array.isArray(orderBy)) { - this.orderBys = orderBy - } else { - this.orderBys = [orderBy]; - } - return this; - } - - /** - * Adds ORDER BY clauses to the union query without replacing existing clauses. - * @param orderBy A single OrderBy object or an array of OrderBy objects to add. - * @returns The current Union instance for method chaining. - */ - public addOrderBy(orderBy: OrderBy | OrderBy[]): Union { - if (Array.isArray(orderBy)) { - this.orderBys.push(...orderBy) - } else { - this.orderBys.push(orderBy); - } - return this; - } - - /** - * Sets the GROUP BY clauses for the union query, replacing any existing clauses. - * @param field A single field name or an array of field names to group by. - * @returns The current Union instance for method chaining. - */ - public groupBy(field: string | string[]): Union { - if (Array.isArray(field)) { - this.groupBys = field - } else { - this.groupBys = [field]; - } - return this; - } - - /** - * Adds GROUP BY clauses to the union query without replacing existing clauses. - * @param field A single field name or an array of field names to add to the GROUP BY clause. - * @returns The current Union instance for method chaining. - */ - public addGroupBy(field: string | string[]): Union { - if (Array.isArray(field)) { - this.groupBys.push(...field) - } else { - this.groupBys.push(field); - } - return this; - } - - /** - * Adds a HAVING clause to the union query. - * @param statement The HAVING clause as a Statement instance or a raw SQL string. - * @param values Optional parameter values if a raw SQL string is provided. - * @returns The current Union instance for method chaining. - */ - public having(statement: Statement | string, ...values: any[]): Union { - if (typeof statement === 'string') { - statement = new Statement().raw('', statement, ...values); - } - this.havingStatement = statement; - return this; - } - - /** - * Adds a HAVING clause to the union query using a callback function. - * The callback function receives a Statement instance to build the HAVING clause. - * @param statement A callback function that takes a Statement instance and returns a Statement or void. - * @returns The current Union instance for method chaining. - */ - public useHavingStatement(statement: (stmt: Statement) => Statement | void): Union { - const stmt = new Statement(); - const newStatement = statement(stmt) || stmt; - - this.havingStatement = newStatement; - return this; - } - - /** - * Builds the final SQL query for the union operation. - * It combines all added SELECT queries with their respective union types, - * applies any WHERE, GROUP BY, HAVING, ORDER BY, LIMIT, and OFFSET clauses, - * and returns the complete SQL text along with the parameter values. - * @param deepAnalysis If true, performs a deep analysis to re-index parameters. Defaults to false. - * @returns An object containing the final SQL text and an array of parameter values. - * @throws Error if no SELECT queries have been added to the union. - */ - public build(deepAnalysis: boolean = false): { text: string; values: any[] } { - if (this.selectQueries.length === 0) { - throw new Error('No SELECT queries added to the UNION.'); - } - - let unionItself: string = ''; - const values: any[] = []; - - // Add offset on each select query to ensure correct parameter indexing - let paramOffset = 1; - for (const { query, type: unionType } of this.selectQueries) { - (query as any).disabledAnalysis = true; - query.resetWhereOffset(); - let builtQuery = query.addWhereOffset(paramOffset - 1).build(); - builtQuery.text = this.spaceLines(`(${builtQuery.text})`, 1); - const type = this.spaceLines(unionType, 1); - unionItself += (unionItself ? `\n\n${type}\n\n` : '') + `${builtQuery.text}`; - paramOffset += builtQuery.values.length; - values.push(...builtQuery.values); - } - - let whereClause = ''; - let whereValues: any[] = []; - if (this.whereStatement) { - const builtWhere = this.whereStatement - .enableWhere() - .setOffset(paramOffset) - .build(); - whereClause = builtWhere.statement; - whereValues = builtWhere.values; - } - - let groupByClause = ''; - if (this.groupBys.length > 0) { - groupByClause = 'GROUP BY ' + this.groupBys.map(gb => `"${gb}"`).join(', '); - } - - let havingClause = ''; - let havingValues: any[] = []; - if (this.havingStatement) { - const builtHaving = this.havingStatement - .disableWhere() - .setOffset(paramOffset + whereValues.length) - .build(); - havingClause = 'HAVING ' + builtHaving.statement; - havingValues = builtHaving.values; - } - - let orderByClause = ''; - if (this.orderBys.length > 0) { - orderByClause = 'ORDER BY ' + this.orderBys.map(ob => { - const direction = ob.direction ? ` ${ob.direction.toUpperCase()}` : ''; - return `"${(ob as any).field || (ob as any).column}"${direction}`; - }).join(', '); - } - - let limitClause = ''; - if (this.limitCount !== null) { - limitClause = `LIMIT ${this.limitCount}`; - } - - let offsetClause = ''; - if (this.offsetCount !== null) { - offsetClause = `OFFSET ${this.offsetCount}`; - } - - const union = [ - 'SELECT * FROM (', - `${unionItself}\n) AS ${this.unionAlias || 'union_subquery'}`, - whereClause, - groupByClause, - havingClause, - orderByClause, - limitClause, - offsetClause - ].filter(part => part.trim() !== '').join('\n'); - - const finalValues = [...values, ...whereValues, ...havingValues]; - - const analyzed = this.reAnalyzeParsedQueryForDuplicateParams( - union, - finalValues, - deepAnalysis - ); - - this.builtQuery = analyzed.text; - this.builtParams = analyzed.values; - - return { - text: this.builtQuery, - values: this.builtParams - }; - } - - /** - * Generates the SQL string for the union query. - * If the query has not been built yet, it will build it first. - * @returns The SQL string of the union query. - * @throws Error if the query fails to build. - */ - public toSQL(): string { - if(!this.builtQuery) this.build(); - if(!this.builtQuery) throw new Error("Failed to build the query."); - return this.builtQuery; - - } - - /** - * Retrieves the parameter values for the union query. - * If the query has not been built yet, it will build it first. - * @returns An array of parameter values for the union query. - * @throws Error if the query fails to build. - */ - public getParams(): any[] { - if(!this.builtParams) this.build(); - if(!this.builtParams) throw new Error("Failed to build the query."); - return this.builtParams; - } - - /** - * Creates a deep clone of the current Union instance. - * This includes cloning all properties and nested objects to ensure - * that modifications to the clone do not affect the original instance. - * @returns A new Union instance that is a deep clone of the current instance. - */ - public clone(): Union { - const newUnion = new Union(); - newUnion.unionAlias = this.unionAlias; - newUnion.limitCount = this.limitCount; - newUnion.offsetCount = this.offsetCount; - newUnion.schemas = [...this.schemas]; - newUnion.selectQueries = this.selectQueries.map(sq => ({ - query: sq.query.clone() as SelectQuery, - type: sq.type - })); - newUnion.orderBys = [...this.orderBys]; - newUnion.groupBys = [...this.groupBys]; - newUnion.havingStatement = this.havingStatement ? this.havingStatement.clone() : null; - newUnion.whereStatement = this.whereStatement ? this.whereStatement.clone() : null; - return newUnion; - } - - /** - * Resets the internal state of the Union instance. - * This clears all properties, including the union alias, limit, offset, - * select queries, order by clauses, group by clauses, having statement, - * where statement, built query, built parameters, and schemas. - * After calling this method, the Union instance will be in its initial state. - */ - public reset(): void { - this.unionAlias = null; - this.limitCount = null; - this.offsetCount = null; - this.selectQueries = []; - this.orderBys = []; - this.groupBys = []; - this.havingStatement = null; - this.whereStatement = null; - this.builtQuery = null; - this.builtParams = null; - this.schemas = []; - } - - /** - * This is a UNION query. - * @returns The kind of SQL operation, which is 'UNION' for this class. - */ - public get kind() { - return QueryKind.UNION; - } - -} - diff --git a/src/queryKinds/update.ts b/src/queryKinds/update.ts deleted file mode 100644 index 5f7fce9..0000000 --- a/src/queryKinds/update.ts +++ /dev/null @@ -1,413 +0,0 @@ -import CteMaker, { Cte } from "../cteMaker.js"; -import SqlEscaper from "../sqlEscaper.js"; -import Statement from "../statementMaker.js"; -import Join from "../types/Join.js"; -import QueryKind from "../types/QueryKind.js"; -import SetValue from "../types/SetValue.js"; -import QueryDefinition from "./query.js"; - -/** - * UpdateQuery class is used to build SQL UPDATE queries. - * It provides methods to specify the table to update, set values, add joins, and define conditions. - * The class supports Common Table Expressions (CTEs) and returning clauses. - * It extends the QueryDefinition class to inherit common query functionalities. - */ -export default class UpdateQuery extends QueryDefinition { - /** The table to update. */ - private table: string; - /** Optional alias for the table. */ - private tableAlias: string | null = null; - /** Optional USING clause table. */ - private usingTable: string | null = null; - /** Optional alias for the USING table. */ - private usingAlias: string | null = null; - - /** JOIN clauses for the update. */ - private joins: Join[] = []; - /** SET values for the update. */ - private setValues: SetValue[] = []; - /** RETURNING fields. */ - private returningFields: string[] = []; - - /** - * Creates an instance of UpdateQuery. - * @param table - The name of the table to update. - * @param alias - An optional alias for the table. - */ - constructor(table?: string, alias?: string) { - super(); - this.table = table ? SqlEscaper.escapeTableName(table, this.flavor) : ''; - this.tableAlias = alias || null; - } - - /** - * Adds Common Table Expressions (CTEs) to the query. - * Accepts a CteMaker instance, a single Cte, or an array of Ctes. - * @param ctes - The CTEs to be added to the query. - * @returns The current UpdateQuery instance for method chaining. - */ - public with(ctes: CteMaker | Cte | Cte[]): this { - if (ctes instanceof CteMaker) { - this.ctes = ctes; - } else if (Array.isArray(ctes)) { - this.ctes = new CteMaker(...ctes); - } else { - this.ctes = new CteMaker(ctes); - } - return this; - } - - /** - * Specifies the table to update and an optional alias. - * @param table - The name of the table. - * @param alias - An optional alias for the table. - * @returns The current UpdateQuery instance for method chaining. - */ - public from(table: string, alias: string | null = null): this { - this.table = SqlEscaper.escapeTableName(table, this.flavor); - this.tableAlias = alias; - return this; - } - - /** - * Specifies the USING clause table and an optional alias. - * @param table - The name of the table for the USING clause. - * @param alias - An optional alias for the USING table. - * @returns The current UpdateQuery instance for method chaining. - */ - public using(table: string, alias: string | null = null): this { - this.usingTable = SqlEscaper.escapeTableName(table, this.flavor); - this.usingAlias = alias; - return this; - } - - /** - * Adds JOIN clauses to the update query. - * Accepts either a single Join object or an array of Join objects. - * @param join - The JOIN clause(s) to be added. - * @returns The current UpdateQuery instance for method chaining. - */ - public join(join: Join | Join[]): this { - if (Array.isArray(join)) { - this.joins.push(...join.map(j => ({ - ...j, - table: SqlEscaper.escapeTableName(j.table, this.flavor), - }))); - } else { - this.joins.push({ - ...join, - table: SqlEscaper.escapeTableName(join.table, this.flavor), - }); - } - return this; - } - - /** - * Specifies the SET values for the update. - * Accepts either a single SetValue object or an array of SetValue objects. - * @param values - The SET value(s) to be added. - * @returns The current UpdateQuery instance for method chaining. - */ - public set(values: SetValue | SetValue[] | { [key: string]: any }): this { - if (Array.isArray(values)) { - this.setValues = values - .filter(v => v.value !== undefined) - .map(v => ({ - setColumn: v.setColumn ? SqlEscaper.escapeTableName(v.setColumn, this.flavor) : '', - from: v.from ? SqlEscaper.escapeTableName(v.from, this.flavor) : undefined as any, - value: v.value ?? null - })); - } else if (values?.setColumn && (values?.from || values?.value !== undefined)) { - this.setValues = [{ - setColumn: values.setColumn ? SqlEscaper.escapeTableName(values.setColumn, this.flavor) : '', - from: values.from ? SqlEscaper.escapeTableName(values.from, this.flavor) : undefined as any, - value: values.value ?? null - }]; - } else if (typeof values === 'object' && !Array.isArray(values)) { - this.setValues = Object.entries(values).filter(([, val]) => { - return val !== undefined; - }).map(([key, val]) => ({ - setColumn: SqlEscaper.escapeTableName(key, this.flavor), - value: val - })); - } - return this; - } - - /** - * Adds a SET clause with a value from another column or expression. - * Example: addSet('column1', 'column2 + 1') results in "SET column1 = column2 + 1". - * @param column - The column to be set. - * @param from - The column or expression to set the value from. - * @returns The current UpdateQuery instance for method chaining. - */ - public addSet(column: string, from: string): this { - this.setValues.push({ - setColumn: SqlEscaper.escapeTableName(column, this.flavor), - from: SqlEscaper.escapeTableName(from, this.flavor) - }); - return this; - } - - /** - * Adds a SET clause with a direct value. - * Example: addSetValue('column1', 42) results in "SET column1 = $1" with 42 as a parameter. - * @param column - The column to be set. - * @param value - The value to set the column to. - * @returns The current UpdateQuery instance for method chaining. - */ - public addSetValue(column: string, value: any): this { - this.setValues.push({ - setColumn: SqlEscaper.escapeTableName(column, this.flavor), - value - }); - return this; - } - - /** - * Specifies the WHERE clause for the update. - * Accepts either a Statement object or a raw SQL string with optional parameters. - * @param statement - The WHERE clause as a Statement or raw SQL string. - * @param values - Optional parameters for the raw SQL string. - * @returns The current UpdateQuery instance for method chaining. - */ - public where(statement: Statement | string, ...values: any[]): this { - if (typeof statement === 'string') { - statement = new Statement().raw('', statement, ...values); - } - - this.whereStatement = statement; - return this; - } - - /** - * Allows using a callback to build the WHERE clause with a Statement object. - * Example: useStatement(stmt => stmt.raw('id = $1', 42)) results in "WHERE id = $1" with 42 as a parameter. - * @param statement - A callback function that receives a Statement object. - * @returns The current UpdateQuery instance for method chaining. - */ - public useStatement(statement: (stmt: Statement) => Statement | void): this { - const stmt = new Statement(); - const newStmt = statement(stmt) || stmt; - return this.where(newStmt); - } - - /** - * Specifies the RETURNING fields for the update. - * Accepts either a single field name or an array of field names. - * @param fields - The field(s) to be returned after the update. - * @returns The current UpdateQuery instance for method chaining. - */ - public returning(fields: string | string[]): this { - if (Array.isArray(fields)) { - this.returningFields = SqlEscaper.escapeSelectIdentifiers(fields, this.flavor); - } else { - this.returningFields = SqlEscaper.escapeSelectIdentifiers([fields], this.flavor); - } - return this; - } - - /** - * Adds additional RETURNING fields to the update. - * Accepts either a single field name or an array of field names. - * @param field - The field(s) to be added to the RETURNING clause. - * @returns The current UpdateQuery instance for method chaining. - */ - public addReturning(field: string | string[]): this { - if (Array.isArray(field)) { - this.returningFields.push(...SqlEscaper.escapeSelectIdentifiers(field, this.flavor)); - } else { - this.returningFields.push(...SqlEscaper.escapeSelectIdentifiers([field], this.flavor)); - } - return this; - } - - /** - * Builds the final SQL UPDATE query string and collects the parameters. - * It handles CTEs, SET clauses, JOINs, WHERE conditions, and RETURNING fields. - * The method ensures proper parameter indexing and returns the query text and values. - * @param deepAnalysis - If true, performs a deeper analysis for duplicate parameters. - * @returns An object containing the built query text and its parameters. - * @throws Error if no table or SET values are specified. - */ - public build(deepAnalysis: boolean = false): { text: string; values: any[] } { - if (this.table.trim() === '') { - throw new Error('No table specified for UPDATE query.'); - } - - if (this.setValues.length === 0) { - throw new Error('No SET values specified for UPDATE query.'); - } - - this.whereStatement = this.whereStatement || new Statement(); - this.whereStatement?.setOffset(1); - - let ctesClause = ''; - let cteValues: any[] = []; - let offset = 0; - if (this.ctes) { - const ctesBuilt = this.ctes.build(); - ctesClause = ctesBuilt.text; - cteValues = ctesBuilt.values; - this.whereStatement?.addOffset(cteValues.length); - offset += cteValues.length; - } - - let updateClause = `UPDATE ${this.table}`; - if (this.tableAlias) { - updateClause += ` ${this.tableAlias}`; - } - - let setClause = 'SET '; - const setParts: string[] = []; - const setValues: any[] = []; - this.setValues.forEach((sv) => { - if (sv.value !== undefined) { - offset += 1; - setParts.push(`${sv.setColumn} = $${offset}`); - this.whereStatement?.addOffset(1); - setValues.push(sv.value); - } else if (sv.from !== undefined) { - setParts.push(`${sv.setColumn} = ${sv.from}`); - } else { - throw new Error(`SET value for column ${sv.setColumn} must have either 'value' or 'from' defined.`); - } - }); - setClause += setParts.join(', '); - - let usingClause = ''; - if (this.usingTable) { - usingClause = `FROM ${this.usingTable}`; - if (this.usingAlias) { - usingClause += ` ${this.usingAlias}`; - } - } - - if(usingClause === '' && this.joins.length > 0) { - throw new Error('JOINs require a USING clause in UPDATE queries.'); - } - - let joinClauses = ''; - let currentOffset = setValues.length; - let parametersToAdd: any = []; - for (const join of this.joins) { - const onClause = - typeof join.on === 'string' ? join.on - : (() => { - join.on.disableWhere(); - join.on.addOffset(currentOffset); - const stmt = join.on.build(false); - currentOffset += stmt.values.length; - parametersToAdd.push(...stmt.values); - return stmt.statement; - })(); - joinClauses += - `${joinClauses ? '\n' : ''}${join.type.toUpperCase()} JOIN ${join.table} ${join.alias}\n ON ${onClause}`; - } - - this.whereStatement.addParams(parametersToAdd); - - this.whereStatement.enableWhere(); - const stmt = this.whereStatement.build(); - const whereClause = stmt.statement; - const values = stmt.values; - - let returningClause = ''; - if (this.returningFields.length > 0) { - returningClause = `RETURNING ${this.returningFields.join(', ')}`; - } - - this.builtQuery = [ - ctesClause, - updateClause, - setClause, - usingClause, - joinClauses, - whereClause, - returningClause - ].filter(part => part !== '') - .join('\n'); - - this.builtQuery = SqlEscaper.appendSchemas( - this.builtQuery, this.schemas - ); - - const allValues = [...cteValues, ...setValues, ...values]; - - const analyzed = this.reAnalyzeParsedQueryForDuplicateParams(this.builtQuery, allValues, deepAnalysis); - this.builtQuery = analyzed.text; - this.builtParams = analyzed.values; - return { text: this.builtQuery, values: this.builtParams }; - } - - /** - * Returns the built SQL query string. - * If the query is not yet built, it triggers the build process. - * @returns The SQL query string. - */ - public toSQL(): string { - if (!this.builtQuery) this.build(); - if (!this.builtQuery) throw new Error('Failed to build the SQL query.'); - return this.builtQuery; - } - - /** - * This an UPDATE query. - * @returns The string 'UPDATE'. - */ - public get kind() { - return QueryKind.UPDATE; - } - - /** - * Resets the query definition to its initial state. - * Clears all properties related to the query configuration. - * @returns void - */ - public reset(): void { - this.table = ''; - this.tableAlias = null; - this.usingTable = null; - this.usingAlias = null; - this.joins = []; - this.setValues = []; - this.whereStatement = null; - this.returningFields = []; - this.builtQuery = null; - this.ctes = null; - this.schemas = []; - } - - /** - * Retrieves the parameters associated with the query. - * If the query is not yet built, it triggers the build process. - * @returns An array of parameters for the query. - */ - public getParams(): any[] { - if (!this.builtParams) this.build(); - if (!this.builtParams) throw new Error('Failed to build the SQL query.'); - return this.builtParams; - } - - /** - * Creates a deep copy of the current UpdateQuery instance. - * This is useful for preserving the current state of the query while making modifications to a clone. - * @returns A new UpdateQuery instance that is a clone of the current instance. - */ - public clone(): UpdateQuery { - const cloned = new UpdateQuery(); - cloned.table = this.table; - cloned.tableAlias = this.tableAlias; - cloned.flavor = this.flavor; - cloned.schemas = [...this.schemas]; - cloned.usingTable = this.usingTable; - cloned.usingAlias = this.usingAlias; - cloned.joins = JSON.parse(JSON.stringify(this.joins)); - cloned.setValues = JSON.parse(JSON.stringify(this.setValues)); - cloned.whereStatement = this.whereStatement ? this.whereStatement.clone() : null; - cloned.returningFields = [...this.returningFields]; - cloned.ctes = this.ctes ? new CteMaker(...this.ctes['ctes']) : null; - return cloned; - } - -} diff --git a/src/queryMaker.ts b/src/queryMaker.ts index fc9a363..8d3f6b7 100644 --- a/src/queryMaker.ts +++ b/src/queryMaker.ts @@ -1,154 +1,180 @@ import { Cte } from "./cteMaker.js"; -import DeleteQuery from "./queryKinds/delete.js"; -import InsertQuery from "./queryKinds/insert.js"; -import SelectQuery from "./queryKinds/select.js"; -import Union from "./queryKinds/union.js"; -import UpdateQuery from "./queryKinds/update.js"; +import { Table } from "./queryKinds/ddl/index.js"; +import DeleteQuery from "./queryKinds/dml/delete.js"; +import InsertQuery from "./queryKinds/dml/insert.js"; +import SelectQuery from "./queryKinds/dml/select.js"; +import Union from "./queryKinds/dml/union.js"; +import UpdateQuery from "./queryKinds/dml/update.js"; import Statement from "./statementMaker.js"; import sqlFlavor from "./types/sqlFlavor.js"; /** - * QueryMaker is a factory class that provides static methods to create instances of different query types. - * It includes methods for creating SELECT, CREATE, DELETE, and UPDATE queries. - * It also provides a method to create a Statement instance for building complex SQL statements. - * Each method returns a new instance of the respective query class. - */ + * QueryMaker is a factory class that provides static methods to create instances of different query types. + * It includes methods for creating SELECT, CREATE, DELETE, and UPDATE queries. + * It also provides a method to create a Statement instance for building complex SQL statements. + * Each method returns a new instance of the respective query class. + */ class Query { - /** - * Creates an instance of QueryMaker. - * @param deepAnalysisDefault - Optional boolean to set the default deep analysis behavior for query building. - * @param flavor - Optional SQL flavor to tailor the query syntax (default is 'postgres'). - */ + * Creates an instance of QueryMaker. + * @param deepAnalysisDefault - Optional boolean to set the default deep analysis behavior for query building. + * @param flavor - Optional SQL flavor to tailor the query syntax (default is 'postgres'). + */ constructor( private readonly deepAnalysisDefault: boolean = false, - private readonly flavor = sqlFlavor.postgres + private readonly flavor = sqlFlavor.postgres, ) {} /** - * Initiates a new SELECT query. - * @returns A new SelectQuery instance with a build method that respects the deepAnalysisDefault setting. - */ + * Initiates a new SELECT query. + * @returns A new SelectQuery instance with a build method that respects the deepAnalysisDefault setting. + */ public get select(): SelectQuery { const selectQuery = new SelectQuery(); (selectQuery as any).flavor = this.flavor; selectQuery.build = (deepAnalysis: boolean = this.deepAnalysisDefault) => { return SelectQuery.prototype.build.call(selectQuery, deepAnalysis); - } + }; return selectQuery; } /** - * Initiates a new DELETE query. - * @returns A new DeleteQuery instance with a build method that respects the deepAnalysisDefault setting. - */ + * Initiates a new DELETE query. + * @returns A new DeleteQuery instance with a build method that respects the deepAnalysisDefault setting. + */ public get delete(): DeleteQuery { const deleteQuery = new DeleteQuery(); (deleteQuery as any).flavor = this.flavor; deleteQuery.build = (deepAnalysis: boolean = this.deepAnalysisDefault) => { return DeleteQuery.prototype.build.call(deleteQuery, deepAnalysis); - } + }; return deleteQuery; } /** - * Initiates a new UPDATE query. - * @returns A new UpdateQuery instance with a build method that respects the deepAnalysisDefault setting. - */ + * Initiates a new UPDATE query. + * @returns A new UpdateQuery instance with a build method that respects the deepAnalysisDefault setting. + */ public get update(): UpdateQuery { const updateQuery = new UpdateQuery(); (updateQuery as any).flavor = this.flavor; updateQuery.build = (deepAnalysis: boolean = this.deepAnalysisDefault) => { return UpdateQuery.prototype.build.call(updateQuery, deepAnalysis); - } + }; return updateQuery; } /** - * Initiates a new INSERT query. - * @returns A new InsertQuery instance with a build method that respects the deepAnalysisDefault setting. - */ + * Initiates a new INSERT query. + * @returns A new InsertQuery instance with a build method that respects the deepAnalysisDefault setting. + */ public get create(): InsertQuery { const insertQuery = new InsertQuery(); (insertQuery as any).flavor = this.flavor; insertQuery.build = (deepAnalysis: boolean = this.deepAnalysisDefault) => { return InsertQuery.prototype.build.call(insertQuery, deepAnalysis); - } + }; return insertQuery; } /** - * Initiates a new CTE (Common Table Expression) instance. - * This can be used to define CTEs for use in queries. - * @returns A new Cte instance. - */ + * Initiates a new CTE (Common Table Expression) instance. + * This can be used to define CTEs for use in queries. + * @returns A new Cte instance. + */ public get cte(): Cte { return new Cte(); } + /** + * Initiates a new UNION query. + * @returns A new Union instance with a build method that respects the deepAnalysisDefault setting. + */ public get union(): Union { const unionQuery = new Union(); (unionQuery as any).flavor = this.flavor; unionQuery.build = (deepAnalysis: boolean = this.deepAnalysisDefault) => { return Union.prototype.build.call(unionQuery, deepAnalysis); - } + }; return unionQuery; } /** - * Initiates a new SELECT query. - * @returns A new SelectQuery instance. - */ + * Initiates a new Statement instance for building complex SQL statements. + * This can be used to create WHERE clauses, JOIN conditions, etc. + * @returns A new Statement instance. + */ + public get table(): Table { + const table = new Table(this.deepAnalysisDefault, this.flavor); + return table; + } + + /** + * Initiates a new SELECT query. + * @returns A new SelectQuery instance. + */ public static get select(): SelectQuery { return new SelectQuery(); } /** - * Initiates a new DELETE query. - * @returns A new DeleteQuery instance. - */ + * Initiates a new DELETE query. + * @returns A new DeleteQuery instance. + */ public static get delete(): DeleteQuery { return new DeleteQuery(); } /** - * Initiates a new UPDATE query. - * @returns A new UpdateQuery instance. - */ + * Initiates a new UPDATE query. + * @returns A new UpdateQuery instance. + */ public static get update(): UpdateQuery { return new UpdateQuery(); } /** - * Initiates a new INSERT query. - * @returns A new InsertQuery instance. - */ + * Initiates a new INSERT query. + * @returns A new InsertQuery instance. + */ public static get create(): InsertQuery { return new InsertQuery(); } /** - * Initiates a new Statement instance for building complex SQL statements. - * This can be used to create WHERE clauses, JOIN conditions, etc. - * @returns A new Statement instance. - */ + * Initiates a new Statement instance for building complex SQL statements. + * This can be used to create WHERE clauses, JOIN conditions, etc. + * @returns A new Statement instance. + */ public static get statement(): Statement { return new Statement(); } /** - * Initiates a new CTE (Common Table Expression) instance. - * This can be used to define CTEs for use in queries. - * @returns A new Cte instance. - */ + * Initiates a new CTE (Common Table Expression) instance. + * This can be used to define CTEs for use in queries. + * @returns A new Cte instance. + */ public static get cte(): Cte { return new Cte(); } + /** + * Initiates a new UNION query. + * @returns A new Union instance. + */ public static get union(): Union { return new Union(); } + /** + * Initiates a new Table instance for DDL operations. + * This can be used to create tables and other DDL statements. + * @returns A new Table instance. + */ + public static get table(): Table { + return new Table(); + } } export default Query; diff --git a/src/queryUtils/Column.test.ts b/src/queryUtils/Column.test.ts new file mode 100644 index 0000000..78580c9 --- /dev/null +++ b/src/queryUtils/Column.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, it } from "vitest"; +import { ColumnDefinition, ColumnType } from "./Column.js"; +import { Varchar } from "../types/ColumnTypes.js"; + + +describe("Column Definition Class", () => { + + it('should create a column definition instance', () => { + const columnDef = new ColumnDefinition("id", new ColumnType("INTEGER")); + expect((columnDef as any).name).toBe("id"); + expect((columnDef as any).type).toBeInstanceOf(ColumnType); + expect(columnDef.build()).toBe('id INTEGER'); + }); + + it('type should be null if not set', () => { + const columnDef = new ColumnDefinition("name"); + expect((columnDef as any).name).toBe("name"); + expect((columnDef as any).type).toBeNull(); + expect(() => columnDef.build()).toThrow("Column type is not set."); + }); + + it('name should be null if not set', () => { + const columnDef = new ColumnDefinition(undefined, new ColumnType("TEXT")); + expect((columnDef as any).name).toBeNull(); + expect((columnDef as any).type).toBeInstanceOf(ColumnType); + expect(() => columnDef.build()).toThrow("Column name is not set."); + }); + + it('should be able to set name and type after creation', () => { + const columnDef = new ColumnDefinition(); + columnDef.setName("username"); + columnDef.setType(new ColumnType("VARCHAR", [50])); + expect((columnDef as any).name).toBe("username"); + expect((columnDef as any).type).toBeInstanceOf(ColumnType); + expect(columnDef.build()).toBe('username VARCHAR(50)'); + }); + + it('should be able to pass type as string', () => { + const columnDef = new ColumnDefinition("age", "INTEGER"); + expect((columnDef as any).name).toBe("age"); + expect((columnDef as any).type).toBeInstanceOf(ColumnType); + expect(columnDef.build()).toBe('age INTEGER'); + + const columnDef2 = new ColumnDefinition(); + columnDef2.setName("created_at"); + columnDef2.setType("TIMESTAMP"); + expect((columnDef2 as any).name).toBe("created_at"); + expect((columnDef2 as any).type).toBeInstanceOf(ColumnType); + expect(columnDef2.build()).toBe('created_at TIMESTAMP'); + }); + + it('should be able to set null or not null constraint', () => { + const columnDef = new ColumnDefinition("email", new ColumnType("VARCHAR", [100])); + columnDef.notNull(); + expect(columnDef.build()).toBe('email VARCHAR(100) NOT NULL'); + + const columnDef2 = new ColumnDefinition("bio", new ColumnType("TEXT")); + columnDef2.null(); + expect(columnDef2.build()).toBe('bio TEXT'); + }); + + it('should chain methods correctly', () => { + const columnDef = new ColumnDefinition() + .setName("status") + .setType(Varchar(20)) + .notNull(); + expect((columnDef as any).name).toBe("status"); + expect((columnDef as any).type).toBeInstanceOf(ColumnType); + expect(columnDef.build()).toBe('status VARCHAR(20) NOT NULL'); + }); + + it('should be able to set default value', () => { + const columnDef = new ColumnDefinition("is_active", new ColumnType("BOOLEAN")); + columnDef.default(true); + expect(columnDef.build()).toBe('is_active BOOLEAN DEFAULT true'); + + const columnDef2 = new ColumnDefinition("created_at", new ColumnType("TIMESTAMP")); + columnDef2.default('CURRENT_TIMESTAMP'); + expect(columnDef2.build()).toBe('created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP'); + }); + + it('should be able to add check constraint', () => { + const columnDef = new ColumnDefinition("age", new ColumnType("INTEGER")); + columnDef.check("age >= 0"); + expect(columnDef.build()).toBe('age INTEGER CHECK (age >= 0)'); + + const columnDef2 = new ColumnDefinition("score", new ColumnType("INTEGER")); + columnDef2.check("score BETWEEN 0 AND 100"); + expect(columnDef2.build()).toBe('score INTEGER CHECK (score BETWEEN 0 AND 100)'); + }); + + it('should be able to set unique constraint', () => { + const columnDef = new ColumnDefinition("email", new ColumnType("VARCHAR", [255])); + columnDef.unique(); + expect(columnDef.build()).toBe('email VARCHAR(255) UNIQUE'); + }); + + it('should be able to set primary key constraint', () => { + const columnDef = new ColumnDefinition("id", new ColumnType("SERIAL")); + columnDef.primaryKey(); + expect(columnDef.build()).toBe('id SERIAL PRIMARY KEY'); + }); + + it('should throw error if name, type or name and type are not set', () => { + const columnDef1 = new ColumnDefinition(); + expect(() => columnDef1.build()).toThrow("Column name and type are not set."); + + const columnDef2 = new ColumnDefinition("username"); + expect(() => columnDef2.build()).toThrow("Column type is not set."); + + const columnDef3 = new ColumnDefinition(undefined, new ColumnType("TEXT")); + expect(() => columnDef3.build()).toThrow("Column name is not set."); + }); + + it('should be able to set foreign key constraint', () => { + const columnDef = new ColumnDefinition("user_id", new ColumnType("INTEGER")); + columnDef.references("users", "id"); + expect(columnDef.build()).toBe('user_id INTEGER REFERENCES users(id)'); + + // With actions + const columnDef2 = new ColumnDefinition("profile_id", new ColumnType("INTEGER")); + columnDef2.references("profiles", "id", "CASCADE", "SET NULL"); + expect(columnDef2.build()).toBe('profile_id INTEGER REFERENCES profiles(id) ON DELETE CASCADE ON UPDATE SET NULL'); + }); + + it('should be able to build for adding in a table', () => { + const columnDef = new ColumnDefinition("email", new ColumnType("VARCHAR", [150])); + expect(columnDef.buildToAdd("users")).toEqual([ + 'ALTER TABLE users ADD COLUMN email VARCHAR(150)', + 'ALTER TABLE users ALTER COLUMN email DROP NOT NULL' + ]); + + // With not null constraint + const columnDef2 = new ColumnDefinition("id", new ColumnType("SERIAL")); + columnDef2.notNull(); + expect(columnDef2.buildToAdd("accounts")).toEqual([ + 'ALTER TABLE accounts ADD COLUMN id SERIAL', + 'ALTER TABLE accounts ALTER COLUMN id SET NOT NULL' + ]); + + // With default value + const columnDef3 = new ColumnDefinition("created_at", new ColumnType("TIMESTAMP")); + columnDef3.default('CURRENT_TIMESTAMP'); + expect(columnDef3.buildToAdd("logs")).toEqual([ + 'ALTER TABLE logs ADD COLUMN created_at TIMESTAMP', + 'ALTER TABLE logs ALTER COLUMN created_at DROP NOT NULL', + 'ALTER TABLE logs ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP' + ]); + + // With check constraint + const columnDef4 = new ColumnDefinition("age", new ColumnType("INTEGER")); + columnDef4.check("age >= 0"); + expect(columnDef4.buildToAdd("persons")).toEqual([ + 'ALTER TABLE persons ADD COLUMN age INTEGER', + 'ALTER TABLE persons ALTER COLUMN age DROP NOT NULL', + 'ALTER TABLE persons ADD CONSTRAINT persons_age_check CHECK (age >= 0)' + ]); + + // With foreign key constraint + const columnDef5 = new ColumnDefinition("user_id", new ColumnType("INTEGER")); + columnDef5.references("users", "id", "CASCADE", "CASCADE"); + expect(columnDef5.buildToAdd("orders")).toEqual([ + 'ALTER TABLE orders ADD COLUMN user_id INTEGER', + 'ALTER TABLE orders ALTER COLUMN user_id DROP NOT NULL', + 'ALTER TABLE orders ADD CONSTRAINT orders_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE' + ]); + + // With unique constraint + const columnDef6 = new ColumnDefinition("email", new ColumnType("VARCHAR", [255])); + columnDef6.unique(); + expect(columnDef6.buildToAdd("subscribers")).toEqual([ + 'ALTER TABLE subscribers ADD COLUMN email VARCHAR(255)', + 'ALTER TABLE subscribers ALTER COLUMN email DROP NOT NULL', + 'ALTER TABLE subscribers ADD CONSTRAINT subscribers_email_unique UNIQUE (email)' + ]); + + // With foreign key with no actions + const columnDef7 = new ColumnDefinition("category_id", new ColumnType("INTEGER")); + columnDef7.references("categories", "id"); + expect(columnDef7.buildToAdd("products")).toEqual([ + 'ALTER TABLE products ADD COLUMN category_id INTEGER', + 'ALTER TABLE products ALTER COLUMN category_id DROP NOT NULL', + 'ALTER TABLE products ADD CONSTRAINT products_category_id_fkey FOREIGN KEY (category_id) REFERENCES categories(id)' + ]); + + // With primary key constraint + const columnDef8 = new ColumnDefinition("id", new ColumnType("INTEGER")); + columnDef8.primaryKey(); + expect(columnDef8.buildToAdd("employees")).toEqual([ + 'ALTER TABLE employees ADD COLUMN id INTEGER', + 'ALTER TABLE employees ALTER COLUMN id SET NOT NULL', + 'ALTER TABLE employees ADD CONSTRAINT employees_id_pkey PRIMARY KEY (id)' + ]); + }); + + it('should be able to build for altering in a table', () => { + + // Altering name + const columnDef = new ColumnDefinition("new_name", new ColumnType("VARCHAR", [100])); + expect(columnDef.buildToAlter("users", "old_name")).toEqual([ + 'ALTER TABLE users RENAME COLUMN old_name TO new_name', + 'ALTER TABLE users ALTER COLUMN new_name TYPE VARCHAR(100)', + 'ALTER TABLE users ALTER COLUMN new_name DROP NOT NULL', + ]); + + // Without constraints + const columnDef2 = new ColumnDefinition("email", new ColumnType("VARCHAR", [150])); + expect(columnDef2.buildToAlter("users")).toEqual([ + 'ALTER TABLE users ALTER COLUMN email TYPE VARCHAR(150)', + 'ALTER TABLE users ALTER COLUMN email DROP NOT NULL', + ]); + + // With not null constraint + const columnDef3 = new ColumnDefinition("id", new ColumnType("SERIAL")); + columnDef3.notNull(); + expect(columnDef3.buildToAlter("accounts")).toEqual([ + 'ALTER TABLE accounts ALTER COLUMN id TYPE SERIAL', + 'ALTER TABLE accounts ALTER COLUMN id SET NOT NULL', + ]); + + // With default value + const columnDef4 = new ColumnDefinition("created_at", new ColumnType("TIMESTAMP")); + columnDef4.default('CURRENT_TIMESTAMP'); + expect(columnDef4.buildToAlter("logs")).toEqual([ + 'ALTER TABLE logs ALTER COLUMN created_at TYPE TIMESTAMP', + 'ALTER TABLE logs ALTER COLUMN created_at DROP NOT NULL', + 'ALTER TABLE logs ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP' + ]); + + // With check constraint + const columnDef5 = new ColumnDefinition("age", new ColumnType("INTEGER")); + columnDef5.check("age >= 0"); + expect(columnDef5.buildToAlter("persons")).toEqual([ + 'ALTER TABLE persons ALTER COLUMN age TYPE INTEGER', + 'ALTER TABLE persons ALTER COLUMN age DROP NOT NULL', + 'ALTER TABLE persons ADD CONSTRAINT persons_age_check CHECK (age >= 0)' + ]); + + // With unique constraint + const columnDef6 = new ColumnDefinition("email", new ColumnType("VARCHAR", [255])); + columnDef6.unique(); + expect(columnDef6.buildToAlter("subscribers")).toEqual([ + 'ALTER TABLE subscribers ALTER COLUMN email TYPE VARCHAR(255)', + 'ALTER TABLE subscribers ALTER COLUMN email DROP NOT NULL', + 'ALTER TABLE subscribers ADD CONSTRAINT subscribers_email_unique UNIQUE (email)' + ]); + + // With primary key constraint + const columnDef7 = new ColumnDefinition("id", new ColumnType("INTEGER")); + columnDef7.primaryKey(); + expect(columnDef7.buildToAlter("employees")).toEqual([ + 'ALTER TABLE employees ALTER COLUMN id TYPE INTEGER', + 'ALTER TABLE employees ALTER COLUMN id SET NOT NULL', + 'ALTER TABLE employees ADD CONSTRAINT employees_id_pkey PRIMARY KEY (id)' + ]); + + // With foreign key constraint + const columnDef8 = new ColumnDefinition("user_id", new ColumnType("INTEGER")); + columnDef8.references("users", "id", "CASCADE", "SET NULL"); + expect(columnDef8.buildToAlter("orders")).toEqual([ + 'ALTER TABLE orders ALTER COLUMN user_id TYPE INTEGER', + 'ALTER TABLE orders ALTER COLUMN user_id DROP NOT NULL', + 'ALTER TABLE orders ADD CONSTRAINT orders_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE SET NULL' + ]); + + // With foreign key with no actions + const columnDef9 = new ColumnDefinition("category_id", new ColumnType("INTEGER")); + columnDef9.references("categories", "id"); + expect(columnDef9.buildToAlter("products")).toEqual([ + 'ALTER TABLE products ALTER COLUMN category_id TYPE INTEGER', + 'ALTER TABLE products ALTER COLUMN category_id DROP NOT NULL', + 'ALTER TABLE products ADD CONSTRAINT products_category_id_fkey FOREIGN KEY (category_id) REFERENCES categories(id)' + ]); + + // To drop default + const columnDef10 = new ColumnDefinition("is_active", new ColumnType("BOOLEAN")); + columnDef10.dropDefaultValue(); + expect(columnDef10.buildToAlter("members")).toEqual([ + 'ALTER TABLE members ALTER COLUMN is_active TYPE BOOLEAN', + 'ALTER TABLE members ALTER COLUMN is_active DROP NOT NULL', + 'ALTER TABLE members ALTER COLUMN is_active DROP DEFAULT' + ]); + }); + + it('should use toString method to build normally', () => { + const columnDef = new ColumnDefinition("username", new ColumnType("VARCHAR", [50])); + expect(columnDef.toString()).toBe('username VARCHAR(50)'); + }); + + +}); + +describe("Column Type Class", () => { + it('should create a column type instance', () => { + const columnType = new ColumnType("VARCHAR", [255]); + expect((columnType as any).typeName).toBe("VARCHAR"); + expect((columnType as any).properties).toEqual(['255']); + expect(columnType.build()).toBe("VARCHAR(255)"); + }); + + it('should be able to set type and properties after creation', () => { + const columnType = new ColumnType("INTEGER"); + columnType.setType("CHAR"); + columnType.addProperty(10); + expect((columnType as any).typeName).toBe("CHAR"); + expect((columnType as any).properties).toEqual(['10']); + expect(columnType.build()).toBe("CHAR(10)"); + }); + + it('should handle types without properties', () => { + const columnType = new ColumnType("TEXT"); + expect(columnType.build()).toBe("TEXT"); + }); + + it('should throw error if type is not set', () => { + const columnType = new ColumnType(); + expect(() => columnType.build()).toThrow("Type name is not set."); + }); +}) diff --git a/src/queryUtils/Column.ts b/src/queryUtils/Column.ts new file mode 100644 index 0000000..d7e422c --- /dev/null +++ b/src/queryUtils/Column.ts @@ -0,0 +1,406 @@ +import type { ColumnTypes } from "../types/ColumnTypes.js"; +import type ForeignKey from "../types/ForeignKey.js"; +import type { Actions } from "../types/ForeignKey.js"; + +/** + * Class representing a column type. + * It allows setting the type and adding properties, and can build a string representation of the column type. + */ +export class ColumnType { + /** The name of the column type. */ + private typeName: ColumnTypes | null; + /** The properties associated with the column type. */ + private properties: string[] = []; + + constructor( + /** The name of the column type. */ + typeName: ColumnTypes | null = null, + /** The properties associated with the column type. */ + properties: (string | { toString(): string })[] = [], + ) { + this.typeName = typeName; + this.properties = properties.map((prop) => prop.toString()); + } + + /** + * Sets the type of the column. + * @param typeName - The name of the column type. + * @returns The current instance for method chaining. + */ + public setType(typeName: ColumnTypes): this { + this.typeName = typeName; + return this; + } + + /** + * Adds a property to the column type. + * @param property - The property to add. + * @returns The current instance for method chaining. + */ + public addProperty( + property: string | { toString(): string }, + ): this { + this.properties.push(property.toString()); + return this; + } + + /** + * Builds the string representation of the column type. + * @returns The string representation of the column type. + * @throws Error if the type name is not set. + */ + public build(): string { + if (!this.typeName) { + throw new Error("Type name is not set."); + } + + if (this.properties.length > 0) { + return `${this.typeName}(${this.properties.join(", ")})`; + } + return this.typeName; + } + + /** + * Returns the string representation of the column type. + * @returns The string representation of the column type. + * @throws Error if the type name is not set. + */ + public toString(): string { + return this.build(); + } +} + +/** + * Class representing a database column with various attributes and constraints. + * It allows setting the column name, type, nullability, primary key status, uniqueness, + * default value, and check conditions. The class can build a string representation of the column definition. + */ +export class ColumnDefinition { + /** The name of the column. */ + private name: string | null = null; + /** The type of the column. */ + private type: ColumnType | null = null; + /** Indicates if the column is nullable. */ + private isNullable: boolean = true; + /** Indicates if the column is a primary key. */ + private isPrimaryKey: boolean = false; + /** Indicates if the column has a unique constraint. */ + private isUnique: boolean = false; + /** The default value for the column, if any. */ + private defaultValue?: string; + /** The check condition for the column, if any. */ + private checkCondition?: string; + /** Foreign key constraint details, if any. */ + private foreignKey: ForeignKey | null = null; + /** Drop default value flag */ + private dropDefault: boolean = false; + + constructor( + name: string | null = null, + type: ColumnType | string | null = null, + ) { + this.name = name ?? null; + if (type) { + if (typeof type === "string") { + this.type = new ColumnType(type as ColumnTypes); + } else { + this.type = type; + } + } else { + this.type = null; + } + } + + /** + * Marks the column to drop its default value. + * @returns The current instance for method chaining. + */ + public dropDefaultValue(): this { + this.dropDefault = true; + return this; + } + + /** + * Sets the name of the column. + * @param name - The name of the column. + * @returns The current instance for method chaining. + */ + public setName(name: string): this { + this.name = name; + return this; + } + + /** + * Sets the type of the column. + * @param type - The type of the column, either as a ColumnType instance or a string. + * @returns The current instance for method chaining. + */ + public setType(type: ColumnType | string): this { + if (typeof type === "string") { + this.type = new ColumnType(type as ColumnTypes); + } else { + this.type = type; + } + return this; + } + + /** + * Marks the column as nullable. + * @returns The current instance for method chaining. + */ + public null(): this { + this.isNullable = true; + return this; + } + + /** + * Marks the column as not nullable. + * @returns The current instance for method chaining. + */ + public notNull(): this { + this.isNullable = false; + return this; + } + + /** + * Marks the column as a primary key. + * This also sets the column as not nullable. + * @returns The current instance for method chaining. + */ + public primaryKey(): this { + this.isPrimaryKey = true; + this.isNullable = false; // Primary key columns cannot be null + return this; + } + + /** + * Marks the column as unique. + * @returns The current instance for method chaining. + */ + public unique(): this { + this.isUnique = true; + return this; + } + + /** + * Sets the default value for the column. + * @param value - The default value for the column. + * @returns The current instance for method chaining. + */ + public default(value: string | { toString(): string }): this { + this.defaultValue = value.toString(); + return this; + } + + /** + * Sets a check condition for the column. + * @param condition - The check condition for the column. + * @returns The current instance for method chaining. + */ + public check(condition: string): this { + this.checkCondition = condition; + return this; + } + + /** + * Sets a foreign key constraint for the column. + * @param foreignTable - The name of the foreign table. + * @param foreignColumn - The name of the foreign column. + * @param onDeleteAction - Optional action to take on delete (e.g., CASCADE, SET NULL). + * @param onUpdateAction - Optional action to take on update (e.g., CASCADE, SET NULL). + * @returns The current instance for method chaining. + */ + public references( + foreignTable: string, + foreignColumn: string, + onDeleteAction?: Actions, + onUpdateAction?: Actions, + ): this { + this.foreignKey = { + table: foreignTable, + column: foreignColumn, + onDelete: onDeleteAction, + onUpdate: onUpdateAction, + }; + return this; + } + + /** + * Builds the string representation of the column definition. + * @returns The string representation of the column definition. + * @throws Error if the column name or type is not set. + */ + public build(forAdding: boolean = false): string { + if (!this.name || !this.type) { + const errorMsg = + !this.name && !this.type + ? "Column name and type" + : !this.name + ? "Column name" + : "Column type"; + throw new Error( + `${errorMsg} ${!this.name && !this.type ? "are" : "is"} not set.`, + ); + } + + const parts: string[] = []; + parts.push(this.name); + parts.push(this.type.toString()); + + if (this.isPrimaryKey && !forAdding) { + parts.push("PRIMARY KEY"); + } else { + if (this.isUnique && !forAdding) { + parts.push("UNIQUE"); + } + if (!this.isNullable && !forAdding) { + parts.push("NOT NULL"); + } + } + + if (this.defaultValue !== undefined && !forAdding) { + parts.push(`DEFAULT ${this.defaultValue}`); + } + + if (this.checkCondition !== undefined && !forAdding) { + parts.push(`CHECK (${this.checkCondition})`); + } + + if (this.foreignKey && !forAdding) { + let fkPart = `REFERENCES ${this.foreignKey.table}(${this.foreignKey.column})`; + if (this.foreignKey.onDelete) { + fkPart += ` ON DELETE ${this.foreignKey.onDelete}`; + } + if (this.foreignKey.onUpdate) { + fkPart += ` ON UPDATE ${this.foreignKey.onUpdate}`; + } + parts.push(fkPart); + } + + return parts.join(" "); + } + + public buildToAdd(tableNameEntry: string): string[] { + let tableName = tableNameEntry.trim(); + if (tableName.startsWith('"') && tableName.endsWith('"')) { + tableName = tableName.slice(1, -1); + } + const addColumn = `ALTER TABLE ${tableNameEntry} ADD COLUMN ${this.build(true)}`; + const addColumnNullability = this.isNullable + ? `ALTER TABLE ${tableNameEntry} ALTER COLUMN ${this.name} DROP NOT NULL` + : `ALTER TABLE ${tableNameEntry} ALTER COLUMN ${this.name} SET NOT NULL`; + const addColumnDefault = + this.defaultValue !== undefined + ? `ALTER TABLE ${tableNameEntry} ALTER COLUMN ${this.name} SET DEFAULT ${this.defaultValue}` + : ""; + const addColumnCheck = + this.checkCondition !== undefined + ? `ALTER TABLE ${tableNameEntry} ADD CONSTRAINT ${tableName}_${this.name}_check CHECK (${this.checkCondition})` + : ""; + const addColumnPrimaryKey = this.isPrimaryKey + ? `ALTER TABLE ${tableNameEntry} ADD CONSTRAINT ${tableName}_${this.name}_pkey PRIMARY KEY (${this.name})` + : ""; + const addColumnUnique = this.isUnique + ? `ALTER TABLE ${tableNameEntry} ADD CONSTRAINT ${tableName}_${this.name}_unique UNIQUE (${this.name})` + : ""; + const addForeignKey = this.foreignKey + ? `ALTER TABLE ${tableNameEntry} ADD CONSTRAINT ${tableName}_${this.name}_fkey FOREIGN KEY (${this.name}) REFERENCES ${this.foreignKey.table}(${this.foreignKey.column})` + + (this.foreignKey.onDelete + ? ` ON DELETE ${this.foreignKey.onDelete}` + : "") + + (this.foreignKey.onUpdate + ? ` ON UPDATE ${this.foreignKey.onUpdate}` + : "") + : ""; + + const additions = [ + addColumn, + addColumnNullability, + addColumnDefault, + addColumnCheck, + addColumnPrimaryKey, + addColumnUnique, + addForeignKey, + ].filter((part) => part !== ""); + + return additions; + } + + public buildToAlter(tableNameEntry: string, previousName?: string): string[] { + + let tableName = tableNameEntry.trim(); + + if (tableName.startsWith('"') && tableName.endsWith('"')) { + tableName = tableName.slice(1, -1); + } + + const alterColumnName = + (previousName !== this.name && previousName) + ? `ALTER TABLE ${tableNameEntry} RENAME COLUMN ${previousName} TO ${this.name}` + : ""; + const alterColumnType = `ALTER TABLE ${tableNameEntry} ALTER COLUMN ${this.name} TYPE ${this.type?.toString()}`; + const alterColumnNullability = this.isNullable + ? `ALTER TABLE ${tableNameEntry} ALTER COLUMN ${this.name} DROP NOT NULL` + : `ALTER TABLE ${tableNameEntry} ALTER COLUMN ${this.name} SET NOT NULL`; + const alterColumnDefault = + this.defaultValue !== undefined + ? `ALTER TABLE ${tableNameEntry} ALTER COLUMN ${this.name} SET DEFAULT ${this.defaultValue}` + : this.dropDefault + ? `ALTER TABLE ${tableNameEntry} ALTER COLUMN ${this.name} DROP DEFAULT` + : ""; + const alterColumnCheck = + this.checkCondition !== undefined + ? `ALTER TABLE ${tableNameEntry} ADD CONSTRAINT ${tableName}_${this.name}_check CHECK (${this.checkCondition})` + : ""; + const alterColumnPrimaryKey = this.isPrimaryKey + ? `ALTER TABLE ${tableNameEntry} ADD CONSTRAINT ${tableName}_${this.name}_pkey PRIMARY KEY (${this.name})` + : ""; + const alterColumnUnique = this.isUnique + ? `ALTER TABLE ${tableNameEntry} ADD CONSTRAINT ${tableName}_${this.name}_unique UNIQUE (${this.name})` + : ""; + const alterForeignKey = this.foreignKey + ? `ALTER TABLE ${tableNameEntry} ADD CONSTRAINT ${tableName}_${this.name}_fkey FOREIGN KEY (${this.name}) REFERENCES ${this.foreignKey.table}(${this.foreignKey.column})` + + (this.foreignKey.onDelete + ? ` ON DELETE ${this.foreignKey.onDelete}` + : "") + + (this.foreignKey.onUpdate + ? ` ON UPDATE ${this.foreignKey.onUpdate}` + : "") + : ""; + + const alterations = [ + alterColumnName, + alterColumnType, + alterColumnNullability, + alterColumnDefault, + alterColumnCheck, + alterColumnPrimaryKey, + alterColumnUnique, + alterForeignKey, + ].filter((part) => part !== ""); + + return alterations; + } + + /** + * Returns the string representation of the column definition. + * @returns The string representation of the column definition. + * @throws Error if the column name or type is not set. + */ + public toString(): string { + return this.build(); + } +} + +/** + * Factory function to create a new ColumnDefinition instance. + * @param name - The name of the column (optional). + * @param type - The type of the column, either as a ColumnType instance or a string (optional). + * @returns A new ColumnDefinition instance. + */ +export default function Column( + name: string | null = null, + type: ColumnType | string | null = null, +): ColumnDefinition { + return new ColumnDefinition(name, type); +} diff --git a/src/queryUtils/index.ts b/src/queryUtils/index.ts new file mode 100644 index 0000000..544d3c0 --- /dev/null +++ b/src/queryUtils/index.ts @@ -0,0 +1,2 @@ +export * from "./Column.js"; +export { default as Column } from "./Column.js"; diff --git a/src/searchModule.ts b/src/searchModule.ts index 71083a0..7192e75 100644 --- a/src/searchModule.ts +++ b/src/searchModule.ts @@ -1,38 +1,35 @@ import Query from "./queryMaker.js"; -import Statement, { StatementKind } from "./statementMaker.js"; - +import type Statement from "./statementMaker.js"; +import type { StatementKind } from "./statementMaker.js"; /** - * SearchModule class provides methods for building search-related SQL conditions. - * It includes methods for full-text search, word-by-word search, and fuzzy search using trigram similarity. - * Each method modifies the provided Statement instance to add the appropriate search conditions. - */ + * SearchModule class provides methods for building search-related SQL conditions. + * It includes methods for full-text search, word-by-word search, and fuzzy search using trigram similarity. + * Each method modifies the provided Statement instance to add the appropriate search conditions. + */ export default class SearchModule { - /** - * Creates an instance of SearchModule. - * @param statement - The Statement instance to which search conditions will be added. - */ - constructor( - private statement: Statement, - ) {} + * Creates an instance of SearchModule. + * @param statement - The Statement instance to which search conditions will be added. + */ + constructor(private statement: Statement) {} /** - * Performs a full-text search on a specified field with the given query. - * It supports case-insensitive searches and allows combining conditions with AND/OR. - * The method uses the LIKE or ILIKE operator based on the case sensitivity requirement. - * @param field - The database field to search. - * @param query - The search query string. - * @param caseInsensitive - Whether the search should be case-insensitive (default is true). - * @param statementKind - The kind of statement to combine with (default is 'AND'). - * @returns The modified Statement instance with the added full-text search condition. - */ + * Performs a full-text search on a specified field with the given query. + * It supports case-insensitive searches and allows combining conditions with AND/OR. + * The method uses the LIKE or ILIKE operator based on the case sensitivity requirement. + * @param field - The database field to search. + * @param query - The search query string. + * @param caseInsensitive - Whether the search should be case-insensitive (default is true). + * @param statementKind - The kind of statement to combine with (default is 'AND'). + * @returns The modified Statement instance with the added full-text search condition. + */ public fulltext( field: string, query: string, caseInsensitive: boolean = true, - statementKind: StatementKind = 'AND' - ) { + statementKind: StatementKind = "AND", + ): Statement { if (caseInsensitive) { this.statement.ilike(field, `%${query}%`, statementKind); } else { @@ -43,36 +40,36 @@ export default class SearchModule { } /** - * Performs a full-text search using PostgreSQL's full-text search capabilities. - * It constructs a ts_vector and ts_query for more advanced search functionality. - * The method allows specifying a text search configuration and combining conditions with AND/OR. - * @param field - The database field to search. - * @param query - The search query string. - * @param config - The text search configuration to use (default is 'simple'). - * @param statementKind - The kind of statement to combine with (default is 'AND'). - * @returns The modified Statement instance with the added full-text search condition. - */ + * Performs a full-text search using PostgreSQL's full-text search capabilities. + * It constructs a ts_vector and ts_query for more advanced search functionality. + * The method allows specifying a text search configuration and combining conditions with AND/OR. + * @param field - The database field to search. + * @param query - The search query string. + * @param config - The text search configuration to use (default is 'simple'). + * @param statementKind - The kind of statement to combine with (default is 'AND'). + * @returns The modified Statement instance with the added full-text search condition. + */ public fulltextTsVector( field: string, query: string, - config: string = 'simple', - statementKind: StatementKind = 'AND' - ) { + config: string = "simple", + statementKind: StatementKind = "AND", + ): Statement { const tsQuery = query - .split(' ') - .filter(word => word.trim()) - .map(word => `${word}:*`) - .join(' & '); + .split(" ") + .filter((word) => word.trim()) + .map((word) => `${word}:*`) + .join(" & "); const tsVectorCondition = Query.statement.raw( - '', + "", `to_tsvector(?, ${field}) @@ to_tsquery(?, ?)`, config, config, - tsQuery + tsQuery, ); - if (statementKind === 'AND') { + if (statementKind === "AND") { this.statement.and(tsVectorCondition); } else { this.statement.or(tsVectorCondition); @@ -82,23 +79,23 @@ export default class SearchModule { } /** - * Performs a word-by-word search on a specified field with the given query. - * It splits the query into individual words and searches for each word separately. - * The method supports case-insensitive searches and allows combining conditions with AND/OR. - * @param field - The database field to search. - * @param query - The search query string. - * @param caseInsensitive - Whether the search should be case-insensitive (default is true). - * @param statementKind - The kind of statement to combine with (default is 'AND'). - * @returns The modified Statement instance with the added word-by-word search conditions. - */ + * Performs a word-by-word search on a specified field with the given query. + * It splits the query into individual words and searches for each word separately. + * The method supports case-insensitive searches and allows combining conditions with AND/OR. + * @param field - The database field to search. + * @param query - The search query string. + * @param caseInsensitive - Whether the search should be case-insensitive (default is true). + * @param statementKind - The kind of statement to combine with (default is 'AND'). + * @returns The modified Statement instance with the added word-by-word search conditions. + */ public wordByWord( field: string, query: string, caseInsensitive: boolean = true, - statementKind: StatementKind = 'AND' - ) { - const words = query.split(' ').filter(word => word.trim()); - words.forEach(word => { + statementKind: StatementKind = "AND", + ): Statement { + const words = query.split(" ").filter((word) => word.trim()); + words.forEach((word) => { if (caseInsensitive) { this.statement.ilike(field, `%${word}%`, statementKind); } else { @@ -110,33 +107,31 @@ export default class SearchModule { } /** - * Performs a fuzzy search using trigram similarity on a specified field with the given query. - * It uses PostgreSQL's pg_trgm extension to find similar strings based on a similarity threshold. - * The method allows specifying the similarity threshold and combining conditions with AND/OR. - * @param field - The database field to search. - * @param query - The search query string. - * @param similarityThreshold - The similarity threshold (default is 0.3). - * @param statementKind - The kind of statement to combine with (default is 'AND'). - * @returns The modified Statement instance with the added fuzzy search conditions. - */ + * Performs a fuzzy search using trigram similarity on a specified field with the given query. + * It uses PostgreSQL's pg_trgm extension to find similar strings based on a similarity threshold. + * The method allows specifying the similarity threshold and combining conditions with AND/OR. + * @param field - The database field to search. + * @param query - The search query string. + * @param similarityThreshold - The similarity threshold (default is 0.3). + * @param statementKind - The kind of statement to combine with (default is 'AND'). + * @returns The modified Statement instance with the added fuzzy search conditions. + */ public fuzzyTrigram( field: string, query: string, similarityThreshold: number = 0.3, - statementKind: StatementKind = 'AND' - ) { + statementKind: StatementKind = "AND", + ): Statement { const fuzzyStatement = Query.statement - .raw('', `${field} % ?`, query) - .raw('AND', `similarity(${field}, ?) >= ?`, query, similarityThreshold); + .raw("", `${field} % ?`, query) + .raw("AND", `similarity(${field}, ?) >= ?`, query, similarityThreshold); - if (statementKind === 'AND') { + if (statementKind === "AND") { this.statement.and(fuzzyStatement); } else { this.statement.or(fuzzyStatement); } - + return this.statement; } - - } diff --git a/src/signal.ts b/src/signal.ts index 8bc9415..2927b2e 100644 --- a/src/signal.ts +++ b/src/signal.ts @@ -1,70 +1,62 @@ - /** - * Callback function type for subscribers. - * It can return void or a Promise. - */ + * Callback function type for subscribers. + * It can return void or a Promise. + */ export type callbackFn = (value: T) => any | Promise; /** - * Signal class to manage a value and notify subscribers on changes. - * It supports subscribing, subscribing once, and clearing subscribers. - */ + * Signal class to manage a value and notify subscribers on changes. + * It supports subscribing, subscribing once, and clearing subscribers. + */ export default class Signal { - /** - * If true, subscribers are notified immediately upon subscription. - * Useful for late subscribers to get the current value right away. - */ + * If true, subscribers are notified immediately upon subscription. + * Useful for late subscribers to get the current value right away. + */ public immediate: boolean; /** - * List of subscriber callback functions. - */ + * List of subscriber callback functions. + */ private subscribers: callbackFn[] = []; /** - * The current value of the signal. - */ + * The current value of the signal. + */ private _value: T | null = null; /** - * Creates a new Signal instance with an initial value. - * @param initialValue - The initial value of the signal. - */ - constructor( - initialValue: T | null = null, - immediate: boolean = false - ) { + * Creates a new Signal instance with an initial value. + * @param initialValue - The initial value of the signal. + */ + constructor(initialValue: T | null = null, immediate: boolean = false) { this._value = initialValue; this.immediate = immediate; } /** - * Creates a new Signal instance with an initial value. - * @param initialValue - The initial value of the signal. - * @returns A new Signal instance. - */ + * Creates a new Signal instance with an initial value. + * @param initialValue - The initial value of the signal. + * @returns A new Signal instance. + */ public static create( initialValue: U | null = null, - immediate: boolean = false + immediate: boolean = false, ): Signal { - return new Signal( - initialValue, - immediate - ); + return new Signal(initialValue, immediate); } /** - * Destroys the signal by clearing subscribers and setting the value to null. - */ + * Destroys the signal by clearing subscribers and setting the value to null. + */ public destroy() { this.clearSubscribers(); this._value = null; } /** - * The current value of the signal. - */ + * The current value of the signal. + */ public get value(): T { return this._value as T; } @@ -75,20 +67,20 @@ export default class Signal { } /** - * Sets the value of the signal using a callback function. - * The callback receives the current value and should return the new value. - * @param callback - The callback function to compute the new value. - */ + * Sets the value of the signal using a callback function. + * The callback receives the current value and should return the new value. + * @param callback - The callback function to compute the new value. + */ public setValue(callback: (currentValue: T) => any) { callback(this._value!); this.notifySubscribers(this._value as T); } /** - * Subscribes to changes in the signal's value. - * @param callback - The callback function(s) to be called when the value changes. - * @returns A function to unsubscribe the provided callbacks. - */ + * Subscribes to changes in the signal's value. + * @param callback - The callback function(s) to be called when the value changes. + * @returns A function to unsubscribe the provided callbacks. + */ public subscribe(...callback: callbackFn[]) { this.subscribers.push(...callback); @@ -100,21 +92,23 @@ export default class Signal { // Return an unsubscribe function return () => { - this.subscribers = this.subscribers.filter(sub => !callback.includes(sub)); + this.subscribers = this.subscribers.filter( + (sub) => !callback.includes(sub), + ); }; } /** - * Subscribes to the signal's value changes only once. - * The callback will be called the next time the value changes and then unsubscribed. - * @param callback - The callback function(s) to be called when the value changes. - * @returns A function to unsubscribe the provided callbacks if they haven't been called yet. - */ + * Subscribes to the signal's value changes only once. + * The callback will be called the next time the value changes and then unsubscribed. + * @param callback - The callback function(s) to be called when the value changes. + * @returns A function to unsubscribe the provided callbacks if they haven't been called yet. + */ public subscribeOnce(...callback: callbackFn[]) { - const onceCallbacks = callback.map(cb => { + const onceCallbacks = callback.map((cb) => { const wrapper: callbackFn = (value: T) => { cb(value); - this.subscribers = this.subscribers.filter(sub => sub !== wrapper); + this.subscribers = this.subscribers.filter((sub) => sub !== wrapper); }; return wrapper; }); @@ -128,14 +122,16 @@ export default class Signal { // Return an unsubscribe function return () => { - this.subscribers = this.subscribers.filter(sub => !onceCallbacks.includes(sub)); + this.subscribers = this.subscribers.filter( + (sub) => !onceCallbacks.includes(sub), + ); }; } /** - * Notifies all subscribers with the new value. - * @param value - The new value to be passed to subscribers. - */ + * Notifies all subscribers with the new value. + * @param value - The new value to be passed to subscribers. + */ private notifySubscribers(value: T) { for (const subscriber of this.subscribers) { subscriber(value); @@ -143,8 +139,8 @@ export default class Signal { } /** - * Clears all subscribers from the signal. - */ + * Clears all subscribers from the signal. + */ public clearSubscribers() { this.subscribers = []; } diff --git a/src/sqlEscaper.ts b/src/sqlEscaper.ts index 343c99e..c74e606 100644 --- a/src/sqlEscaper.ts +++ b/src/sqlEscaper.ts @@ -1,30 +1,28 @@ -import { match, P } from "ts-pattern"; import sqlFlavor from "./types/sqlFlavor.js"; /** - * SqlEscaper provides static methods to escape SQL identifiers and values - * according to different SQL dialects (flavors). It includes methods to escape - * identifiers, table names, and to append schema names in queries. - */ + * SqlEscaper provides static methods to escape SQL identifiers and values + * according to different SQL dialects (flavors). It includes methods to escape + * identifiers, table names, and to append schema names in queries. + */ export default class SqlEscaper { - /** Regex to identify schema placeholders like $schema, $schema1, $schema2, etc. */ private static schemaRegex = /^\$schema\d*$/; /** - * Escapes a given string value by wrapping it with the specified escape character - * and replacing occurrences of the escape character within the value. - * @param value - The string value to be escaped. - * @param escapeChar - The character used to escape the value (default is double quote `"`). - * @param escapeCharReplacement - The string to replace occurrences of the escape character (default is two double quotes `""`). - * @returns The escaped string value. - */ + * Escapes a given string value by wrapping it with the specified escape character + * and replacing occurrences of the escape character within the value. + * @param value - The string value to be escaped. + * @param escapeChar - The character used to escape the value (default is double quote `"`). + * @param escapeCharReplacement - The string to replace occurrences of the escape character (default is two double quotes `""`). + * @returns The escaped string value. + */ public static escape( value: string, - escapeCharLeft: string | RegExp = "\"", + escapeCharLeft: string | RegExp = '"', escapeCharRight: string | RegExp | null = null, - escapeCharLeftReplacement: string = "\"\"", - escapeCharRightReplacement: string | null = null + escapeCharLeftReplacement: string = '""', + escapeCharRightReplacement: string | null = null, ): string { const escapeCharLeftRegex = new RegExp(escapeCharLeft, "g"); @@ -46,23 +44,26 @@ export default class SqlEscaper { value = value.replace(escapeCharLeftRegex, escapeCharLeftReplacement); } - escapeCharLeft = typeof escapeCharLeft === "string" ? escapeCharLeft.replace("\\", "") : ""; - escapeCharRight = typeof escapeCharRight === "string" ? escapeCharRight.replace("\\", "") : ""; + escapeCharLeft = + typeof escapeCharLeft === "string" + ? escapeCharLeft.replace("\\", "") + : ""; + escapeCharRight = + typeof escapeCharRight === "string" + ? escapeCharRight.replace("\\", "") + : ""; return `${escapeCharLeft}${value}${escapeCharRight}`; } /** - * Replaces schema placeholders in the query with actual schema names from the provided array. - * Placeholders are in the format $schema, $schema1, $schema2, etc. - * @param query - The SQL query string containing schema placeholders. - * @param schemas - An array of schema names to replace the placeholders. - * @returns The SQL query string with schema names appended. - * @throws Error if a placeholder index is out of bounds for the provided schemas array. - */ - public static appendSchemas( - query: string, - schemas: string[] = [] - ) { + * Replaces schema placeholders in the query with actual schema names from the provided array. + * Placeholders are in the format $schema, $schema1, $schema2, etc. + * @param query - The SQL query string containing schema placeholders. + * @param schemas - An array of schema names to replace the placeholders. + * @returns The SQL query string with schema names appended. + * @throws Error if a placeholder index is out of bounds for the provided schemas array. + */ + public static appendSchemas(query: string, schemas: string[] = []) { return query.replace(/\$schema\d*/g, (match) => { const indexMatch = match.match(/\d+/); const index = indexMatch ? parseInt(indexMatch[0], 10) : 0; @@ -72,74 +73,71 @@ export default class SqlEscaper { throw new Error( [ `Schema index ${index} out of bounds for provided schemas.`, - `Provided schemas: [${schemas.join(", ")}]` - ].join(" ") + `Provided schemas: [${schemas.join(", ")}]`, + ].join(" "), ); } }); } /** - * Will escape an identifier for use in a SQL statement. - * The identifier will be escaped according to the specified SQL flavor. - * It will return a string of the escaped identifier. - * @param identifier - The identifier to be escaped. - * @param flavor - The SQL flavor to use for escaping (e.g., postgres, mysql, mssql, sqlite, oracle). - * @returns The escaped identifier string. - * @throws Error if the SQL flavor is unsupported. - * @remarks If the identifier matches the schema regex (e.g., $schema), it will be returned as is without escaping. - */ + * Will escape an identifier for use in a SQL statement. + * The identifier will be escaped according to the specified SQL flavor. + * It will return a string of the escaped identifier. + * @param identifier - The identifier to be escaped. + * @param flavor - The SQL flavor to use for escaping (e.g., postgres, mysql, mssql, sqlite, oracle). + * @returns The escaped identifier string. + * @throws Error if the SQL flavor is unsupported. + * @remarks If the identifier matches the schema regex (e.g., $schema), it will be returned as is without escaping. + */ public static escapeIdentifier( identifier: string, - flavor: sqlFlavor + flavor: sqlFlavor, ): string { - if (identifier) { - const isSchema = this.schemaRegex.test(identifier); + const isSchema = SqlEscaper.schemaRegex.test(identifier); if (isSchema) { return identifier; } } try { - const escapedIdentifier = match(flavor) - .returnType() - .with(P.union(sqlFlavor.postgres, sqlFlavor.sqlite), () => { - return this.escape(identifier, "\"", null, "\"\"", null); - }) - .with(sqlFlavor.mysql, () => { - return this.escape(identifier, "`", null, "``", null); - }) - .with(sqlFlavor.mssql, () => { - return this.escape(identifier, "\\[", "]", "]]", "[["); - }) - .with(sqlFlavor.oracle, () => { - return this.escape(identifier, "\"", null, "\"\"", null); - }) - .exhaustive(); - - return escapedIdentifier!; + switch (flavor) { + case sqlFlavor.postgres: + case sqlFlavor.sqlite: + case sqlFlavor.oracle: + return SqlEscaper.escape(identifier, '"', null, '""', null); + case sqlFlavor.mysql: + return SqlEscaper.escape(identifier, "`", null, "``", null); + case sqlFlavor.mssql: + return SqlEscaper.escape(identifier, "\\[", "]", "]]", "[["); + default: + throw new Error(`Unsupported SQL flavor: ${flavor}`); + } } catch (error) { - if (error instanceof Error && error.message.includes("Pattern matching error")) { + if ( + error instanceof Error && + error.message.includes("Pattern matching error") + ) { throw new Error(`Unsupported SQL flavor: ${flavor}`); } else throw error; } } /** - * Will escape a list of identifiers for use in a SELECT statement. - * The identifiers will be escaped according to the specified SQL flavor. - * It will return a string array (string[]) of the escaped identifiers. - * @param identifiers - The list of identifiers to be escaped. - * @param flavor - The SQL flavor to use for escaping (e.g., postgres, mysql, mssql, sqlite, oracle). - * @returns An array of escaped identifier strings. - * @throws Error if an identifier with an AS clause is invalid. - */ + * Will escape a list of identifiers for use in a SELECT statement. + * The identifiers will be escaped according to the specified SQL flavor. + * It will return a string array (string[]) of the escaped identifiers. + * @param identifiers - The list of identifiers to be escaped. + * @param flavor - The SQL flavor to use for escaping (e.g., postgres, mysql, mssql, sqlite, oracle). + * @returns An array of escaped identifier strings. + * @throws Error if an identifier with an AS clause is invalid. + */ public static escapeSelectIdentifiers( identifiers: string[], - flavor: sqlFlavor + flavor: sqlFlavor, ): string[] { - return identifiers.map(identifier => { + return identifiers.map((identifier) => { if (identifier.trim().toUpperCase().startsWith("AS ")) { throw new Error(`Invalid identifier with AS clause: ${identifier}`); } else if (identifier.trim().toUpperCase().endsWith(" AS")) { @@ -155,34 +153,35 @@ export default class SqlEscaper { } const columnParts = column.split("."); - const escapedColumn = columnParts.map(part => this.escapeIdentifier(part.trim(), flavor)).join("."); - const escapedAlias = this.escapeIdentifier(alias.trim(), flavor); + const escapedColumn = columnParts + .map((part) => SqlEscaper.escapeIdentifier(part.trim(), flavor)) + .join("."); + const escapedAlias = SqlEscaper.escapeIdentifier(alias.trim(), flavor); return `${escapedColumn} AS ${escapedAlias}`; } else { const columnParts = identifier.split("."); if (columnParts.length > 1) { - return columnParts.map(part => this.escapeIdentifier(part.trim(), flavor)).join("."); + return columnParts + .map((part) => SqlEscaper.escapeIdentifier(part.trim(), flavor)) + .join("."); } - return this.escapeIdentifier(identifier.trim(), flavor); + return SqlEscaper.escapeIdentifier(identifier.trim(), flavor); } }); } /** - * Will escape a table name for use in a SQL statement. - * The table name will be escaped according to the specified SQL flavor. - * It will return a string of the escaped table name. - * It supports $schema.table format. - * @param tableName - The table name to be escaped. - * @param flavor - The SQL flavor to use for escaping (e.g., postgres, mysql, mssql, sqlite, oracle). - * @returns The escaped table name string. - * @throws Error if the table name is invalid. - */ - public static escapeTableName( - tableName: string, - flavor: sqlFlavor - ): string { + * Will escape a table name for use in a SQL statement. + * The table name will be escaped according to the specified SQL flavor. + * It will return a string of the escaped table name. + * It supports $schema.table format. + * @param tableName - The table name to be escaped. + * @param flavor - The SQL flavor to use for escaping (e.g., postgres, mysql, mssql, sqlite, oracle). + * @returns The escaped table name string. + * @throws Error if the table name is invalid. + */ + public static escapeTableName(tableName: string, flavor: sqlFlavor): string { const parts = tableName.split("."); if (parts.length === 2) { const [schema, table] = parts; @@ -191,14 +190,13 @@ export default class SqlEscaper { throw new Error(`Invalid table name with schema: ${tableName}`); } - const escapedSchema = this.escapeIdentifier(schema.trim(), flavor); - const escapedTable = this.escapeIdentifier(table.trim(), flavor); + const escapedSchema = SqlEscaper.escapeIdentifier(schema.trim(), flavor); + const escapedTable = SqlEscaper.escapeIdentifier(table.trim(), flavor); return `${escapedSchema}.${escapedTable}`; } else if (parts.length === 1) { - return this.escapeIdentifier(tableName.trim(), flavor); + return SqlEscaper.escapeIdentifier(tableName.trim(), flavor); } else { throw new Error(`Invalid table name: ${tableName}`); } } - } diff --git a/src/statementMaker.test.ts b/src/statementMaker.test.ts index 8505a4b..daac261 100644 --- a/src/statementMaker.test.ts +++ b/src/statementMaker.test.ts @@ -244,7 +244,7 @@ describe('StatementMaker Basics', () => { expect(secondEnd - secondStart).toBeLessThan(firstEnd - firstStart); expect(result1).toStrictEqual(result2); - }, { retry: 3 }); + }); it('should return faster compared reparseOnChange = false', () => { const maker = new StatementMaker() @@ -314,7 +314,7 @@ describe('StatementMaker Basics', () => { expect(secondEnd - secondStart).toBeLessThan(firstEnd - firstStart); expect(result1).toStrictEqual(result2); - }, { retry: 3 }); + }); it('should clone itself correctly', () => { const maker = new StatementMaker() diff --git a/src/statementMaker.ts b/src/statementMaker.ts index 8aac068..fe7362a 100644 --- a/src/statementMaker.ts +++ b/src/statementMaker.ts @@ -2,66 +2,66 @@ import SearchModule from "./searchModule.js"; import Signal from "./signal.js"; /** - * Defines the statement kind for combining statements. - * 'AND' and 'OR' are the two possible kinds. - */ -export type StatementKind = 'AND' | 'OR'; + * Defines the statement kind for combining statements. + * 'AND' and 'OR' are the two possible kinds. + */ +export type StatementKind = "AND" | "OR"; /** - * A class to build SQL WHERE clauses with parameterized queries. - * It supports various SQL conditions and allows combining multiple statements. - * It ensures that the generated SQL is safe from injection attacks by using placeholders. - * It can also handle nested statements and subqueries. - */ + * A class to build SQL WHERE clauses with parameterized queries. + * It supports various SQL conditions and allows combining multiple statements. + * It ensures that the generated SQL is safe from injection attacks by using placeholders. + * It can also handle nested statements and subqueries. + */ export default class Statement { /** - * The current index for parameter placeholders. - */ + * The current index for parameter placeholders. + */ private index: Signal; /** - * Array to hold individual unparsed SQL statements. - * These statements will be combined to form the final SQL clause. - * This is a reactive signal, so changes will trigger re-parsing - * or invalidation based on the constructor option. - */ + * Array to hold individual unparsed SQL statements. + * These statements will be combined to form the final SQL clause. + * This is a reactive signal, so changes will trigger re-parsing + * or invalidation based on the constructor option. + */ private statements = Signal.create([]); /** - * The final parsed SQL statement with placeholders. - * This is generated when the build method is called. - */ + * The final parsed SQL statement with placeholders. + * This is generated when the build method is called. + */ private parsedStatement: string | null = null; /** - * Array to hold the values corresponding to the placeholders in the SQL statement. - * These values will be used in the parameterized query execution. - * This is a reactive signal, so changes will trigger re-parsing - * or invalidation based on the constructor option. - */ + * Array to hold the values corresponding to the placeholders in the SQL statement. + * These values will be used in the parameterized query execution. + * This is a reactive signal, so changes will trigger re-parsing + * or invalidation based on the constructor option. + */ private values = Signal.create([]); /** - * Flag to determine if the final statement should include the 'WHERE' keyword. - * This is useful when the statement is part of a larger SQL query. - */ + * Flag to determine if the final statement should include the 'WHERE' keyword. + * This is useful when the statement is part of a larger SQL query. + */ private addWhere = true; /** - * Flag to indicate if the statement should be re-parsed on changes. - * This is useful for performance optimization, signals will handle reactivity. - * If false, the statement will only be parsed when build() is called. - * If true, it will re-parse on every change to statements or values. - */ + * Flag to indicate if the statement should be re-parsed on changes. + * This is useful for performance optimization, signals will handle reactivity. + * If false, the statement will only be parsed when build() is called. + * If true, it will re-parse on every change to statements or values. + */ private reparseOnChange: boolean; /** - * Array of functions to unsubscribe from signals. - * This is used to remove listeners when reparseOnChange is toggled. - */ + * Array of functions to unsubscribe from signals. + * This is used to remove listeners when reparseOnChange is toggled. + */ private unsubscribeSignals: (() => void)[] = []; /** - * Creates an instance of the Statement class. - * @param initialOffset - An optional offset to start the parameter index from. - * This is useful when combining multiple statements to ensure unique placeholders. - */ + * Creates an instance of the Statement class. + * @param initialOffset - An optional offset to start the parameter index from. + * This is useful when combining multiple statements to ensure unique placeholders. + */ constructor(initialOffset = 0, reparseOnChange = false) { this.index = Signal.create(1 + initialOffset); this.reparseOnChange = reparseOnChange; @@ -69,30 +69,30 @@ export default class Statement { } /** - * Unsubscribes from all signal listeners. - * This is used when toggling the reparseOnChange flag to prevent memory leaks. - */ - private unsubscribeAllSignals() { - this.unsubscribeSignals.forEach(unsub => unsub()); + * Unsubscribes from all signal listeners. + * This is used when toggling the reparseOnChange flag to prevent memory leaks. + */ + private unsubscribeAllSignals(): void { + this.unsubscribeSignals.forEach((unsub) => unsub()); this.unsubscribeSignals = []; } /** - * Keys of the signals to subscribe to for changes. - * This is used to set up listeners for statements, values, and index changes. - */ - private subscribeKeys = ['statements', 'values', 'index']; + * Keys of the signals to subscribe to for changes. + * This is used to set up listeners for statements, values, and index changes. + */ + private subscribeKeys = ["statements", "values", "index"]; /** - * Subscribes to changes in the statements and values signals. - * Depending on the reparseOnChange flag, it either invalidates the parsed statement - * or triggers a re-parse when changes occur. - */ - private subscribeToSignals() { + * Subscribes to changes in the statements and values signals. + * Depending on the reparseOnChange flag, it either invalidates the parsed statement + * or triggers a re-parse when changes occur. + */ + private subscribeToSignals(): void { if (!this.reparseOnChange) { for (const key of this.subscribeKeys) { this.unsubscribeSignals.push( - (this as any)[key].subscribe(() => this.invalidate()) + (this as any)[key].subscribe(() => this.invalidate()), ); } } else { @@ -103,17 +103,17 @@ export default class Statement { try { this.build(); } catch {} - }) + }), ); } } } /** - * Enables re-parsing of the statement whenever there are changes to the statements or values. - * This is useful for scenarios where the statement needs to be kept up-to-date with changes. - * @returns The current Statement instance for method chaining. - */ + * Enables re-parsing of the statement whenever there are changes to the statements or values. + * This is useful for scenarios where the statement needs to be kept up-to-date with changes. + * @returns The current Statement instance for method chaining. + */ public enableReparseOnChange(): this { if (this.reparseOnChange) return this; @@ -125,11 +125,11 @@ export default class Statement { } /** - * Disables re-parsing of the statement on changes to the statements or values. - * Instead, the statement will only be parsed when the build method is called. - * This is useful for performance optimization in scenarios where changes are frequent. - * @returns The current Statement instance for method chaining. - */ + * Disables re-parsing of the statement on changes to the statements or values. + * Instead, the statement will only be parsed when the build method is called. + * This is useful for performance optimization in scenarios where changes are frequent. + * @returns The current Statement instance for method chaining. + */ public disableReparseOnChange(): this { if (!this.reparseOnChange) return this; @@ -141,85 +141,85 @@ export default class Statement { } /** - * Adds multiple parameters to the values array and updates the index accordingly. - * This is useful when you have a list of values to be used in the SQL statement. - * @param params - An array of values to be added. - * @returns The current Statement instance for method chaining. - */ - public addParams(params: any[]) { + * Adds multiple parameters to the values array and updates the index accordingly. + * This is useful when you have a list of values to be used in the SQL statement. + * @param params - An array of values to be added. + * @returns The current Statement instance for method chaining. + */ + public addParams(params: any[]): this { this.values.value = [...params, ...this.values.value]; this.index.value += params.length; return this; } /** - * Adds an offset to the current index. - * This is useful when you want to adjust the starting point for parameter placeholders. - * @param offset - The number to add to the current index. - * @returns The current Statement instance for method chaining. - */ - public addOffset(offset: number) { + * Adds an offset to the current index. + * This is useful when you want to adjust the starting point for parameter placeholders. + * @param offset - The number to add to the current index. + * @returns The current Statement instance for method chaining. + */ + public addOffset(offset: number): this { this.index.value += offset; return this; } /** - * Sets the current index to a specific value. - * This is useful when you want to reset or set the starting point for parameter placeholders. - * @param offset - The value to set the current index to. - * @returns The current Statement instance for method chaining. - */ - public setOffset(offset: number) { + * Sets the current index to a specific value. + * This is useful when you want to reset or set the starting point for parameter placeholders. + * @param offset - The value to set the current index to. + * @returns The current Statement instance for method chaining. + */ + public setOffset(offset: number): this { this.index.value = offset; return this; } /** - * Enables the addition of the 'WHERE' keyword in the final SQL statement. - * This is useful when the statement is a standalone WHERE clause. - * @returns void - */ - public enableWhere() { + * Enables the addition of the 'WHERE' keyword in the final SQL statement. + * This is useful when the statement is a standalone WHERE clause. + * @returns void + */ + public enableWhere(): this { this.addWhere = true; return this; } /** - * Disables the addition of the 'WHERE' keyword in the final SQL statement. - * This is useful when the statement is part of a larger SQL query that already includes 'WHERE'. - * @returns void - */ - public disableWhere() { + * Disables the addition of the 'WHERE' keyword in the final SQL statement. + * This is useful when the statement is part of a larger SQL query that already includes 'WHERE'. + * @returns void + */ + public disableWhere(): this { this.addWhere = false; return this; } /** - * Counts the number of placeholders ('?') in a given SQL template string. - * This is useful for validating that the number of placeholders matches the number of provided values. - * @param template - The SQL template string to be analyzed. - * @returns The count of placeholders in the template. - */ - private static countPlaceholders(template: string) { + * Counts the number of placeholders ('?') in a given SQL template string. + * This is useful for validating that the number of placeholders matches the number of provided values. + * @param template - The SQL template string to be analyzed. + * @returns The count of placeholders in the template. + */ + private static countPlaceholders(template: string): number { return (template.match(/\?/g) || []).length; } /** - * Invalidates the current parsed statement. - * This forces a re-parse the next time the build method is called. - * @returns void - */ - public invalidate() { + * Invalidates the current parsed statement. + * This forces a re-parse the next time the build method is called. + * @returns void + */ + public invalidate(): void { this.parsedStatement = null; } /** - * Resets the statement to its initial state. - * This clears all collected statements, values, and resets the index. - * It also re-enables the addition of the 'WHERE' keyword. - * @returns void - */ - public reset() { + * Resets the statement to its initial state. + * This clears all collected statements, values, and resets the index. + * It also re-enables the addition of the 'WHERE' keyword. + * @returns void + */ + public reset(): void { this.statements.value = []; this.parsedStatement = null; this.values.value = []; @@ -228,20 +228,20 @@ export default class Statement { } /** - * Adds a new SQL statement to the list of statements. - * It handles both string statements and nested Statement instances. - * It also manages the values associated with the statement and the logical kind (AND/OR). - * @param statement - The SQL statement to be added, either as a string or a Statement instance. - * @param values - The values corresponding to the placeholders in the statement. - * @param kind - The logical operator to combine this statement with previous ones ('AND' or 'OR'). - * @returns void - * @throws Error if the number of placeholders does not match the number of provided values. - */ + * Adds a new SQL statement to the list of statements. + * It handles both string statements and nested Statement instances. + * It also manages the values associated with the statement and the logical kind (AND/OR). + * @param statement - The SQL statement to be added, either as a string or a Statement instance. + * @param values - The values corresponding to the placeholders in the statement. + * @param kind - The logical operator to combine this statement with previous ones ('AND' or 'OR'). + * @returns void + * @throws Error if the number of placeholders does not match the number of provided values. + */ private addStatement( statement: string | Statement, values: any | any[] = [], - kind: StatementKind | string = 'AND' - ) { + kind: StatementKind | string = "AND", + ): void { if (statement instanceof Statement) { const raw = statement.returnRaw(); statement = raw.statement; @@ -250,8 +250,8 @@ export default class Statement { values = Array.isArray(values) ? values : [values]; - if(Statement.countPlaceholders(statement) !== values.length) { - throw new Error('Number of placeholders does not match number of values'); + if (Statement.countPlaceholders(statement) !== values.length) { + throw new Error("Number of placeholders does not match number of values"); } if (this.statements.value.length === 0) { @@ -263,255 +263,243 @@ export default class Statement { } /** - * Adds a new statement combined with 'AND'. - * @param statement - The SQL statement to be added, either as a string or a Statement instance. - * @param values - The values corresponding to the placeholders in the statement. - * @returns The current Statement instance for method chaining. - */ - public and( - statement: string | Statement, - values: any | any[] = [] - ) { - this.addStatement(statement, values, 'AND'); + * Adds a new statement combined with 'AND'. + * @param statement - The SQL statement to be added, either as a string or a Statement instance. + * @param values - The values corresponding to the placeholders in the statement. + * @returns The current Statement instance for method chaining. + */ + public and(statement: string | Statement, values: any | any[] = []): this { + this.addStatement(statement, values, "AND"); return this; } /** - * Adds a new statement combined with 'OR'. - * @param statement - The SQL statement to be added, either as a string or a Statement instance. - * @param values - The values corresponding to the placeholders in the statement. - * @returns The current Statement instance for method chaining. - */ - public or( - statement: string | Statement, - values: any | any[] = [] - ) { - this.addStatement(statement, values, 'OR'); + * Adds a new statement combined with 'OR'. + * @param statement - The SQL statement to be added, either as a string or a Statement instance. + * @param values - The values corresponding to the placeholders in the statement. + * @returns The current Statement instance for method chaining. + */ + public or(statement: string | Statement, values: any | any[] = []): this { + this.addStatement(statement, values, "OR"); return this; } /** - * Adds an IN condition to the statement. - * @param column - The database column to compare. - * @param values - The array of values for the IN condition. - * @param kind - The logical operator to combine this condition with previous ones ('AND' or 'OR'). - * @returns The current Statement instance for method chaining. - */ - public in( - column: string, - values: any[], - kind: StatementKind = 'AND' - ) { - const placeholders = values.map(() => '?').join(', '); + * Adds an IN condition to the statement. + * @param column - The database column to compare. + * @param values - The array of values for the IN condition. + * @param kind - The logical operator to combine this condition with previous ones ('AND' or 'OR'). + * @returns The current Statement instance for method chaining. + */ + public in(column: string, values: any[], kind: StatementKind = "AND"): this { + const placeholders = values.map(() => "?").join(", "); this.addStatement(`${column} IN (${placeholders})`, values, kind); return this; } /** - * Adds a NOT IN condition to the statement. - * @param column - The database column to compare. - * @param values - The array of values for the NOT IN condition. - * @param kind - The logical operator to combine this condition with previous ones ('AND' or 'OR'). - * @returns The current Statement instance for method chaining. - */ - public notIn(column: string, values: any[], kind: StatementKind = 'AND') { - const placeholders = values.map(() => '?').join(', '); + * Adds a NOT IN condition to the statement. + * @param column - The database column to compare. + * @param values - The array of values for the NOT IN condition. + * @param kind - The logical operator to combine this condition with previous ones ('AND' or 'OR'). + * @returns The current Statement instance for method chaining. + */ + public notIn( + column: string, + values: any[], + kind: StatementKind = "AND", + ): this { + const placeholders = values.map(() => "?").join(", "); this.addStatement(`${column} NOT IN (${placeholders})`, values, kind); return this; } /** - * Adds a BETWEEN condition to the statement. - * @param column - The database column to compare. - * @param start - The start value of the range. - * @param end - The end value of the range. - * @param kind - The logical operator to combine this condition with previous ones ('AND' or 'OR'). - * @returns The current Statement instance for method chaining. - */ + * Adds a BETWEEN condition to the statement. + * @param column - The database column to compare. + * @param start - The start value of the range. + * @param end - The end value of the range. + * @param kind - The logical operator to combine this condition with previous ones ('AND' or 'OR'). + * @returns The current Statement instance for method chaining. + */ public between( column: string, start: any, end: any, - kind: StatementKind = 'AND' - ) { + kind: StatementKind = "AND", + ): this { this.addStatement(`${column} BETWEEN ? AND ?`, [start, end], kind); return this; } /** - * Adds a NOT BETWEEN condition to the statement. - * @param column - The database column to compare. - * @param start - The start value of the range. - * @param end - The end value of the range. - * @param kind - The logical operator to combine this condition with previous ones ('AND' or 'OR'). - * @returns The current Statement instance for method chaining. - */ + * Adds a NOT BETWEEN condition to the statement. + * @param column - The database column to compare. + * @param start - The start value of the range. + * @param end - The end value of the range. + * @param kind - The logical operator to combine this condition with previous ones ('AND' or 'OR'). + * @returns The current Statement instance for method chaining. + */ public notBetween( column: string, start: any, end: any, - kind: StatementKind = 'AND' - ) { + kind: StatementKind = "AND", + ): this { this.addStatement(`${column} NOT BETWEEN ? AND ?`, [start, end], kind); return this; } /** - * Adds an IS NULL condition to the statement. - * @param column - The database column to check for NULL. - * @param kind - The logical operator to combine this condition with previous ones ('AND' or 'OR'). - * @returns The current Statement instance for method chaining. - */ - public isNull( - column: string, - kind: StatementKind = 'AND' - ) { + * Adds an IS NULL condition to the statement. + * @param column - The database column to check for NULL. + * @param kind - The logical operator to combine this condition with previous ones ('AND' or 'OR'). + * @returns The current Statement instance for method chaining. + */ + public isNull(column: string, kind: StatementKind = "AND"): this { this.addStatement(`${column} IS NULL`, [], kind); return this; } /** - * Adds an IS NOT NULL condition to the statement. - * @param column - The database column to check for NOT NULL. - * @param kind - The logical operator to combine this condition with previous ones ('AND' or 'OR'). - * @returns The current Statement instance for method chaining. - */ - public isNotNull( - column: string, - kind: StatementKind = 'AND' - ) { + * Adds an IS NOT NULL condition to the statement. + * @param column - The database column to check for NOT NULL. + * @param kind - The logical operator to combine this condition with previous ones ('AND' or 'OR'). + * @returns The current Statement instance for method chaining. + */ + public isNotNull(column: string, kind: StatementKind = "AND"): this { this.addStatement(`${column} IS NOT NULL`, [], kind); return this; } /** - * Adds a case-sensitive LIKE condition to the statement. - * @param column - The database column to compare. - * @param pattern - The pattern to match using LIKE. - * @param kind - The logical operator to combine this condition with previous ones ('AND' or 'OR'). - * @returns The current Statement instance for method chaining. - */ + * Adds a case-sensitive LIKE condition to the statement. + * @param column - The database column to compare. + * @param pattern - The pattern to match using LIKE. + * @param kind - The logical operator to combine this condition with previous ones ('AND' or 'OR'). + * @returns The current Statement instance for method chaining. + */ public like( column: string, pattern: string, - kind: StatementKind = 'AND' - ) { + kind: StatementKind = "AND", + ): this { this.addStatement(`${column} LIKE ?`, [pattern], kind); return this; } /** - * Adds a case-insensitive LIKE condition to the statement. - * @param column - The database column to compare. - * @param pattern - The pattern to match using ILIKE. - * @param kind - The logical operator to combine this condition with previous ones ('AND' or 'OR'). - * @returns The current Statement instance for method chaining. - */ + * Adds a case-insensitive LIKE condition to the statement. + * @param column - The database column to compare. + * @param pattern - The pattern to match using ILIKE. + * @param kind - The logical operator to combine this condition with previous ones ('AND' or 'OR'). + * @returns The current Statement instance for method chaining. + */ public ilike( column: string, pattern: string, - kind: StatementKind = 'AND' - ) { + kind: StatementKind = "AND", + ): this { this.addStatement(`${column} ILIKE ?`, [pattern], kind); return this; } /** - * Adds a NOT LIKE condition to the statement. - * @param column - The database column to compare. - * @param pattern - The pattern to match using NOT LIKE. - * @param kind - The logical operator to combine this condition with previous ones ('AND' or 'OR'). - */ + * Adds a NOT LIKE condition to the statement. + * @param column - The database column to compare. + * @param pattern - The pattern to match using NOT LIKE. + * @param kind - The logical operator to combine this condition with previous ones ('AND' or 'OR'). + */ public notLike( column: string, pattern: string, - kind: StatementKind = 'AND' - ) { + kind: StatementKind = "AND", + ): this { this.addStatement(`${column} NOT LIKE ?`, [pattern], kind); return this; } /** - * Adds a case-insensitive NOT LIKE condition to the statement. - * @param column - The database column to compare. - * @param pattern - The pattern to match using NOT ILIKE. - * @param kind - The logical operator to combine this condition with previous ones ('AND' or 'OR'). - * @returns The current Statement instance for method chaining. - */ + * Adds a case-insensitive NOT LIKE condition to the statement. + * @param column - The database column to compare. + * @param pattern - The pattern to match using NOT ILIKE. + * @param kind - The logical operator to combine this condition with previous ones ('AND' or 'OR'). + * @returns The current Statement instance for method chaining. + */ public notIlike( column: string, pattern: string, - kind: StatementKind = 'AND' - ) { + kind: StatementKind = "AND", + ): this { this.addStatement(`${column} NOT ILIKE ?`, [pattern], kind); return this; } /** - * Adds an EXISTS condition with a subquery to the statement. - * @param subquery - The subquery to be used in the EXISTS condition. - * @param values - The values corresponding to the placeholders in the subquery. - * @param kind - The logical operator to combine this condition with previous ones ('AND' or 'OR'). - * @returns The current Statement instance for method chaining. - */ + * Adds an EXISTS condition with a subquery to the statement. + * @param subquery - The subquery to be used in the EXISTS condition. + * @param values - The values corresponding to the placeholders in the subquery. + * @param kind - The logical operator to combine this condition with previous ones ('AND' or 'OR'). + * @returns The current Statement instance for method chaining. + */ public exists( subquery: string, values: any | any[] = [], - kind: StatementKind = 'AND' - ) { + kind: StatementKind = "AND", + ): this { this.addStatement(`EXISTS (${subquery})`, values, kind); return this; } /** - * Adds a NOT EXISTS condition with a subquery to the statement. - * @param subquery - The subquery to be used in the NOT EXISTS condition. - * @param values - The values corresponding to the placeholders in the subquery. - * @param kind - The logical operator to combine this condition with previous ones ('AND' or 'OR'). - * @returns The current Statement instance for method chaining. - */ + * Adds a NOT EXISTS condition with a subquery to the statement. + * @param subquery - The subquery to be used in the NOT EXISTS condition. + * @param values - The values corresponding to the placeholders in the subquery. + * @param kind - The logical operator to combine this condition with previous ones ('AND' or 'OR'). + * @returns The current Statement instance for method chaining. + */ public notExists( subquery: string, values: any | any[] = [], - kind: StatementKind = 'AND' - ) { + kind: StatementKind = "AND", + ): this { this.addStatement(`NOT EXISTS (${subquery})`, values, kind); return this; } /** - * Provides access to the SearchModule for advanced search capabilities. - * This allows for full-text search, tsvector search, word-by-word search, etc. - * Returns an instance of SearchModule linked to this Statement. - * @returns An instance of SearchModule for building search conditions. - */ + * Provides access to the SearchModule for advanced search capabilities. + * This allows for full-text search, tsvector search, word-by-word search, etc. + * Returns an instance of SearchModule linked to this Statement. + * @returns An instance of SearchModule for building search conditions. + */ public search(): SearchModule { return new SearchModule(this); } /** - * Adds a raw SQL statement with placeholders to the statement list. - * This allows for custom SQL conditions that may not be covered by the predefined methods. - * @param kind - The logical operator to combine this statement with previous ones ('AND' or 'OR'). - * @param template - The SQL template string containing '?' placeholders. - * @param values - The values corresponding to the placeholders in the template. - * @returns The current Statement instance for method chaining. - * @throws Error if the number of placeholders does not match the number of provided values. - */ + * Adds a raw SQL statement with placeholders to the statement list. + * This allows for custom SQL conditions that may not be covered by the predefined methods. + * @param kind - The logical operator to combine this statement with previous ones ('AND' or 'OR'). + * @param template - The SQL template string containing '?' placeholders. + * @param values - The values corresponding to the placeholders in the template. + * @returns The current Statement instance for method chaining. + * @throws Error if the number of placeholders does not match the number of provided values. + */ public raw( kind: StatementKind | string, template: string, ...values: any[] - ) { - const parts = template.split('?'); - let statement = ''; + ): this { + const parts = template.split("?"); + let statement = ""; if (parts.length !== values.length + 1) { - throw new Error('Number of placeholders does not match number of values'); + throw new Error("Number of placeholders does not match number of values"); } for (let i = 0; i < values.length; i++) { - statement += parts[i] + '?'; + statement += `${parts[i]}?`; } statement += parts[parts.length - 1]; @@ -520,16 +508,16 @@ export default class Statement { } /** - * Joins multiple Statement instances into the current statement. - * This allows for complex nested conditions to be built up from smaller parts. - * @param statements - An array of Statement instances to be joined. - * @param joinWith - The logical operator to combine these statements ('AND' or 'OR'). - * @returns The current Statement instance for method chaining. - */ + * Joins multiple Statement instances into the current statement. + * This allows for complex nested conditions to be built up from smaller parts. + * @param statements - An array of Statement instances to be joined. + * @param joinWith - The logical operator to combine these statements ('AND' or 'OR'). + * @returns The current Statement instance for method chaining. + */ public joinMultipleStatements( statements: Statement[], - joinWith: StatementKind = 'AND' - ) { + joinWith: StatementKind = "AND", + ): this { statements.forEach((stmt) => { const raw = stmt.returnRaw(); this.addStatement(raw.statement, raw.values.value, joinWith); @@ -539,63 +527,65 @@ export default class Statement { } /** - * Checks if the statements have already been parsed. - * This prevents re-parsing and ensures that the final SQL is only generated once. - * @returns True if the statements have been parsed, false otherwise. - */ - public isDone() { + * Checks if the statements have already been parsed. + * This prevents re-parsing and ensures that the final SQL is only generated once. + * @returns True if the statements have been parsed, false otherwise. + */ + public isDone(): boolean { return this.parsedStatement !== null; } /** - * Parses the collected statements into a single SQL string with placeholders. - * It also adds the 'WHERE' keyword if required. - * @param newLine - Whether to separate statements with new lines for readability (default is true). - * @returns The final parsed SQL statement as a string. - */ + * Parses the collected statements into a single SQL string with placeholders. + * It also adds the 'WHERE' keyword if required. + * @param newLine - Whether to separate statements with new lines for readability (default is true). + * @returns The final parsed SQL statement as a string. + */ private parseStatements(newLine = true) { if (this.isDone()) { return this.parsedStatement as string; } if (this.statements.value.length === 0) { - this.parsedStatement = ''; + this.parsedStatement = ""; return this.parsedStatement; } - let separator = newLine ? '\n ' : ' '; + const separator = newLine ? "\n " : " "; let index = this.index.value; - let statement = this.statements.value.join(separator).replace(/\?/g, () => `$${index++}`); + const statement = this.statements.value + .join(separator) + .replace(/\?/g, () => `$${index++}`); this.parsedStatement = this.addWhere ? `WHERE ${statement}` : statement; return this.parsedStatement; } /** - * Builds the final SQL statement and the corresponding values array. - * This is the method to call when the statement is complete and ready for execution. - * @param newLine - Whether to separate statements with new lines for readability (default is true). - * @returns An object containing the final SQL statement and the array of values. - */ - public build(newLine = true) { + * Builds the final SQL statement and the corresponding values array. + * This is the method to call when the statement is complete and ready for execution. + * @param newLine - Whether to separate statements with new lines for readability (default is true). + * @returns An object containing the final SQL statement and the array of values. + */ + public build(newLine = true): { statement: string; values: any[] } { return { statement: this.parseStatements(newLine), - values: this.values.value - } + values: this.values.value, + }; } /** - * Provides access to the values array. - * This is useful for retrieving the parameters to be used in the parameterized query execution. - * @returns The array of values corresponding to the placeholders in the SQL statement. - */ - public get params() { + * Provides access to the values array. + * This is useful for retrieving the parameters to be used in the parameterized query execution. + * @returns The array of values corresponding to the placeholders in the SQL statement. + */ + public get params(): any[] { return this.values.value; } /** - * Creates a deep copy of the current Statement instance. - * This is useful when you want to duplicate the statement without affecting the original. - * @returns A new Statement instance that is a clone of the current one. - */ + * Creates a deep copy of the current Statement instance. + * This is useful when you want to duplicate the statement without affecting the original. + * @returns A new Statement instance that is a clone of the current one. + */ public clone(): Statement { const clone = new Statement(this.index.value - 1, this.reparseOnChange); clone.statements.value = [...this.statements.value]; @@ -608,15 +598,15 @@ export default class Statement { } /** - * Returns the raw SQL statement and values without parsing. - * This is useful for debugging or when the raw format is needed. - * @returns An object containing the raw SQL statement and the array of values. - */ + * Returns the raw SQL statement and values without parsing. + * This is useful for debugging or when the raw format is needed. + * @returns An object containing the raw SQL statement and the array of values. + */ private returnRaw() { - const statement = this.statements.value.join('\n '); + const statement = this.statements.value.join("\n "); return { statement: statement, - values: this.values - } + values: this.values, + }; } } diff --git a/src/types/ColumnTypes.test.ts b/src/types/ColumnTypes.test.ts new file mode 100644 index 0000000..65c8bad --- /dev/null +++ b/src/types/ColumnTypes.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { Bit, Char, Decimal, Numeric, VarBit, Varchar } from "./ColumnTypes.js"; + +describe("Column Types", () => { + it("should create Numeric column type", () => { + const numericType = Numeric(10, 2); + expect(numericType.build()).toEqual("NUMERIC(10, 2)"); + + const numericTypeNoScale = Numeric(5); + expect(numericTypeNoScale.build()).toEqual("NUMERIC(5)"); + }); + + it('should create Decimal column type', () => { + const decimalType = Decimal(8, 3); + expect(decimalType.build()).toEqual("DECIMAL(8, 3)"); + + const decimalTypeNoScale = Decimal(4); + expect(decimalTypeNoScale.build()).toEqual("DECIMAL(4)"); + }); + + it('should create Char column type', () => { + const charType = Char(10); + expect(charType.build()).toEqual("CHAR(10)"); + }); + + it('should create Varchar column type', () => { + const varcharType = Varchar(255); + expect(varcharType.build()).toEqual("VARCHAR(255)"); + }); + + it('should create Bit column type', () => { + const bitType = Bit(1); + expect(bitType.build()).toEqual("BIT(1)"); + }); + + it('should create VarBit column type', () => { + const varBitType = VarBit(16); + expect(varBitType.build()).toEqual("VARBIT(16)"); + }); +}); diff --git a/src/types/ColumnTypes.ts b/src/types/ColumnTypes.ts new file mode 100644 index 0000000..debff87 --- /dev/null +++ b/src/types/ColumnTypes.ts @@ -0,0 +1,174 @@ +import { ColumnType } from "../queryUtils/Column.js"; + +/** + * Contain all the possible column types in PostgreSQL + * Reference: https://www.postgresql.org/docs/current/datatype.html + */ +export enum ColumnTypesEnum { + // Numeric Types + SMALLINT = "smallint", + INTEGER = "integer", + BIGINT = "bigint", + DECIMAL = "decimal", + NUMERIC = "numeric", + REAL = "real", + DOUBLE_PRECISION = "double precision", + SERIAL = "serial", + BIGSERIAL = "bigserial", + SMALLSERIAL = "smallserial", + MONEY = "money", + + // Character Types + VARCHAR = "character varying", + CHAR = "character", + TEXT = "text", + + // Binary + BYTEA = "bytea", + + // Date/Time + TIMESTAMP = "timestamp", + TIMESTAMPTZ = "timestamptz", + DATE = "date", + TIME = "time", + TIMETZ = "timetz", + INTERVAL = "interval", + + // Boolean + BOOLEAN = "boolean", + + // Enumerated + ENUM = "enum", + + // Geometric + POINT = "point", + LINE = "line", + LSEG = "lseg", + BOX = "box", + PATH = "path", + POLYGON = "polygon", + CIRCLE = "circle", + + // Network + CIDR = "cidr", + INET = "inet", + MACADDR = "macaddr", + MACADDR8 = "macaddr8", + + // Bit Strings + BIT = "bit", + VARBIT = "bit varying", + + // Text Search + TSVECTOR = "tsvector", + TSQUERY = "tsquery", + + // UUID + UUID = "uuid", + + // JSON + JSON = "json", + JSONB = "jsonb", + + // XML + XML = "xml", + + // Arrays + ARRAY = "array", + + // Composite + COMPOSITE = "composite", + + // Range Types + INT4RANGE = "int4range", + INT8RANGE = "int8range", + NUMRANGE = "numrange", + TSRANGE = "tsrange", + TSTZRANGE = "tstzrange", + DATERANGE = "daterange", + + // Special + OID = "oid", + PG_LSN = "pg_lsn", + TXID_SNAPSHOT = "txid_snapshot", + REGPROC = "regproc", + REGPROCEDURE = "regprocedure", + REGOPER = "regoper", + REGOPERATOR = "regoperator", + REGCLASS = "regclass", + REGTYPE = "regtype", +} + +/** + * Union type for ColumnTypesEnum keys + */ +export type ColumnTypesUnion = keyof typeof ColumnTypesEnum; + +/** + * Union type for ColumnTypesEnum values and keys + */ +export type ColumnTypes = ColumnTypesEnum | ColumnTypesUnion; + +/** + * Create a ColumnType of type VARCHAR with specified length + * @param length - The maximum length of the VARCHAR column + * @returns A ColumnType instance representing a VARCHAR column with the specified length + */ +function Varchar(length: number): ColumnType { + return new ColumnType("VARCHAR", [length]); +} + +/** + * Create a ColumnType of type NUMERIC with specified precision and optional scale + * @param precision - The total number of digits + * @param scale - The number of digits to the right of the decimal point (optional) + * @returns A ColumnType instance representing a NUMERIC column with the specified precision and scale + */ +function Numeric(precision: number, scale?: number): ColumnType { + if (scale !== undefined) { + return new ColumnType("NUMERIC", [precision, scale]); + } + return new ColumnType("NUMERIC", [precision]); +} + +/** + * Create a ColumnType of type DECIMAL with specified precision and optional scale + * @param precision - The total number of digits + * @param scale - The number of digits to the right of the decimal point (optional) + * @returns A ColumnType instance representing a DECIMAL column with the specified precision and scale + */ +function Decimal(precision: number, scale?: number): ColumnType { + if (scale !== undefined) { + return new ColumnType("DECIMAL", [precision, scale]); + } + return new ColumnType("DECIMAL", [precision]); +} + +/** + * Create a ColumnType of type CHAR with specified length + * @param length - The fixed length of the CHAR column + * @returns A ColumnType instance representing a CHAR column with the specified length + */ +function Char(length: number): ColumnType { + return new ColumnType("CHAR", [length]); +} + +/** + * Create a ColumnType of type VARBIT with specified length + * @param length - The maximum length of the VARBIT column + * @returns A ColumnType instance representing a VARBIT column with the specified length + */ +function VarBit(length: number): ColumnType { + return new ColumnType("VARBIT", [length]); +} + +/** + * Create a ColumnType of type BIT with specified length + * @param length - The fixed length of the BIT column + * @returns A ColumnType instance representing a BIT column with the specified length + */ +function Bit(length: number): ColumnType { + return new ColumnType("BIT", [length]); +} + +export { Varchar, Numeric, Decimal, Char, VarBit, Bit }; diff --git a/src/types/ColumnValue.ts b/src/types/ColumnValue.ts index 5de8ce5..67f65b5 100644 --- a/src/types/ColumnValue.ts +++ b/src/types/ColumnValue.ts @@ -1,8 +1,7 @@ - /** - * ColumnValue interface represents a column and its associated value. - * It includes the column name and an optional value to be assigned to that column. - */ + * ColumnValue interface represents a column and its associated value. + * It includes the column name and an optional value to be assigned to that column. + */ export default interface ColumnValue { /** The name of the column. */ column: string; diff --git a/src/types/ForeignKey.ts b/src/types/ForeignKey.ts new file mode 100644 index 0000000..d51cdfe --- /dev/null +++ b/src/types/ForeignKey.ts @@ -0,0 +1,21 @@ +/** + * Represents a foreign key relationship in a database schema. + * It includes the referenced table and column, as well as optional actions + * to take on delete or update events. + */ +type ForeignKey = { + table: string; + column: string; + onDelete?: Actions; + onUpdate?: Actions; +}; + +/** Possible actions for foreign key constraints. */ +export type Actions = + | "CASCADE" + | "SET NULL" + | "RESTRICT" + | "NO ACTION" + | "SET DEFAULT"; + +export default ForeignKey; diff --git a/src/types/Join.ts b/src/types/Join.ts index dd8c666..cbc0925 100644 --- a/src/types/Join.ts +++ b/src/types/Join.ts @@ -1,20 +1,43 @@ -import Statement from "../statementMaker.js"; +import type SelectQuery from "../queryKinds/dml/select.js"; +import type Statement from "../statementMaker.js"; -type joinTypeBase = 'INNER' | 'LEFT' | 'RIGHT' | 'FULL'; +type joinTypeBase = "INNER" | "LEFT" | "RIGHT" | "FULL"; export type joinType = Lowercase | joinTypeBase; /** - * Join interface represents a SQL JOIN clause. - * It includes the type of join, the table to join, an optional alias, and the condition for the join. - */ -export default interface Join { + * Join interface represents the base to a SQL JOIN clause. + * It includes the type of join, an optional alias, and the condition for the join. + */ +interface JoinBase { /** The type of join: INNER, LEFT, RIGHT, or FULL. */ type: joinType; - /** The name of the table to join. */ - table: string; /** An optional alias for the joined table. */ alias?: string; /** The condition for the join, which can be a Statement or a raw SQL string. */ on: Statement | string; } + +/** + * Join interface represents a SQL JOIN clause. + * It includes the type of join, the table to join, an optional alias, and the condition for the join. + */ +type JoinTable = JoinBase & { + /** The name of the table to join. */ + table: string; + subQuery?: never; +}; + +type JoinSubQuery = JoinBase & { + /** A subquery to join. */ + subQuery: SelectQuery; + table?: never; +}; + +type Join = JoinTable | JoinSubQuery; + +export function isJoinTable(join: Join): join is JoinTable { + return (join as JoinTable).table !== undefined; +} + +export default Join; diff --git a/src/types/OrderBy.ts b/src/types/OrderBy.ts index 38c3cec..dc3df27 100644 --- a/src/types/OrderBy.ts +++ b/src/types/OrderBy.ts @@ -1,33 +1,34 @@ - -type DirectionBase = 'ASC' | 'DESC'; +type DirectionBase = "ASC" | "DESC"; export type Direction = Lowercase | DirectionBase; /** - * OrderByField interface represents sorting criteria for database queries or data collections. - * It includes the field to sort by and the direction of sorting (ascending or descending). - */ + * OrderByField interface represents sorting criteria for database queries or data collections. + * It includes the field to sort by and the direction of sorting (ascending or descending). + */ export interface OrderByField { /** The field to sort by. */ field: string; /** The direction of sorting: 'ASC' for ascending or 'DESC' for descending. */ direction: Direction; + column?: never; } /** - * OrderByColumn interface represents sorting criteria for database queries or data collections. - * It includes the column to sort by and the direction of sorting (ascending or descending). - */ + * OrderByColumn interface represents sorting criteria for database queries or data collections. + * It includes the column to sort by and the direction of sorting (ascending or descending). + */ export interface OrderByColumn { /** The column to sort by. */ column: string; /** The direction of sorting: 'ASC' for ascending or 'DESC' for descending. */ direction: Direction; + field?: never; } -type OrderBy = OrderByField | OrderByColumn +type OrderBy = OrderByField | OrderByColumn; export function isOrderByField(obj: any): obj is OrderByField { - return obj && typeof obj === 'object' && 'field' in obj && 'direction' in obj; + return obj && typeof obj === "object" && "field" in obj && "direction" in obj; } export default OrderBy; diff --git a/src/types/QueryKind.ts b/src/types/QueryKind.ts index 8e9b584..cdbd52e 100644 --- a/src/types/QueryKind.ts +++ b/src/types/QueryKind.ts @@ -1,11 +1,12 @@ - - enum QueryKind { SELECT = "SELECT", INSERT = "INSERT", UPDATE = "UPDATE", DELETE = "DELETE", - UNION = "UNION" + UNION = "UNION", + CREATE_TABLE = "CREATE_TABLE", + DROP_TABLE = "DROP_TABLE", + ALTER_TABLE = "ALTER_TABLE", } export default QueryKind; diff --git a/src/types/SetValue.ts b/src/types/SetValue.ts index 0f830bd..41f89a9 100644 --- a/src/types/SetValue.ts +++ b/src/types/SetValue.ts @@ -1,9 +1,8 @@ - /** - * Interface representing a value to be set in a database operation. - * It includes the column to be set, and either a direct value or a reference to another column. - * Only one of 'from' or 'value' should be provided. - */ + * Interface representing a value to be set in a database operation. + * It includes the column to be set, and either a direct value or a reference to another column. + * Only one of 'from' or 'value' should be provided. + */ export default interface SetValue { /** The column to be set. */ setColumn: string; diff --git a/src/types/UsingTable.ts b/src/types/UsingTable.ts index 90e458b..4b0947c 100644 --- a/src/types/UsingTable.ts +++ b/src/types/UsingTable.ts @@ -1,7 +1,6 @@ - /** - * Interface representing a table used in a SQL query with an optional alias. - */ + * Interface representing a table used in a SQL query with an optional alias. + */ export default interface UsingTable { /** The name of the table. */ table: string; diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..83266a3 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,16 @@ +export * from "./ColumnTypes.js"; +export type { default as ColumnValue } from "./ColumnValue.js"; +export type * from "./ForeignKey.js"; +export type { default as ForeignKey } from "./ForeignKey.js"; +export type { default as Join } from "./Join.js"; +export * from "./Join.js"; +export type { default as OrderBy } from "./OrderBy.js"; +export * from "./OrderBy.js"; +export type { default as QueryKind } from "./QueryKind.js"; +export * from "./QueryKind.js"; +export type { default as SetValue } from "./SetValue.js"; +export * from "./SetValue.js"; +export * from "./sqlFlavor.js"; +export { default as sqlFlavor } from "./sqlFlavor.js"; +export type { default as UsingTable } from "./UsingTable.js"; +export * from "./UsingTable.js"; diff --git a/src/types/sqlFlavor.ts b/src/types/sqlFlavor.ts index 38640fc..382f4bf 100644 --- a/src/types/sqlFlavor.ts +++ b/src/types/sqlFlavor.ts @@ -1,15 +1,14 @@ - /** - * Enumeration of supported SQL flavors. - * This enum helps in identifying the SQL dialect being used, - * which can affect query syntax and features. - */ + * Enumeration of supported SQL flavors. + * This enum helps in identifying the SQL dialect being used, + * which can affect query syntax and features. + */ enum sqlFlavor { postgres = "postgres", mysql = "mysql", sqlite = "sqlite", mssql = "mssql", - oracle = "oracle" + oracle = "oracle", } export default sqlFlavor; diff --git a/tsconfig.json b/tsconfig.json index a974335..85f74a3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,12 @@ "declarationMap": true, "noUncheckedIndexedAccess": true, - "exactOptionalPropertyTypes": true, + "exactOptionalPropertyTypes": false, + // Needs explicit return types on functions and class methods + "noImplicitOverride": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noImplicitAny": true, // Style Options "noUnusedLocals": true, diff --git a/tsup.config.qjs.ts b/tsup.config.qjs.ts new file mode 100644 index 0000000..c4ddd91 --- /dev/null +++ b/tsup.config.qjs.ts @@ -0,0 +1,23 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + name: "For QuickJS", + entry: [ + "src/index.ts", + "src/types/index.ts", + "src/queryUtils/index.ts", + "src/queryKinds/dml/index.ts", + "src/queryKinds/ddl/index.ts", + "src/queryKinds/ddl/table/index.ts", + ], + target: ["es2023"], + platform: "neutral", + format: ["esm"], + ignoreWatch: ["**/*.test.ts", "**/*.spec.ts"], + dts: true, + outDir: "dist-qjs", + clean: true, + treeshake: "smallest", + minifyIdentifiers: true, + minifySyntax: true, +}); diff --git a/tsup.config.ts b/tsup.config.ts index 2f4cc67..a2d3700 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,14 +1,23 @@ -import { defineConfig } from 'tsup' +import { defineConfig } from "tsup"; export default defineConfig({ - entry: ['src/index.ts'], - target: ['esnext', 'node21'], - format: ['cjs', 'esm'], - ignoreWatch: ['**/*.test.ts', '**/*.spec.ts'], + name: "For everything else", + entry: [ + "src/index.ts", + "src/types/index.ts", + "src/queryUtils/index.ts", + "src/queryKinds/dml/index.ts", + "src/queryKinds/ddl/index.ts", + "src/queryKinds/ddl/table/index.ts", + ], + target: ["esnext"], + platform: "neutral", + format: ["cjs", "esm"], + ignoreWatch: ["**/*.test.ts", "**/*.spec.ts"], dts: true, - outDir: 'dist', + outDir: "dist", clean: true, - treeshake: 'smallest', + treeshake: "smallest", minifyIdentifiers: true, - minifySyntax: true -}) + minifySyntax: true, +}); diff --git a/vitest.config.ts b/vitest.config.ts index 471e615..e5ed32d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,20 +1,21 @@ -import { defineConfig } from 'vitest/config'; +import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - environment: 'node', + environment: "node", coverage: { - reporter: ['json', 'html', 'text'], + reporter: ["json", "html", "text"], exclude: [ - 'node_modules', - 'dist', - '**/*.test.ts', - '**/*.spec.ts', - 'vitest.config.ts', - '**/getOptionalPackages.ts', - 'src/index.ts', + "node_modules", + "dist", + "**/*.test.ts", + "**/*.spec.ts", + "vitest.config.ts", + "**/getOptionalPackages.ts", + "**/index.ts", ], - include: ['src/**/*.ts'] - } - } -}) + include: ["src/**/*.ts"], + }, + retry: 3, + }, +});