diff --git a/esbuild.js b/esbuild.js index a0b1bb21..b209103c 100644 --- a/esbuild.js +++ b/esbuild.js @@ -1,19 +1,20 @@ -import process from 'node:process'; -import * as esbuild from 'esbuild' +import process from "node:process"; +import * as esbuild from "esbuild"; const name = process.argv[2]; +const entrypoint = process.argv[3]; function config(opt) { return { - entryPoints: ['src/index.js'], - target: ['esnext'], - format: 'esm', + entryPoints: [entrypoint ?? `src/index.js`], + target: ["esnext"], + format: "esm", bundle: true, - ...opt + ...opt, }; } await Promise.all([ esbuild.build(config({ outfile: `dist/${name}.js` })), - esbuild.build(config({ minify: true, outfile: `dist/${name}.min.js` })) + esbuild.build(config({ minify: true, outfile: `dist/${name}.min.js` })), ]); diff --git a/package-lock.json b/package-lock.json index dd5a9712..12cf9a66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,9 @@ "packages/*" ], "devDependencies": { + "@types/mocha": "^10.0.6", + "@typescript-eslint/eslint-plugin": "^6.19.1", + "@typescript-eslint/parser": "^6.19.1", "esbuild": "^0.19.11", "eslint": "^8.56.0", "lerna": "^8.0.2", @@ -17,6 +20,8 @@ "nodemon": "^3.0.3", "rimraf": "^5.0.5", "timezone-mock": "^1.3.6", + "ts-mocha": "^10.0.0", + "ts-node": "^10.9.2", "typescript": "^5.3.3", "vite": "^5.0.11", "vitepress": "1.0.0-rc.39", @@ -431,6 +436,18 @@ "node": ">=6.0.0" } }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@docsearch/css": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.5.2.tgz", @@ -1056,12 +1073,31 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.4.15", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", "dev": true }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@jupyter-widgets/base": { "version": "6.0.4", "dev": true, @@ -2850,6 +2886,30 @@ "node": ">= 10" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", + "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", + "dev": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true + }, "node_modules/@tufjs/canonical-json": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-1.0.0.tgz", @@ -2936,6 +2996,19 @@ "@types/sizzle": "*" } }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "optional": true + }, "node_modules/@types/linkify-it": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.5.tgz", @@ -2975,6 +3048,12 @@ "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", "dev": true }, + "node_modules/@types/mocha": { + "version": "10.0.6", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.6.tgz", + "integrity": "sha512-dJvrYWxP/UcXm36Qn36fxhUKu8A/xMRXVT2cliFF1Z7UA9liG5Psj3ezNSZw+5puH2czDXRLcXQxf8JbJt0ejg==", + "dev": true + }, "node_modules/@types/node": { "version": "20.3.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.0.tgz", @@ -2990,6 +3069,12 @@ "version": "2.1.1", "license": "MIT" }, + "node_modules/@types/semver": { + "version": "7.5.6", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz", + "integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==", + "dev": true + }, "node_modules/@types/sizzle": { "version": "2.3.3", "dev": true, @@ -3006,6 +3091,220 @@ "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", "dev": true }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "6.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.19.1.tgz", + "integrity": "sha512-roQScUGFruWod9CEyoV5KlCYrubC/fvG8/1zXuT0WTcxX87GnMMmnksMwSg99lo1xiKrBzw2icsJPMAw1OtKxg==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.5.1", + "@typescript-eslint/scope-manager": "6.19.1", + "@typescript-eslint/type-utils": "6.19.1", + "@typescript-eslint/utils": "6.19.1", + "@typescript-eslint/visitor-keys": "6.19.1", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.2.4", + "natural-compare": "^1.4.0", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "6.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.19.1.tgz", + "integrity": "sha512-WEfX22ziAh6pRE9jnbkkLGp/4RhTpffr2ZK5bJ18M8mIfA8A+k97U9ZyaXCEJRlmMHh7R9MJZWXp/r73DzINVQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "6.19.1", + "@typescript-eslint/types": "6.19.1", + "@typescript-eslint/typescript-estree": "6.19.1", + "@typescript-eslint/visitor-keys": "6.19.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "6.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.19.1.tgz", + "integrity": "sha512-4CdXYjKf6/6aKNMSly/BP4iCSOpvMmqtDzRtqFyyAae3z5kkqEjKndR5vDHL8rSuMIIWP8u4Mw4VxLyxZW6D5w==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.19.1", + "@typescript-eslint/visitor-keys": "6.19.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "6.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.19.1.tgz", + "integrity": "sha512-0vdyld3ecfxJuddDjACUvlAeYNrHP/pDeQk2pWBR2ESeEzQhg52DF53AbI9QCBkYE23lgkhLCZNkHn2hEXXYIg==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "6.19.1", + "@typescript-eslint/utils": "6.19.1", + "debug": "^4.3.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "6.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.19.1.tgz", + "integrity": "sha512-6+bk6FEtBhvfYvpHsDgAL3uo4BfvnTnoge5LrrCj2eJN8g3IJdLTD4B/jK3Q6vo4Ql/Hoip9I8aB6fF+6RfDqg==", + "dev": true, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "6.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.19.1.tgz", + "integrity": "sha512-aFdAxuhzBFRWhy+H20nYu19+Km+gFfwNO4TEqyszkMcgBDYQjmPJ61erHxuT2ESJXhlhrO7I5EFIlZ+qGR8oVA==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.19.1", + "@typescript-eslint/visitor-keys": "6.19.1", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "6.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.19.1.tgz", + "integrity": "sha512-JvjfEZuP5WoMqwh9SPAPDSHSg9FBHHGhjPugSRxu5jMfjvBpq5/sGTD+9M9aQ5sh6iJ8AY/Kk/oUYVEMAPwi7w==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "6.19.1", + "@typescript-eslint/types": "6.19.1", + "@typescript-eslint/typescript-estree": "6.19.1", + "semver": "^7.5.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "6.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.19.1.tgz", + "integrity": "sha512-gkdtIO+xSO/SmI0W68DBg4u1KElmIUo3vXzgHyGPs6cxgB0sa3TlptRAAE0hUY1hM6FcDKEv7aIwiTGm76cXfQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.19.1", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", @@ -3413,6 +3712,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/add-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/add-stream/-/add-stream-1.0.0.tgz", @@ -3607,6 +3915,12 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, "node_modules/argparse": { "version": "2.0.1", "dev": true, @@ -4390,6 +4704,12 @@ } } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, "node_modules/cross-spawn": { "version": "7.0.3", "dev": true, @@ -7240,6 +7560,12 @@ "semver": "bin/semver.js" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, "node_modules/make-fetch-happen": { "version": "10.2.1", "license": "ISC", @@ -10738,6 +11064,16 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/spdx-correct": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", @@ -11241,6 +11577,179 @@ "node": ">=8" } }, + "node_modules/ts-api-utils": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz", + "integrity": "sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg==", + "dev": true, + "engines": { + "node": ">=16.13.0" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/ts-mocha": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/ts-mocha/-/ts-mocha-10.0.0.tgz", + "integrity": "sha512-VRfgDO+iiuJFlNB18tzOfypJ21xn2xbuZyDvJvqpTbWgkAgD17ONGr8t+Tl8rcBtOBdjXp5e/Rk+d39f7XBHRw==", + "dev": true, + "dependencies": { + "ts-node": "7.0.1" + }, + "bin": { + "ts-mocha": "bin/ts-mocha" + }, + "engines": { + "node": ">= 6.X.X" + }, + "optionalDependencies": { + "tsconfig-paths": "^3.5.0" + }, + "peerDependencies": { + "mocha": "^3.X.X || ^4.X.X || ^5.X.X || ^6.X.X || ^7.X.X || ^8.X.X || ^9.X.X || ^10.X.X" + } + }, + "node_modules/ts-mocha/node_modules/diff": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", + "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/ts-mocha/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "optional": true, + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/ts-mocha/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ts-mocha/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "optional": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/ts-mocha/node_modules/ts-node": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-7.0.1.tgz", + "integrity": "sha512-BVwVbPJRspzNh2yfslyT1PSbl5uIk03EZlb493RKHN4qej/D06n1cEhjlOJG69oFsE7OT8XjpTUcYf6pKTLMhw==", + "dev": true, + "dependencies": { + "arrify": "^1.0.0", + "buffer-from": "^1.1.0", + "diff": "^3.1.0", + "make-error": "^1.1.1", + "minimist": "^1.2.0", + "mkdirp": "^0.5.1", + "source-map-support": "^0.5.6", + "yn": "^2.0.0" + }, + "bin": { + "ts-node": "dist/bin.js" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/ts-mocha/node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "optional": true, + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/ts-mocha/node_modules/yn": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yn/-/yn-2.0.0.tgz", + "integrity": "sha512-uTv8J/wiWTgUTg+9vLTi//leUl5vDQS6uii/emeTb2ssY7vl6QWf2fFbIIGjnhjvbdKlU0ed7QPgY1htTC86jQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node/node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/tsconfig-paths": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", @@ -11518,6 +12027,12 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -12431,6 +12946,15 @@ "node": ">=8" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "dev": true, diff --git a/package.json b/package.json index d26de04b..c9b67df3 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,12 @@ "test": "lerna run test", "server": "nodemon packages/duckdb/bin/run-server.js", "dev": "vite", - "release": "npm run test && npm run lint && lerna publish" + "release": "npm run build && npm run test && npm run lint && lerna publish" }, "devDependencies": { + "@types/mocha": "^10.0.6", + "@typescript-eslint/eslint-plugin": "^6.19.1", + "@typescript-eslint/parser": "^6.19.1", "esbuild": "^0.19.11", "eslint": "^8.56.0", "lerna": "^8.0.2", @@ -29,6 +32,8 @@ "nodemon": "^3.0.3", "rimraf": "^5.0.5", "timezone-mock": "^1.3.6", + "ts-mocha": "^10.0.0", + "ts-node": "^10.9.2", "typescript": "^5.3.3", "vite": "^5.0.11", "vitepress": "1.0.0-rc.39", diff --git a/packages/sql/.eslintrc.json b/packages/sql/.eslintrc.json new file mode 100644 index 00000000..2f1291d0 --- /dev/null +++ b/packages/sql/.eslintrc.json @@ -0,0 +1,14 @@ +{ + "parser": "@typescript-eslint/parser", + "plugins": [ + "@typescript-eslint" + ], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended" + ], + "rules": { + "@typescript-eslint/no-explicit-any": "warn" + } +} \ No newline at end of file diff --git a/packages/sql/package.json b/packages/sql/package.json index aaa0fe41..0f8040f4 100644 --- a/packages/sql/package.json +++ b/packages/sql/package.json @@ -10,8 +10,9 @@ "license": "BSD-3-Clause", "author": "Jeffrey Heer (http://idl.cs.washington.edu)", "type": "module", - "main": "src/index.js", - "module": "src/index.js", + "types": "dist/mosaic-sql.d.ts", + "main": "dist/mosaic-sql.js", + "module": "src/index.ts", "jsdelivr": "dist/mosaic-sql.min.js", "unpkg": "dist/mosaic-sql.min.js", "repository": { @@ -20,9 +21,9 @@ }, "scripts": { "prebuild": "rimraf dist && mkdir dist", - "build": "node ../../esbuild.js mosaic-sql", - "lint": "eslint src test --ext .js", - "test": "mocha 'test/**/*-test.js'", + "build": "node ../../esbuild.js mosaic-sql src/index.ts", + "lint": "eslint src test --ext .ts", + "test": "ts-mocha 'test/**/*-test.ts'", "prepublishOnly": "npm run test && npm run lint && npm run build" } } diff --git a/packages/sql/src/Query.js b/packages/sql/src/Query.js deleted file mode 100644 index c7f16703..00000000 --- a/packages/sql/src/Query.js +++ /dev/null @@ -1,464 +0,0 @@ -import { isSQLExpression } from './expression.js'; -import { asColumn, asRelation, isColumnRefFor, Ref } from './ref.js'; - -export class Query { - - static select(...expr) { - return new Query().select(...expr); - } - - static from(...expr) { - return new Query().from(...expr); - } - - static with(...expr) { - return new Query().with(...expr); - } - - static union(...queries) { - return new SetOperation('UNION', queries.flat()); - } - - static unionAll(...queries) { - return new SetOperation('UNION ALL', queries.flat()); - } - - static intersect(...queries) { - return new SetOperation('INTERSECT', queries.flat()); - } - - static except(...queries) { - return new SetOperation('EXCEPT', queries.flat()); - } - - constructor() { - this.query = { - with: [], - select: [], - from: [], - where: [], - groupby: [], - having: [], - window: [], - qualify: [], - orderby: [] - }; - } - - clone() { - const q = new Query(); - q.query = { ...this.query }; - return q; - } - - with(...expr) { - const { query } = this; - if (expr.length === 0) { - return query.with; - } else { - const list = []; - const add = (as, q) => { - const query = q.clone(); - query.cteFor = this; - list.push({ as, query }); - }; - expr.flat().forEach(e => { - if (e == null) { - // do nothing - } else if (e.as && e.query) { - add(e.as, e.query); - } else { - for (const as in e) { - add(as, e[as]); - } - } - }); - query.with = query.with.concat(list); - return this; - } - } - - select(...expr) { - const { query } = this; - if (expr.length === 0) { - return query.select; - } else { - const list = []; - for (const e of expr.flat()) { - if (e == null) { - // do nothing - } else if (typeof e === 'string') { - list.push({ as: e, expr: asColumn(e) }); - } else if (e instanceof Ref) { - list.push({ as: e.column, expr: e }); - } else if (Array.isArray(e)) { - list.push({ as: e[0], expr: e[1] }); - } else { - for (const as in e) { - list.push({ as: unquote(as), expr: asColumn(e[as]) }); - } - } - } - query.select = query.select.concat(list); - return this; - } - } - - $select(...expr) { - this.query.select = []; - return this.select(...expr); - } - - distinct(value = true) { - this.query.distinct = !!value; - return this; - } - - from(...expr) { - const { query } = this; - if (expr.length === 0) { - return query.from; - } else { - const list = []; - expr.flat().forEach(e => { - if (e == null) { - // do nothing - } else if (typeof e === 'string') { - list.push({ as: e, from: asRelation(e) }); - } else if (e instanceof Ref) { - list.push({ as: e.table, from: e }); - } else if (isQuery(e) || isSQLExpression(e)) { - list.push({ from: e }); - } else if (Array.isArray(e)) { - list.push({ as: unquote(e[0]), from: asRelation(e[1]) }); - } else { - for (const as in e) { - list.push({ as: unquote(as), from: asRelation(e[as]) }); - } - } - }); - query.from = query.from.concat(list); - return this; - } - } - - $from(...expr) { - this.query.from = []; - return this.from(...expr); - } - - sample(value, method) { - const { query } = this; - if (arguments.length === 0) { - return query.sample; - } else { - let spec = value; - if (typeof value === 'number') { - spec = value > 0 && value < 1 - ? { perc: 100 * value, method } - : { rows: Math.round(value), method }; - } - query.sample = spec; - return this; - } - } - - where(...expr) { - const { query } = this; - if (expr.length === 0) { - return query.where; - } else { - query.where = query.where.concat( - expr.flat().filter(x => x) - ); - return this; - } - } - - $where(...expr) { - this.query.where = []; - return this.where(...expr); - } - - groupby(...expr) { - const { query } = this; - if (expr.length === 0) { - return query.groupby; - } else { - query.groupby = query.groupby.concat( - expr.flat().filter(x => x).map(asColumn) - ); - return this; - } - } - - $groupby(...expr) { - this.query.groupby = []; - return this.groupby(...expr); - } - - having(...expr) { - const { query } = this; - if (expr.length === 0) { - return query.having; - } else { - query.having = query.having.concat( - expr.flat().filter(x => x) - ); - return this; - } - } - - window(...expr) { - const { query } = this; - if (expr.length === 0) { - return query.window; - } else { - const list = []; - expr.flat().forEach(e => { - if (e == null) { - // do nothing - } else { - for (const as in e) { - list.push({ as: unquote(as), expr: e[as] }); - } - } - }); - query.window = query.window.concat(list); - return this; - } - } - - qualify(...expr) { - const { query } = this; - if (expr.length === 0) { - return query.qualify; - } else { - query.qualify = query.qualify.concat( - expr.flat().filter(x => x) - ); - return this; - } - } - - orderby(...expr) { - const { query } = this; - if (expr.length === 0) { - return query.orderby; - } else { - query.orderby = query.orderby.concat( - expr.flat().filter(x => x).map(asColumn) - ); - return this; - } - } - - limit(value) { - const { query } = this; - if (arguments.length === 0) { - return query.limit; - } else { - query.limit = Number.isFinite(value) ? value : undefined; - return this; - } - } - - offset(value) { - const { query } = this; - if (arguments.length === 0) { - return query.offset; - } else { - query.offset = Number.isFinite(value) ? value : undefined; - return this; - } - } - - get subqueries() { - const { query, cteFor } = this; - const ctes = (cteFor?.query || query).with; - const cte = ctes?.reduce((o, {as, query}) => (o[as] = query, o), {}); - const q = []; - query.from.forEach(({ from }) => { - if (isQuery(from)) { - q.push(from); - } else if (cte[from.table]) { - const sub = cte[from.table]; - q.push(sub); - } - }); - return q; - } - - toString() { - const { - select, distinct, from, sample, where, groupby, - having, window, qualify, orderby, limit, offset, with: cte - } = this.query; - - const sql = []; - - // WITH - if (cte.length) { - const list = cte.map(({ as, query })=> `"${as}" AS (${query})`); - sql.push(`WITH ${list.join(', ')}`); - } - - // SELECT - const sels = select.map( - ({ as, expr }) => isColumnRefFor(expr, as) && !expr.table - ? `${expr}` - : `${expr} AS "${as}"` - ); - sql.push(`SELECT${distinct ? ' DISTINCT' : ''} ${sels.join(', ')}`); - - // FROM - if (from.length) { - const rels = from.map(({ as, from }) => { - const rel = isQuery(from) ? `(${from})` : `${from}`; - return !as || as === from.table ? rel : `${rel} AS "${as}"`; - }); - sql.push(`FROM ${rels.join(', ')}`); - } - - // WHERE - if (where.length) { - const clauses = where.map(String).filter(x => x).join(' AND '); - if (clauses) sql.push(`WHERE ${clauses}`); - } - - // SAMPLE - if (sample) { - const { rows, perc, method, seed } = sample; - const size = rows ? `${rows} ROWS` : `${perc} PERCENT`; - const how = method ? ` (${method}${seed != null ? `, ${seed}` : ''})` : ''; - sql.push(`USING SAMPLE ${size}${how}`); - } - - // GROUP BY - if (groupby.length) { - sql.push(`GROUP BY ${groupby.join(', ')}`); - } - - // HAVING - if (having.length) { - const clauses = having.map(String).filter(x => x).join(' AND '); - if (clauses) sql.push(`HAVING ${clauses}`); - } - - // WINDOW - if (window.length) { - const windows = window.map(({ as, expr }) => `"${as}" AS (${expr})`); - sql.push(`WINDOW ${windows.join(', ')}`); - } - - // QUALIFY - if (qualify.length) { - const clauses = qualify.map(String).filter(x => x).join(' AND '); - if (clauses) sql.push(`QUALIFY ${clauses}`); - } - - // ORDER BY - if (orderby.length) { - sql.push(`ORDER BY ${orderby.join(', ')}`); - } - - // LIMIT - if (Number.isFinite(limit)) { - sql.push(`LIMIT ${limit}`); - } - - // OFFSET - if (Number.isFinite(offset)) { - sql.push(`OFFSET ${offset}`); - } - - return sql.join(' '); - } -} - -export class SetOperation { - constructor(op, queries) { - this.op = op; - this.queries = queries.map(q => q.clone()); - this.query = { orderby: [] }; - } - - clone() { - const q = new SetOperation(this.op, this.queries); - q.query = { ...this.query }; - return q; - } - - orderby(...expr) { - const { query } = this; - if (expr.length === 0) { - return query.orderby; - } else { - query.orderby = query.orderby.concat( - expr.flat().filter(x => x).map(asColumn) - ); - return this; - } - } - - limit(value) { - const { query } = this; - if (arguments.length === 0) { - return query.limit; - } else { - query.limit = Number.isFinite(value) ? value : undefined; - return this; - } - } - - offset(value) { - const { query } = this; - if (arguments.length === 0) { - return query.offset; - } else { - query.offset = Number.isFinite(value) ? value : undefined; - return this; - } - } - - get subqueries() { - const { queries, cteFor } = this; - if (cteFor) queries.forEach(q => q.cteFor = cteFor); - return queries; - } - - toString() { - const { op, queries, query: { orderby, limit, offset } } = this; - - const sql = [ queries.join(` ${op} `) ]; - - // ORDER BY - if (orderby.length) { - sql.push(`ORDER BY ${orderby.join(', ')}`); - } - - // LIMIT - if (Number.isFinite(limit)) { - sql.push(`LIMIT ${limit}`); - } - - // OFFSET - if (Number.isFinite(offset)) { - sql.push(`OFFSET ${offset}`); - } - - return sql.join(' '); - } -} - -export function isQuery(value) { - return value instanceof Query || value instanceof SetOperation; -} - -function unquote(s) { - return isDoubleQuoted(s) ? s.slice(1, -1) : s; -} - -function isDoubleQuoted(s) { - return s[0] === '"' && s[s.length-1] === '"'; -} diff --git a/packages/sql/src/Query.ts b/packages/sql/src/Query.ts new file mode 100644 index 00000000..646494ad --- /dev/null +++ b/packages/sql/src/Query.ts @@ -0,0 +1,596 @@ +import { isSQLExpression, SQLExpression } from "./expression"; +import { asColumn, asRelation, isColumnRefFor, Ref } from "./ref"; + +export type Sample = { + rows?: number; + perc?: number; + method?: string; + seed?: number; +}; + +export type QueryFields = { + with: { as: string; query: QueryFields }[]; + select: { as: string; expr: Ref }[]; + distinct?: boolean; + from: { as?: string; from: Ref | Query }[]; + sample?: Sample; + where: Ref[]; + groupby: Ref[]; + having: Ref[]; + window: { as: string; expr: Ref }[]; + qualify: Ref[]; + orderby: Ref[]; + limit?: number; + offset?: number; +}; + +export class Query { + cteFor?: Query; + query: QueryFields; + + static select( + ...expr: (string | Ref | Ref[] | [string, any] | { [key: string]: any })[] + ) { + return new Query().select(...expr); + } + + static from(...expr: (string | Ref | Query)[]) { + return new Query().from(...expr); + } + + static with( + ...expr: ( + | { as: string; query: any } + | { as: string; [key: string]: any } + | { [key: string]: Query } + )[] + ) { + return new Query().with(...expr); + } + + static union(...queries: Query[]) { + return new SetOperation("UNION", queries.flat()); + } + + static unionAll(...queries: Query[]) { + return new SetOperation("UNION ALL", queries.flat()); + } + + static intersect(...queries: Query[]) { + return new SetOperation("INTERSECT", queries.flat()); + } + + static except(...queries: Query[]) { + return new SetOperation("EXCEPT", queries.flat()); + } + + constructor() { + this.query = { + with: [], + select: [], + from: [], + where: [], + groupby: [], + having: [], + window: [], + qualify: [], + orderby: [], + }; + } + + clone() { + const q = new Query(); + q.query = { ...this.query }; + return q; + } + + with(): { as: string; query: QueryFields }[]; + with( + ...expr: ( + | { as: string; query: any } + | { as: string; [key: string]: any } + | { [key: string]: Query } + )[] + ): this; + with( + ...expr: ( + | { as: string; query: any } + | { as: string; [key: string]: any } + | { [key: string]: Query } + )[] + ) { + const { query } = this; + if (expr.length === 0) { + return query.with; + } else { + const list: { as: string; query: QueryFields }[] = []; + const add = (as: string, q: any) => { + const query = q.clone(); + query.cteFor = this; + list.push({ as, query }); + }; + expr.flat().forEach((e) => { + if (e == null) { + // do nothing + } else if (e.as && e.query && typeof e.as === "string") { + add(e.as, e.query); + } else { + for (const as in e) { + add(as, (e as { [key: string]: any })[as]); + } + } + }); + query.with = query.with.concat(list); + return this; + } + } + + select(): { as: string; expr: Ref }[]; + select( + ...expr: (string | Ref[] | Ref | [string, any] | { [key: string]: any })[] + ): this; + select( + ...expr: (string | Ref[] | Ref | [string, any] | { [key: string]: any })[] + ) { + const { query } = this; + if (expr.length === 0) { + return query.select; + } else { + const list: { as: string; expr: any }[] = []; + for (const e of expr.flat()) { + if (e == null) { + // do nothing + } else if (typeof e === "string") { + list.push({ as: e, expr: asColumn(e) }); + } else if (e instanceof Ref) { + list.push({ as: e.column!, expr: e }); + } else if (Array.isArray(e)) { + list.push({ as: e[0], expr: e[1] }); + } else { + for (const as in e) { + list.push({ as: unquote(as), expr: asColumn(e[as]) }); + } + } + } + query.select = query.select.concat(list); + return this; + } + } + + $select(...expr: (string | Ref[] | [string, any])[]) { + this.query.select = []; + return this.select(...expr); + } + + distinct(value = true) { + this.query.distinct = !!value; + return this; + } + + from(): { as?: string; from: Ref | Query }[]; + from( + ...expr: (string | Ref | Query | { [key: string]: string | Ref | Query })[] + ): this; + from( + ...expr: (string | Ref | Query | { [key: string]: string | Ref | Query })[] + ) { + const { query } = this; + if (expr.length === 0) { + return query.from; + } else { + const list: { as?: string; from: any }[] = []; + expr.flat().forEach((e) => { + if (e == null) { + // do nothing + } else if (typeof e === "string") { + list.push({ as: e, from: asRelation(e) }); + } else if (e instanceof Ref) { + list.push({ as: e.table, from: e }); + } else if (isQuery(e) || isSQLExpression(e)) { + list.push({ from: e }); + } else if (Array.isArray(e)) { + list.push({ as: unquote(e[0]), from: asRelation(e[1]) }); + } else { + const obj = e as { [key: string]: string | Ref }; + for (const as in obj) { + list.push({ as: unquote(as), from: asRelation(obj[as]) }); + } + } + }); + query.from = query.from.concat(list); + return this; + } + } + + $from(...expr: (string | Ref | Query | { [key: string]: string | Ref })[]) { + this.query.from = []; + return this.from(...expr); + } + + sample(): Sample | undefined; + sample(value?: Sample | number, method?: string): this; + sample(value?: Sample | number, method?: string) { + const { query } = this; + if (arguments.length === 0) { + return query.sample; + } else { + let spec = value; + if (typeof value === "number") { + spec = + value > 0 && value < 1 + ? { perc: 100 * value, method } + : { rows: Math.round(value), method }; + } + query.sample = spec as Sample; + return this; + } + } + + where(): Ref[]; + where(...expr: any[]): this; + where(...expr: any[]) { + const { query } = this; + if (expr.length === 0) { + return query.where; + } else { + query.where = query.where.concat(expr.flat().filter((x) => x)); + return this; + } + } + + $where(...expr: any[]) { + this.query.where = []; + return this.where(...expr); + } + + groupby(): Ref[]; + groupby(...expr: (Ref | string | (Ref | string)[])[]): this; + groupby(...expr: (Ref | string | (Ref | string)[])[]) { + const { query } = this; + if (expr.length === 0) { + return query.groupby; + } else { + query.groupby = query.groupby.concat( + expr + .flat() + .filter((x) => x) + .map(asColumn) + ); + return this; + } + } + + $groupby(...expr: (Ref | string)[]) { + this.query.groupby = []; + return this.groupby(...expr); + } + + having(): Ref[]; + having(...expr: any[]): this; + having(...expr: any[]) { + const { query } = this; + if (expr.length === 0) { + return query.having; + } else { + query.having = query.having.concat(expr.flat().filter((x) => x)); + return this; + } + } + + window(): { as: string; expr: Ref }[]; + window(...expr: { [key: string]: string | Ref }[]): this; + window(...expr: { [key: string]: string | Ref }[]) { + const { query } = this; + if (expr.length === 0) { + return query.window; + } else { + const list: { as: string; expr: any }[] = []; + expr.flat().forEach((e) => { + if (e == null) { + // do nothing + } else { + for (const as in e) { + list.push({ as: unquote(as), expr: e[as] }); + } + } + }); + query.window = query.window.concat(list); + return this; + } + } + + qualify(): Ref[]; + qualify(...expr: any[]): this; + qualify(...expr: any[]) { + const { query } = this; + if (expr.length === 0) { + return query.qualify; + } else { + query.qualify = query.qualify.concat(expr.flat().filter((x) => x)); + return this; + } + } + + orderby(): Ref[]; + orderby( + ...expr: ((Ref | string | SQLExpression)[] | Ref | string | SQLExpression)[] + ): this; + orderby(...expr: (Ref | string | SQLExpression)[]) { + const { query } = this; + if (expr.length === 0) { + return query.orderby; + } else { + query.orderby = query.orderby.concat( + expr + .flat() + .filter((x) => x) + .map(asColumn) + ); + return this; + } + } + + limit(): number | undefined; + limit(value?: number): this; + limit(value?: number) { + const { query } = this; + if (arguments.length === 0) { + return query.limit; + } else { + query.limit = Number.isFinite(value) ? value : undefined; + return this; + } + } + + offset(): number | undefined; + offset(value?: number): this; + offset(value?: number) { + const { query } = this; + if (arguments.length === 0) { + return query.offset; + } else { + query.offset = Number.isFinite(value) ? value : undefined; + return this; + } + } + + get subqueries() { + const { query, cteFor } = this; + const ctes = (cteFor?.query || query).with; + const cte = ctes?.reduce( + ( + prev: { [key: string]: QueryFields }, + curr: { as: string; query: QueryFields } + ) => { + prev[curr.as] = curr.query; + return prev; + }, + {} as { [key: string]: QueryFields } + ); + const q: (Query | QueryFields)[] = []; + query.from.forEach(({ from }) => { + if (isQuery(from)) { + q.push(from as Query); + } else if (cte[(from as Ref).table!]) { + const sub = cte[(from as Ref).table!]; + q.push(sub); + } + }); + return q; + } + + toString() { + const { + select, + distinct, + from, + sample, + where, + groupby, + having, + window, + qualify, + orderby, + limit, + offset, + with: cte, + } = this.query; + + const sql: string[] = []; + + // WITH + if (cte.length) { + const list = cte.map(({ as, query }) => `"${as}" AS (${query})`); + sql.push(`WITH ${list.join(", ")}`); + } + + // SELECT + const sels = select.map(({ as, expr }) => + isColumnRefFor(expr, as) && !expr.table ? `${expr}` : `${expr} AS "${as}"` + ); + sql.push(`SELECT${distinct ? " DISTINCT" : ""} ${sels.join(", ")}`); + + // FROM + if (from.length) { + const rels = from.map(({ as, from }) => { + const rel = isQuery(from) ? `(${from})` : `${from}`; + return !as || as === (from as Ref).table ? rel : `${rel} AS "${as}"`; + }); + sql.push(`FROM ${rels.join(", ")}`); + } + + // WHERE + if (where.length) { + const clauses = where + .map(String) + .filter((x) => x) + .join(" AND "); + if (clauses) sql.push(`WHERE ${clauses}`); + } + + // SAMPLE + if (sample) { + const { rows, perc, method, seed } = sample; + const size = rows ? `${rows} ROWS` : `${perc} PERCENT`; + const how = method + ? ` (${method}${seed != null ? `, ${seed}` : ""})` + : ""; + sql.push(`USING SAMPLE ${size}${how}`); + } + + // GROUP BY + if (groupby.length) { + sql.push(`GROUP BY ${groupby.join(", ")}`); + } + + // HAVING + if (having.length) { + const clauses = having + .map(String) + .filter((x) => x) + .join(" AND "); + if (clauses) sql.push(`HAVING ${clauses}`); + } + + // WINDOW + if (window.length) { + const windows = window.map(({ as, expr }) => `"${as}" AS (${expr})`); + sql.push(`WINDOW ${windows.join(", ")}`); + } + + // QUALIFY + if (qualify.length) { + const clauses = qualify + .map(String) + .filter((x) => x) + .join(" AND "); + if (clauses) sql.push(`QUALIFY ${clauses}`); + } + + // ORDER BY + if (orderby.length) { + sql.push(`ORDER BY ${orderby.join(", ")}`); + } + + // LIMIT + if (Number.isFinite(limit)) { + sql.push(`LIMIT ${limit}`); + } + + // OFFSET + if (Number.isFinite(offset)) { + sql.push(`OFFSET ${offset}`); + } + + return sql.join(" "); + } +} + +export class SetOperation { + op: string; + queries: Query[]; + cteFor?: Query; + query: { + orderby: Ref[]; + limit?: number; + offset?: number; + }; + + constructor(op: string, queries: Query[]) { + this.op = op; + this.queries = queries.map((q) => q.clone()); + this.query = { orderby: [] }; + } + + clone() { + const q = new SetOperation(this.op, this.queries); + q.query = { ...this.query }; + return q; + } + + orderby(): Ref[]; + orderby(...expr: (Ref | string)[]): this; + orderby(...expr: (Ref | string)[]) { + const { query } = this; + if (expr.length === 0) { + return query.orderby; + } else { + query.orderby = query.orderby.concat( + expr + .flat() + .filter((x) => x) + .map(asColumn) + ); + return this; + } + } + + limit(): number | undefined; + limit(value?: number): this; + limit(value?: number) { + const { query } = this; + if (arguments.length === 0) { + return query.limit; + } else { + query.limit = Number.isFinite(value) ? value : undefined; + return this; + } + } + + offset(): number | undefined; + offset(value?: number): this; + offset(value?: number) { + const { query } = this; + if (arguments.length === 0) { + return query.offset; + } else { + query.offset = Number.isFinite(value) ? value : undefined; + return this; + } + } + + get subqueries() { + const { queries, cteFor } = this; + if (cteFor) queries.forEach((q) => (q.cteFor = cteFor)); + return queries; + } + + toString() { + const { + op, + queries, + query: { orderby, limit, offset }, + } = this; + + const sql = [queries.join(` ${op} `)]; + + // ORDER BY + if (orderby.length) { + sql.push(`ORDER BY ${orderby.join(", ")}`); + } + + // LIMIT + if (Number.isFinite(limit)) { + sql.push(`LIMIT ${limit}`); + } + + // OFFSET + if (Number.isFinite(offset)) { + sql.push(`OFFSET ${offset}`); + } + + return sql.join(" "); + } +} + +export function isQuery(value: any) { + return value instanceof Query || value instanceof SetOperation; +} + +function unquote(s: string) { + return isDoubleQuoted(s) ? s.slice(1, -1) : s; +} + +function isDoubleQuoted(s: string) { + return s[0] === '"' && s[s.length - 1] === '"'; +} diff --git a/packages/sql/src/aggregates.js b/packages/sql/src/aggregates.js deleted file mode 100644 index 30574889..00000000 --- a/packages/sql/src/aggregates.js +++ /dev/null @@ -1,138 +0,0 @@ -import { SQLExpression, parseSQL, sql } from './expression.js'; -import { asColumn } from './ref.js'; -import { repeat } from './repeat.js'; -import { literalToSQL } from './to-sql.js'; -import { WindowFunction } from './windows.js'; - -/** - * Tag function for SQL aggregate expressions. Interpolated values - * may be strings, other SQL expression objects (such as column - * references), or parameterized values. - */ -export function agg(strings, ...exprs) { - return sql(strings, ...exprs).annotate({ aggregate: true }); -} - -/** - * Base class for individual aggregate functions. - * Most callers should use a dedicated aggregate function - * rather than instantiate this class. - */ -export class AggregateFunction extends SQLExpression { - constructor(op, args, type, isDistinct, filter) { - args = (args || []).map(asColumn); - const { strings, exprs } = aggExpr(op, args, type, isDistinct, filter); - const { spans, cols } = parseSQL(strings, exprs); - super(spans, cols, { aggregate: op, args, type, isDistinct, filter }); - } - - get basis() { - return this.column; - } - - get label() { - const { aggregate: op, args, isDistinct } = this; - const dist = isDistinct ? 'DISTINCT' + (args.length ? ' ' : '') : ''; - const tail = args.length ? `(${dist}${args.map(unquoted).join(', ')})` : ''; - return `${op.toLowerCase()}${tail}`; - } - - distinct() { - const { aggregate: op, args, type, filter } = this; - return new AggregateFunction(op, args, type, true, filter); - } - - where(filter) { - const { aggregate: op, args, type, isDistinct } = this; - return new AggregateFunction(op, args, type, isDistinct, filter); - } - - window() { - const { aggregate: op, args, type, isDistinct } = this; - const func = new AggregateFunction(op, args, null, isDistinct); - return new WindowFunction(op, func, type); - } - - partitionby(...expr) { - return this.window().partitionby(...expr); - } - - orderby(...expr) { - return this.window().orderby(...expr); - } - - rows(prev, next) { - return this.window().rows(prev, next); - } - - range(prev, next) { - return this.window().range(prev, next); - } -} - -function aggExpr(op, args, type, isDistinct, filter) { - const close = `)${type ? `::${type}` : ''}`; - let strings = [`${op}(${isDistinct ? 'DISTINCT ' :''}`]; - let exprs = []; - if (args.length) { - strings = strings.concat([ - ...repeat(args.length - 1, ', '), - `${close}${filter ? ' FILTER (WHERE ' : ''}`, - ...(filter ? [')'] : []) - ]); - exprs = [...args, ...(filter ? [filter] : [])]; - } else { - strings[0] += '*' + close; - } - return { exprs, strings }; -} - -function unquoted(value) { - const s = literalToSQL(value); - return s && s.startsWith('"') && s.endsWith('"') ? s.slice(1, -1) : s -} - -function aggf(op, type) { - return (...args) => new AggregateFunction(op, args, type); -} - -export const count = aggf('COUNT', 'INTEGER'); -export const avg = aggf('AVG'); -export const mean = aggf('AVG'); -export const mad = aggf('MAD'); -export const max = aggf('MAX'); -export const min = aggf('MIN'); -export const sum = aggf('SUM', 'DOUBLE'); -export const product = aggf('PRODUCT'); -export const median = aggf('MEDIAN'); -export const quantile = aggf('QUANTILE'); -export const mode = aggf('MODE'); - -export const variance = aggf('VARIANCE'); -export const stddev = aggf('STDDEV'); -export const skewness = aggf('SKEWNESS'); -export const kurtosis = aggf('KURTOSIS'); -export const entropy = aggf('ENTROPY'); -export const varPop = aggf('VAR_POP'); -export const stddevPop = aggf('STDDEV_POP'); - -export const corr = aggf('CORR'); -export const covarPop = aggf('COVAR_POP'); -export const regrIntercept = aggf('REGR_INTERCEPT'); -export const regrSlope = aggf('REGR_SLOPE'); -export const regrCount = aggf('REGR_COUNT'); -export const regrR2 = aggf('REGR_R2'); -export const regrSYY = aggf('REGR_SYY'); -export const regrSXX = aggf('REGR_SXX'); -export const regrSXY = aggf('REGR_SXY'); -export const regrAvgX = aggf('REGR_AVGX'); -export const regrAvgY = aggf('REGR_AVGY'); - -export const first = aggf('FIRST'); -export const last = aggf('LAST'); - -export const argmin = aggf('ARG_MIN'); -export const argmax = aggf('ARG_MAX'); - -export const stringAgg = aggf('STRING_AGG'); -export const arrayAgg = aggf('ARRAY_AGG'); diff --git a/packages/sql/src/aggregates.ts b/packages/sql/src/aggregates.ts new file mode 100644 index 00000000..8cf89058 --- /dev/null +++ b/packages/sql/src/aggregates.ts @@ -0,0 +1,161 @@ +import { SQLExpression, parseSQL, sql } from "./expression"; +import { asColumn } from "./ref"; +import { repeat } from "./repeat"; +import { literalToSQL } from "./to-sql"; +import { WindowFunction } from "./windows"; + +/** + * Tag function for SQL aggregate expressions. Interpolated values + * may be strings, other SQL expression objects (such as column + * references), or parameterized values. + */ +export function agg(strings: any, ...exprs: any[]) { + return sql(strings, ...exprs).annotate({ aggregate: true }); +} + +/** + * Base class for individual aggregate functions. + * Most callers should use a dedicated aggregate function + * rather than instantiate this class. + */ +export class AggregateFunction extends SQLExpression { + isDistinct: boolean; + filter: SQLExpression | null; + type: string | null; + aggregate: string; + args: SQLExpression[]; + + constructor( + op: string, + args: any[], + type: string | null = null, + isDistinct?: boolean, + filter?: SQLExpression | null + ) { + args = (args || []).map(asColumn); + const { strings, exprs } = aggExpr(op, args, type, isDistinct, filter); + const { spans, cols } = parseSQL(strings, exprs); + super(spans, cols); + this.aggregate = op; + this.args = args; + this.type = type; + this.isDistinct = isDistinct || false; + this.filter = filter || null; + + // generate the label + const dist = this.isDistinct ? "DISTINCT " : ""; + const tail = this.args.length + ? `(${dist}${this.args.map(unquoted).join(", ")})` + : ""; + this.label = `${op.toLowerCase()}${tail}`; + } + + get basis() { + return this.column; + } + + distinct() { + const { aggregate: op, args, type, filter } = this; + return new AggregateFunction(op, args, type, true, filter); + } + + where(filter: SQLExpression | null) { + const { aggregate: op, args, type, isDistinct } = this; + return new AggregateFunction(op, args, type, isDistinct, filter); + } + + window() { + const { aggregate: op, args, type, isDistinct } = this; + const func = new AggregateFunction(op, args, null, isDistinct); + return new WindowFunction(op, func, type); + } + + partitionby(...expr: any[]) { + return this.window().partitionby(...expr); + } + + orderby(...expr: any[]) { + return this.window().orderby(...expr); + } + + rows([start, end]: [any, any]) { + return this.window().rows([start, end]); + } + + range([start, end]: [any, any]) { + return this.window().range([start, end]); + } +} + +function aggExpr( + op: string, + args: string[], + type: string | null = null, + isDistinct?: boolean, + filter?: SQLExpression | null +) { + const close = `)${type ? `::${type}` : ""}`; + let strings = [`${op}(${isDistinct ? "DISTINCT " : ""}`]; + let exprs: (string | SQLExpression)[] = []; + if (args.length) { + strings = strings.concat([ + ...repeat(args.length - 1, ", "), + `${close}${filter ? " FILTER (WHERE " : ""}`, + ...(filter ? [")"] : []), + ]); + exprs = [...args, ...(filter ? [filter] : [])]; + } else { + strings[0] += "*" + close; + } + return { exprs, strings }; +} + +function unquoted(value: any) { + const s = literalToSQL(value); + return s && s.startsWith('"') && s.endsWith('"') ? s.slice(1, -1) : s; +} + +function aggf(op: string, type?: string) { + return (...args: any) => new AggregateFunction(op, args, type); +} + +export const count = aggf("COUNT", "INTEGER"); +export const avg = aggf("AVG"); +export const mean = aggf("AVG"); +export const mad = aggf("MAD"); +export const max = aggf("MAX"); +export const min = aggf("MIN"); +export const sum = aggf("SUM", "DOUBLE"); +export const product = aggf("PRODUCT"); +export const median = aggf("MEDIAN"); +export const quantile = aggf("QUANTILE"); +export const mode = aggf("MODE"); + +export const variance = aggf("VARIANCE"); +export const stddev = aggf("STDDEV"); +export const skewness = aggf("SKEWNESS"); +export const kurtosis = aggf("KURTOSIS"); +export const entropy = aggf("ENTROPY"); +export const varPop = aggf("VAR_POP"); +export const stddevPop = aggf("STDDEV_POP"); + +export const corr = aggf("CORR"); +export const covarPop = aggf("COVAR_POP"); +export const regrIntercept = aggf("REGR_INTERCEPT"); +export const regrSlope = aggf("REGR_SLOPE"); +export const regrCount = aggf("REGR_COUNT"); +export const regrR2 = aggf("REGR_R2"); +export const regrSYY = aggf("REGR_SYY"); +export const regrSXX = aggf("REGR_SXX"); +export const regrSXY = aggf("REGR_SXY"); +export const regrAvgX = aggf("REGR_AVGX"); +export const regrAvgY = aggf("REGR_AVGY"); + +export const first = aggf("FIRST"); +export const last = aggf("LAST"); + +export const argmin = aggf("ARG_MIN"); +export const argmax = aggf("ARG_MAX"); + +export const stringAgg = aggf("STRING_AGG"); +export const arrayAgg = aggf("ARRAY_AGG"); diff --git a/packages/sql/src/cast.js b/packages/sql/src/cast.js deleted file mode 100644 index 2236b8c0..00000000 --- a/packages/sql/src/cast.js +++ /dev/null @@ -1,19 +0,0 @@ -import { sql } from './expression.js'; -import { asColumn } from './ref.js'; - -export function cast(expr, type) { - const arg = asColumn(expr); - const e = sql`CAST(${arg} AS ${type})`; - Object.defineProperty(e, 'label', { - enumerable: true, - get() { return expr.label; } - }); - Object.defineProperty(e, 'aggregate', { - enumerable: true, - get() { return expr.aggregate || false; } - }); - return e; -} - -export const castDouble = expr => cast(expr, 'DOUBLE'); -export const castInteger = expr => cast(expr, 'INTEGER'); diff --git a/packages/sql/src/cast.ts b/packages/sql/src/cast.ts new file mode 100644 index 00000000..a312cb5e --- /dev/null +++ b/packages/sql/src/cast.ts @@ -0,0 +1,23 @@ +import { SQLExpression, sql } from "./expression"; +import { asColumn } from "./ref"; + +export function cast(expr: any, type: string): SQLExpression { + const arg = asColumn(expr); + const e = sql`CAST(${arg} AS ${type})`; + Object.defineProperty(e, "label", { + enumerable: true, + get() { + return expr.label; + }, + }); + Object.defineProperty(e, "aggregate", { + enumerable: true, + get() { + return expr.aggregate || false; + }, + }); + return e; +} + +export const castDouble = (expr: any) => cast(expr, "DOUBLE"); +export const castInteger = (expr: any) => cast(expr, "INTEGER"); diff --git a/packages/sql/src/datetime.js b/packages/sql/src/datetime.js deleted file mode 100644 index 581dd1fa..00000000 --- a/packages/sql/src/datetime.js +++ /dev/null @@ -1,25 +0,0 @@ -import { sql } from './expression.js'; -import { asColumn } from './ref.js'; - -export const epoch_ms = expr => { - const d = asColumn(expr); - return sql`(1000 * (epoch(${d}) - second(${d})) + millisecond(${d}))::DOUBLE`; -}; - -export const dateMonth = expr => { - const d = asColumn(expr); - return sql`MAKE_DATE(2012, MONTH(${d}), 1)` - .annotate({ label: 'month' }); -}; - -export const dateMonthDay = expr => { - const d = asColumn(expr); - return sql`MAKE_DATE(2012, MONTH(${d}), DAY(${d}))` - .annotate({ label: 'date' }); -}; - -export const dateDay = expr => { - const d = asColumn(expr); - return sql`MAKE_DATE(2012, 1, DAY(${d}))` - .annotate({ label: 'date' }); -}; diff --git a/packages/sql/src/datetime.ts b/packages/sql/src/datetime.ts new file mode 100644 index 00000000..b13366e8 --- /dev/null +++ b/packages/sql/src/datetime.ts @@ -0,0 +1,24 @@ +import { sql } from "./expression"; +import { asColumn } from "./ref"; + +export const epoch_ms = (expr: any) => { + const d = asColumn(expr); + return sql`(1000 * (epoch(${d}) - second(${d})) + millisecond(${d}))::DOUBLE`; +}; + +export const dateMonth = (expr: any) => { + const d = asColumn(expr); + return sql`MAKE_DATE(2012, MONTH(${d}), 1)`.annotate({ label: "month" }); +}; + +export const dateMonthDay = (expr: any) => { + const d = asColumn(expr); + return sql`MAKE_DATE(2012, MONTH(${d}), DAY(${d}))`.annotate({ + label: "date", + }); +}; + +export const dateDay = (expr: any) => { + const d = asColumn(expr); + return sql`MAKE_DATE(2012, 1, DAY(${d}))`.annotate({ label: "date" }); +}; diff --git a/packages/sql/src/desc.js b/packages/sql/src/desc.ts similarity index 68% rename from packages/sql/src/desc.js rename to packages/sql/src/desc.ts index df1f0861..46b6b0e4 100644 --- a/packages/sql/src/desc.js +++ b/packages/sql/src/desc.ts @@ -1,5 +1,5 @@ -import { sql } from './expression.js'; -import { asColumn } from './ref.js'; +import { SQLExpression, sql } from "./expression"; +import { Ref, asColumn } from "./ref"; /** * Annotate an expression to indicate descending sort order. @@ -7,7 +7,7 @@ import { asColumn } from './ref.js'; * @param {SQLExpression|string} expr A SQL expression or column name string. * @returns {SQLExpression} An expression with descending order. */ -export function desc(expr) { +export function desc(expr: SQLExpression | string | Ref): SQLExpression { const e = asColumn(expr); return sql`${e} DESC NULLS LAST`.annotate({ label: e?.label, desc: true }); } diff --git a/packages/sql/src/expression.js b/packages/sql/src/expression.ts similarity index 58% rename from packages/sql/src/expression.js rename to packages/sql/src/expression.ts index 585e0b85..d585a1c1 100644 --- a/packages/sql/src/expression.js +++ b/packages/sql/src/expression.ts @@ -1,18 +1,26 @@ -import { literalToSQL } from './to-sql.js'; +import { Ref } from "./ref"; +import { literalToSQL } from "./to-sql"; + +export interface Param { + addEventListener(type: "value", callback: (a: SQLExpression) => void): void; + value?: any; + update(value: any): void; +} /** * Test if a value is parameter-like. Parameters have addEventListener methods. * @param {*} value The value to test. * @returns True if the value is param-like, false otherwise. */ -export const isParamLike = value => typeof value?.addEventListener === 'function'; +export const isParamLike = (value: any): boolean => + typeof value?.addEventListener === "function"; /** * Test if a value is a SQL expression instance. * @param {*} value The value to test. * @returns {boolean} True if value is a SQL expression, false otherwise. */ -export function isSQLExpression(value) { +export function isSQLExpression(value: any): boolean { return value instanceof SQLExpression; } @@ -21,6 +29,24 @@ export function isSQLExpression(value) { * template tag rather than instantiate this class. */ export class SQLExpression { + _expr: (string | SQLExpression | Ref | Param)[]; + _deps: string[]; + _params?: Param[]; + _map?: Map void>>; + aggregate?: string; + label?: string; + + /** + * Add an event listener callback for the provided event type. + * @param {string} type The event type to listen for (for example, "value"). + * @param {(a: SQLExpression) => Promise?} callback The callback function to + * invoke upon updates. A callback may optionally return a Promise that + * upstream listeners may await before proceeding. + */ + addEventListener?: ( + type: string, + callback: (a: SQLExpression) => void + ) => void; /** * Create a new SQL expression instance. @@ -28,17 +54,30 @@ export class SQLExpression { * @param {string[]} [columns=[]] The column dependencies * @param {object} [props] Additional properties for this expression. */ - constructor(parts, columns, props) { + constructor( + parts: (string | SQLExpression | Ref | Param)[], + columns: string[], + props?: any + ) { this._expr = Array.isArray(parts) ? parts : [parts]; this._deps = columns || []; this.annotate(props); - const params = this._expr.filter(part => isParamLike(part)); + const params: Param[] = this._expr.filter((part) => + isParamLike(part) + ) as unknown[] as Param[]; if (params.length > 0) { this._params = Array.from(new Set(params)); - this._params.forEach(param => { - param.addEventListener('value', () => update(this, this.map?.get('value'))); + this._params.forEach((param) => { + param.addEventListener("value", () => + update(this, this._map?.get("value")) + ); }); + this.addEventListener = (type, callback) => { + const map = this._map || (this._map = new Map()); + const set = map.get(type) || (map.set(type, new Set()), map.get(type)); + set.add(callback); + }; } else { // do not support event listeners if not needed // this causes the expression instance to NOT be param-like @@ -62,13 +101,15 @@ export class SQLExpression { const { _params, _deps } = this; if (_params) { // pull latest dependencies, as they may change across updates - const pset = new Set(_params.flatMap(p => { - const cols = p.value?.columns; - return Array.isArray(cols) ? cols : []; - })); + const pset = new Set( + _params.flatMap((p) => { + const cols = p.value?.columns; + return Array.isArray(cols) ? cols : []; + }) + ); if (pset.size) { const set = new Set(_deps); - pset.forEach(col => set.add(col)); + pset.forEach((col) => set.add(col)); return Array.from(set); } } @@ -89,7 +130,7 @@ export class SQLExpression { * @param {object[]} [props] One or more objects with properties to add. * @returns {this} This SQL expression. */ - annotate(...props) { + annotate(...props: any[]) { return Object.assign(this, ...props); } @@ -99,43 +140,37 @@ export class SQLExpression { */ toString() { return this._expr - .map(p => isParamLike(p) && !isSQLExpression(p) ? literalToSQL(p.value) : p) - .join(''); - } - - /** - * Add an event listener callback for the provided event type. - * @param {string} type The event type to listen for (for example, "value"). - * @param {(a: SQLExpression) => Promise?} callback The callback function to - * invoke upon updates. A callback may optionally return a Promise that - * upstream listeners may await before proceeding. - */ - addEventListener(type, callback) { - const map = this.map || (this.map = new Map()); - const set = map.get(type) || (map.set(type, new Set), map.get(type)); - set.add(callback); + .map((p) => + isParamLike(p) && !isSQLExpression(p) + ? literalToSQL((p as unknown as Param).value) + : p + ) + .join(""); } } -function update(expr, callbacks) { +function update(expr: SQLExpression, callbacks?: Set) { if (callbacks?.size) { - return Promise.allSettled(Array.from(callbacks, fn => fn(expr))); + return Promise.allSettled(Array.from(callbacks, (fn) => fn(expr))); } } -export function parseSQL(strings, exprs) { - const spans = [strings[0]]; - const cols = new Set; +export function parseSQL( + strings: readonly string[], + exprs: (string | SQLExpression | Ref | Param)[] +) { + const spans: (string | SQLExpression | Ref | Param)[] = [strings[0]]; + const cols = new Set(); const n = exprs.length; - for (let i = 0, k = 0; i < n;) { + for (let i = 0, k = 0; i < n; ) { const e = exprs[i]; if (isParamLike(e)) { spans[++k] = e; } else { - if (Array.isArray(e?.columns)) { - e.columns.forEach(col => cols.add(col)); + if (Array.isArray((e as SQLExpression)?.columns)) { + (e as SQLExpression).columns.forEach((col) => cols.add(col)); } - spans[k] += typeof e === 'string' ? e : literalToSQL(e); + spans[k] += typeof e === "string" ? e : literalToSQL(e); } const s = strings[++i]; if (isParamLike(spans[k])) { @@ -153,7 +188,10 @@ export function parseSQL(strings, exprs) { * may be strings, other SQL expression objects (such as column * references), or parameterized values. */ -export function sql(strings, ...exprs) { +export function sql( + strings: readonly string[], + ...exprs: (SQLExpression | Ref | string | Param)[] +) { const { spans, cols } = parseSQL(strings, exprs); - return new SQLExpression(spans, cols); + return new SQLExpression(spans as SQLExpression[], cols); } diff --git a/packages/sql/src/functions.js b/packages/sql/src/functions.js deleted file mode 100644 index af9e091c..00000000 --- a/packages/sql/src/functions.js +++ /dev/null @@ -1,25 +0,0 @@ -import { sql } from './expression.js'; -import { asColumn } from './ref.js'; -import { repeat } from './repeat.js'; - -export function functionCall(op, type) { - return (...values) => { - const args = values.map(asColumn); - const cast = type ? `::${type}` : ''; - const expr = args.length - ? sql([`${op}(`, ...repeat(args.length - 1, ', '), `)${cast}`], ...args) - : sql`${op}()${cast}`; - return expr.annotate({ func: op, args }); - } -} - -export const regexp_matches = functionCall('REGEXP_MATCHES'); -export const contains = functionCall('CONTAINS'); -export const prefix = functionCall('PREFIX'); -export const suffix = functionCall('SUFFIX'); -export const lower = functionCall('LOWER'); -export const upper = functionCall('UPPER'); -export const length = functionCall('LENGTH'); -export const isNaN = functionCall('ISNAN'); -export const isFinite = functionCall('ISFINITE'); -export const isInfinite = functionCall('ISINF'); diff --git a/packages/sql/src/functions.ts b/packages/sql/src/functions.ts new file mode 100644 index 00000000..02c83c59 --- /dev/null +++ b/packages/sql/src/functions.ts @@ -0,0 +1,28 @@ +import { sql } from "./expression"; +import { asColumn } from "./ref"; +import { repeat } from "./repeat"; + +export function functionCall( + op: string, + type?: string +): (...values: any[]) => any { + return (...values) => { + const args = values.map(asColumn); + const cast = type ? `::${type}` : ""; + const expr = args.length + ? sql([`${op}(`, ...repeat(args.length - 1, ", "), `)${cast}`], ...args) + : sql`${op}()${cast}`; + return expr.annotate({ func: op, args }); + }; +} + +export const regexp_matches = functionCall("REGEXP_MATCHES"); +export const contains = functionCall("CONTAINS"); +export const prefix = functionCall("PREFIX"); +export const suffix = functionCall("SUFFIX"); +export const lower = functionCall("LOWER"); +export const upper = functionCall("UPPER"); +export const length = functionCall("LENGTH"); +export const isNaN = functionCall("ISNAN"); +export const isFinite = functionCall("ISFINITE"); +export const isInfinite = functionCall("ISINF"); diff --git a/packages/sql/src/index.js b/packages/sql/src/index.js deleted file mode 100644 index 5acee9ab..00000000 --- a/packages/sql/src/index.js +++ /dev/null @@ -1,137 +0,0 @@ -export { - Ref, - asColumn, - asRelation, - all, - column, - relation -} from './ref.js'; - -export { - isSQLExpression, - isParamLike, - sql -} from './expression.js'; - -export { - desc -} from './desc.js'; - -export { - literal -} from './literal.js'; - -export { - and, - or, - not, - eq, - neq, - lt, - gt, - lte, - gte, - isBetween, - isNotBetween, - isDistinct, - isNotDistinct, - isNull, - isNotNull -} from './operators.js'; - -export { - agg, - argmax, - argmin, - arrayAgg, - avg, - corr, - count, - covarPop, - entropy, - first, - kurtosis, - mean, - mad, - max, - median, - min, - mode, - last, - product, - quantile, - regrAvgX, - regrAvgY, - regrCount, - regrIntercept, - regrR2, - regrSXX, - regrSXY, - regrSYY, - regrSlope, - skewness, - stddev, - stddevPop, - stringAgg, - sum, - variance, - varPop -} from './aggregates.js'; - -export { - cast, - castDouble, - castInteger -} from './cast.js'; - -export { - epoch_ms, - dateMonth, - dateMonthDay, - dateDay -} from './datetime.js'; - -export { - regexp_matches, - contains, - prefix, - suffix, - lower, - upper, - length, - isNaN, - isFinite, - isInfinite -} from './functions.js'; - -export { - row_number, - rank, - dense_rank, - percent_rank, - cume_dist, - ntile, - lag, - lead, - first_value, - last_value, - nth_value -} from './windows.js'; - -export { - Query, - isQuery -} from './Query.js'; - -export { - toSQL, - literalToSQL -} from './to-sql.js'; - -export { create } from './load/create.js'; -export { - loadCSV, - loadJSON, - loadObjects, - loadParquet -} from './load/load.js'; diff --git a/packages/sql/src/index.ts b/packages/sql/src/index.ts new file mode 100644 index 00000000..c9b17c90 --- /dev/null +++ b/packages/sql/src/index.ts @@ -0,0 +1,102 @@ +export { Ref, asColumn, asRelation, all, column, relation } from "./ref"; + +export { isSQLExpression, isParamLike, sql } from "./expression"; + +export { desc } from "./desc"; + +export { literal } from "./literal"; + +export { + and, + or, + not, + eq, + neq, + lt, + gt, + lte, + gte, + isBetween, + isNotBetween, + isDistinct, + isNotDistinct, + isNull, + isNotNull, +} from "./operators"; + +export { + agg, + argmax, + argmin, + arrayAgg, + avg, + corr, + count, + covarPop, + entropy, + first, + kurtosis, + mean, + mad, + max, + median, + min, + mode, + last, + product, + quantile, + regrAvgX, + regrAvgY, + regrCount, + regrIntercept, + regrR2, + regrSXX, + regrSXY, + regrSYY, + regrSlope, + skewness, + stddev, + stddevPop, + stringAgg, + sum, + variance, + varPop, +} from "./aggregates"; + +export { cast, castDouble, castInteger } from "./cast"; + +export { epoch_ms, dateMonth, dateMonthDay, dateDay } from "./datetime"; + +export { + regexp_matches, + contains, + prefix, + suffix, + lower, + upper, + length, + isNaN, + isFinite, + isInfinite, +} from "./functions"; + +export { + row_number, + rank, + dense_rank, + percent_rank, + cume_dist, + ntile, + lag, + lead, + first_value, + last_value, + nth_value, +} from "./windows"; + +export { Query, isQuery } from "./Query"; + +export { toSQL, literalToSQL } from "./to-sql"; + +export { create } from "./load/create"; +export { loadCSV, loadJSON, loadObjects, loadParquet } from "./load/load"; diff --git a/packages/sql/src/literal.js b/packages/sql/src/literal.js deleted file mode 100644 index 4a6ae61b..00000000 --- a/packages/sql/src/literal.js +++ /dev/null @@ -1,6 +0,0 @@ -import { literalToSQL } from './to-sql.js'; - -export const literal = value => ({ - value, - toString: () => literalToSQL(value) -}); diff --git a/packages/sql/src/literal.ts b/packages/sql/src/literal.ts new file mode 100644 index 00000000..faab01a3 --- /dev/null +++ b/packages/sql/src/literal.ts @@ -0,0 +1,6 @@ +import { literalToSQL } from "./to-sql"; + +export const literal = (value: any) => ({ + value, + toString: () => literalToSQL(value), +}); diff --git a/packages/sql/src/load/create.js b/packages/sql/src/load/create.js deleted file mode 100644 index e00bf696..00000000 --- a/packages/sql/src/load/create.js +++ /dev/null @@ -1,12 +0,0 @@ -export function create(name, query, { - replace = false, - temp = true, - view = false -} = {}) { - return 'CREATE' - + (replace ? ' OR REPLACE ' : ' ') - + (temp ? 'TEMP ' : '') - + (view ? 'VIEW' : 'TABLE') - + (replace ? ' ' : ' IF NOT EXISTS ') - + name + ' AS ' + query; -} diff --git a/packages/sql/src/load/create.ts b/packages/sql/src/load/create.ts new file mode 100644 index 00000000..d5fb3936 --- /dev/null +++ b/packages/sql/src/load/create.ts @@ -0,0 +1,24 @@ +export function create( + name: string, + query: string, + { + replace = false, + temp = true, + view = false, + }: { + replace?: boolean; + temp?: boolean; + view?: boolean; + } +) { + return ( + "CREATE" + + (replace ? " OR REPLACE " : " ") + + (temp ? "TEMP " : "") + + (view ? "VIEW" : "TABLE") + + (replace ? " " : " IF NOT EXISTS ") + + name + + " AS " + + query + ); +} diff --git a/packages/sql/src/load/load.js b/packages/sql/src/load/load.js deleted file mode 100644 index 06c732d3..00000000 --- a/packages/sql/src/load/load.js +++ /dev/null @@ -1,62 +0,0 @@ -import { create } from './create.js'; -import { sqlFrom } from './sql-from.js'; - -export function load(method, tableName, fileName, options = {}, defaults = {}) { - const { select = ['*'], where, view, temp, replace, ...file } = options; - const params = parameters({ ...defaults, ...file }); - const read = `${method}('${fileName}'${params ? ', ' + params : ''})`; - const filter = where ? ` WHERE ${where}` : ''; - const query = `SELECT ${select.join(', ')} FROM ${read}${filter}`; - return create(tableName, query, { view, temp, replace }); -} - -export function loadCSV(tableName, fileName, options) { - return load('read_csv', tableName, fileName, options, { auto_detect: true, sample_size: -1 }); -} - -export function loadJSON(tableName, fileName, options) { - return load('read_json', tableName, fileName, options, { auto_detect: true, json_format: 'auto' }); -} - -export function loadParquet(tableName, fileName, options) { - return load('read_parquet', tableName, fileName, options); -} - -export function loadObjects(tableName, data, options = {}) { - const { select = ['*'], ...opt } = options; - const values = sqlFrom(data); - const query = select.length === 1 && select[0] === '*' - ? values - : `SELECT ${select} FROM ${values}`; - return create(tableName, query, opt); -} - -function parameters(options) { - return Object.entries(options) - .map(([key, value]) => `${key}=${toDuckDBValue(value)}`) - .join(', '); -} - -function toDuckDBValue(value) { - switch (typeof value) { - case 'boolean': - return String(value); - case 'string': - return `'${value}'`; - case 'undefined': - case 'object': - if (value == null) { - return 'NULL'; - } else if (Array.isArray(value)) { - return '[' + value.map(v => toDuckDBValue(v)).join(', ') + ']'; - } else { - return '{' - + Object.entries(value) - .map(([k, v]) => `'${k}': ${toDuckDBValue(v)}`) - .join(', ') - + '}'; - } - default: - return value; - } -} diff --git a/packages/sql/src/load/load.ts b/packages/sql/src/load/load.ts new file mode 100644 index 00000000..146dbd63 --- /dev/null +++ b/packages/sql/src/load/load.ts @@ -0,0 +1,104 @@ +import { create } from "./create"; +import { sqlFrom } from "./sql-from"; + +type LoadOptions = { + select?: string[]; + where?: string; + view?: boolean; + temp?: boolean; + replace?: boolean; + auto_detect?: boolean; + sample_size?: number; + json_format?: "auto" | "array-of-objects" | "lines"; +}; + +export function load( + method: string, + tableName: string, + fileName: string, + options: LoadOptions = {}, + defaults: LoadOptions = {} +): string { + const { select = ["*"], where, view, temp, replace, ...file } = options; + const params = parameters({ ...defaults, ...file }); + const read = `${method}('${fileName}'${params ? ", " + params : ""})`; + const filter = where ? ` WHERE ${where}` : ""; + const query = `SELECT ${select.join(", ")} FROM ${read}${filter}`; + return create(tableName, query, { view, temp, replace }); +} + +export function loadCSV( + tableName: string, + fileName: string, + options: LoadOptions +): string { + return load("read_csv", tableName, fileName, options, { + auto_detect: true, + sample_size: -1, + }); +} + +export function loadJSON( + tableName: string, + fileName: string, + options: LoadOptions +): string { + return load("read_json", tableName, fileName, options, { + auto_detect: true, + json_format: "auto", + }); +} + +export function loadParquet( + tableName: string, + fileName: string, + options: LoadOptions +): string { + return load("read_parquet", tableName, fileName, options); +} + +export function loadObjects( + tableName: string, + data: { [key: string]: any }[], + options: LoadOptions = {} +) { + const { select = ["*"], ...opt } = options; + const values = sqlFrom(data); + const query = + select.length === 1 && select[0] === "*" + ? values + : `SELECT ${select} FROM ${values}`; + return create(tableName, query, opt); +} + +function parameters(options: LoadOptions): string { + return Object.entries(options) + .map(([key, value]) => `${key}=${toDuckDBValue(value)}`) + .join(", "); +} + +function toDuckDBValue(value: any): string { + switch (typeof value) { + case "boolean": + return String(value); + case "string": + return `'${value}'`; + case "undefined": + case "object": + if (value == null) { + return "NULL"; + } else if (Array.isArray(value)) { + return "[" + value.map((v) => toDuckDBValue(v)).join(", ") + "]"; + } else { + return ( + "{" + + Object.entries(value) + .map(([k, v]) => `'${k}': ${toDuckDBValue(v)}`) + .join(", ") + + "}" + ); + } + default: + return value; + } +} diff --git a/packages/sql/src/load/sql-from.js b/packages/sql/src/load/sql-from.js deleted file mode 100644 index a0e7ff31..00000000 --- a/packages/sql/src/load/sql-from.js +++ /dev/null @@ -1,22 +0,0 @@ -import { literalToSQL } from '../to-sql.js'; - -export function sqlFrom(data, { - columns = Object.keys(data?.[0] || {}) -} = {}) { - let keys = []; - if (Array.isArray(columns)) { - keys = columns; - columns = keys.reduce((m, k) => (m[k] = k, m), {}); - } else if (columns) { - keys = Object.keys(columns); - } - if (!keys.length) { - throw new Error('Can not create table from empty column set.'); - } - const subq = []; - for (const datum of data) { - const sel = keys.map(k => `${literalToSQL(datum[k])} AS "${columns[k]}"`); - subq.push(`(SELECT ${sel.join(', ')})`); - } - return subq.join(' UNION ALL '); -} diff --git a/packages/sql/src/load/sql-from.ts b/packages/sql/src/load/sql-from.ts new file mode 100644 index 00000000..5460bff3 --- /dev/null +++ b/packages/sql/src/load/sql-from.ts @@ -0,0 +1,39 @@ +import { literalToSQL } from "../to-sql"; + +/** + * Generates SQL from a set of objects and optionally maps them to new column + * names + * @param data - an array of objects to load + * @param columns - [optional] an array of column names or a map of old column names to new + * @returns + */ +export function sqlFrom( + data: { [key: string]: any }[], + { columns }: { columns?: string[] | { [key: string]: string } } = {} +) { + if (!columns) { + columns = Object.keys(data[0]); + } + let keys: string[] = []; + if (Array.isArray(columns)) { + keys = columns; + columns = keys.reduce( + (m: { [key: string]: any }, k) => ((m[k] = k), m), + {} + ); + } else if (columns) { + keys = Object.keys(columns); + } + if (!keys.length) { + throw new Error("Can not create table from empty column set."); + } + const subq: string[] = []; + const columnMap = columns as { [key: string]: string }; + for (const datum of data) { + const sel = keys.map( + (k) => `${literalToSQL(datum[k])} AS "${columnMap[k]}"` + ); + subq.push(`(SELECT ${sel.join(", ")})`); + } + return subq.join(" UNION ALL "); +} diff --git a/packages/sql/src/operators.js b/packages/sql/src/operators.js deleted file mode 100644 index dda6d9b7..00000000 --- a/packages/sql/src/operators.js +++ /dev/null @@ -1,54 +0,0 @@ -import { sql } from './expression.js'; -import { asColumn } from './ref.js'; - -function visit(callback) { - callback(this.op, this); - this.children?.forEach(v => v.visit(callback)); -} - -function logical(op, clauses) { - const children = clauses.filter(x => x != null).map(asColumn); - const strings = children.map((c, i) => i ? ` ${op} ` : ''); - if (children.length === 1) { - strings.push('') - } else if (children.length > 1) { - strings[0] = '('; - strings.push(')'); - } - return sql(strings, ...children).annotate({ op, children, visit }); -} - -export const and = (...clauses) => logical('AND', clauses.flat()); -export const or = (...clauses) => logical('OR', clauses.flat()); - -const unaryOp = op => a => sql`(${op} ${asColumn(a)})`.annotate({ op, a, visit }); - -export const not = unaryOp('NOT'); - -const unaryPostOp = op => a => sql`(${asColumn(a)} ${op})`.annotate({ op, a, visit }); - -export const isNull = unaryPostOp('IS NULL'); -export const isNotNull = unaryPostOp('IS NOT NULL'); - -const binaryOp = op => (a, b) => sql`(${asColumn(a)} ${op} ${asColumn(b)})`.annotate({ op, a, b, visit }); - -export const eq = binaryOp('='); -export const neq = binaryOp('<>'); -export const lt = binaryOp('<'); -export const gt = binaryOp('>'); -export const lte = binaryOp('<='); -export const gte = binaryOp('>='); -export const isDistinct = binaryOp('IS DISTINCT FROM'); -export const isNotDistinct = binaryOp('IS NOT DISTINCT FROM'); - -function rangeOp(op, a, range, exclusive) { - a = asColumn(a); - const prefix = op.startsWith('NOT ') ? 'NOT ' : ''; - const expr = !range ? sql`` - : exclusive ? sql`${prefix}(${range[0]} <= ${a} AND ${a} < ${range[1]})` - : sql`(${a} ${op} ${range[0]} AND ${range[1]})`; - return expr.annotate({ op, visit, field: a, range }); -} - -export const isBetween = (a, range, exclusive) => rangeOp('BETWEEN', a, range, exclusive); -export const isNotBetween = (a, range, exclusive) => rangeOp('NOT BETWEEN', a, range, exclusive); diff --git a/packages/sql/src/operators.ts b/packages/sql/src/operators.ts new file mode 100644 index 00000000..540f0dbe --- /dev/null +++ b/packages/sql/src/operators.ts @@ -0,0 +1,83 @@ +import { sql } from "./expression"; +import { Ref, asColumn } from "./ref"; + +function visit( + this: { + op: string; + children?: Ref[]; + visit: (callback: (op: string, expr: any) => void) => void; + a?: string | Ref; + b?: string | Ref; + field?: Ref; + range?: string[]; + }, + callback: (op: string, expr: any) => void +) { + callback(this.op, this); + this.children?.forEach((v: any) => v.visit(callback)); +} + +function logical(op: string, clauses: any[]) { + const children = clauses.filter((x) => x != null).map(asColumn); + const strings = children.map((c, i) => (i ? ` ${op} ` : "")); + if (children.length === 1) { + strings.push(""); + } else if (children.length > 1) { + strings[0] = "("; + strings.push(")"); + } + return sql(strings, ...children).annotate({ op, children, visit }); +} + +export const and = (...clauses: any[]) => logical("AND", clauses.flat()); +export const or = (...clauses: any[]) => logical("OR", clauses.flat()); + +const unaryOp = (op: string) => (a: string | Ref) => + sql`(${op} ${asColumn(a)})`.annotate({ op, a, visit }); + +export const not = unaryOp("NOT"); + +const unaryPostOp = (op: string) => (a: string | Ref) => + sql`(${asColumn(a)} ${op})`.annotate({ op, a, visit }); + +export const isNull = unaryPostOp("IS NULL"); +export const isNotNull = unaryPostOp("IS NOT NULL"); + +const binaryOp = (op: string) => (a: any, b: any) => + sql`(${asColumn(a)} ${op} ${asColumn(b)})`.annotate({ op, a, b, visit }); + +export const eq = binaryOp("="); +export const neq = binaryOp("<>"); +export const lt = binaryOp("<"); +export const gt = binaryOp(">"); +export const lte = binaryOp("<="); +export const gte = binaryOp(">="); +export const isDistinct = binaryOp("IS DISTINCT FROM"); +export const isNotDistinct = binaryOp("IS NOT DISTINCT FROM"); + +function rangeOp( + op: string, + a: string | Ref, + range?: [any, any] | null, + exclusive?: boolean +) { + a = asColumn(a); + const prefix = op.startsWith("NOT ") ? "NOT " : ""; + const expr = !range + ? sql`` + : exclusive + ? sql`${prefix}(${range[0]} <= ${a} AND ${a} < ${range[1]})` + : sql`(${a} ${op} ${range[0]} AND ${range[1]})`; + return expr.annotate({ op, visit, field: a, range }); +} + +export const isBetween = ( + a: string | Ref, + range?: [any, any] | null, + exclusive?: boolean +) => rangeOp("BETWEEN", a, range, exclusive); +export const isNotBetween = ( + a: string | Ref, + range?: [any, any] | null, + exclusive?: boolean +) => rangeOp("NOT BETWEEN", a, range, exclusive); diff --git a/packages/sql/src/ref.js b/packages/sql/src/ref.ts similarity index 64% rename from packages/sql/src/ref.js rename to packages/sql/src/ref.ts index 95b788f6..55f3dc10 100644 --- a/packages/sql/src/ref.js +++ b/packages/sql/src/ref.ts @@ -2,12 +2,16 @@ * Class representing a table and/or column reference. */ export class Ref { + table?: string; + column?: string; + label?: string; + /** * Create a new Ref instance. * @param {string|Ref|null} table The table name. * @param {string|null} column The column name. */ - constructor(table, column) { + constructor(table?: string | Ref, column?: string) { if (table) this.table = String(table); if (column) this.column = column; } @@ -27,10 +31,10 @@ export class Ref { toString() { const { table, column } = this; if (column) { - const col = column.startsWith('*') ? column : `"${column}"`; - return `${table ? `${quoteTableName(table)}.` : ''}${col}`; + const col = column.startsWith("*") ? column : `"${column}"`; + return `${table ? `${quoteTableName(table)}.` : ""}${col}`; } else { - return table ? quoteTableName(table) : 'NULL'; + return table ? quoteTableName(table) : "NULL"; } } } @@ -40,9 +44,9 @@ export class Ref { * @param {string} table the name of the table which may contain a database reference * @returns The quoted table name. */ -function quoteTableName(table) { - const pieces = table.split('.'); - return pieces.map(p => `"${p}"`).join('.'); +function quoteTableName(table: string) { + const pieces = table.split("."); + return pieces.map((p) => `"${p}"`).join("."); } /** @@ -52,7 +56,7 @@ function quoteTableName(table) { * @returns {boolean} True if ref is a Ref instance that refers to * the given column name. False otherwise. */ -export function isColumnRefFor(ref, name) { +export function isColumnRefFor(ref: any, name: string): boolean { return ref instanceof Ref && ref.column === name; } @@ -62,8 +66,8 @@ export function isColumnRefFor(ref, name) { * a new column reference will be returned. * @returns {*} A column reference or the input value. */ -export function asColumn(value) { - return typeof value === 'string' ? column(value) : value; +export function asColumn(value: any): any { + return typeof value === "string" ? column(value) : value; } /** @@ -72,8 +76,8 @@ export function asColumn(value) { * a new table (relation) reference will be returned. * @returns {*} A table reference or the input value. */ -export function asRelation(value) { - return typeof value === 'string' ? relation(value) : value; +export function asRelation(value: string | Ref): Ref { + return typeof value === "string" ? relation(value) : value; } /** @@ -81,7 +85,7 @@ export function asRelation(value) { * @param {string} name The table (relation) name. * @returns {Ref} The generated table reference. */ -export function relation(name) { +export function relation(name: string): Ref { return new Ref(name); } @@ -91,12 +95,16 @@ export function relation(name) { * @param {string} column The column name. * @returns {Ref} The generated column reference. */ -export function column(table, column) { - if (arguments.length === 1) { - column = table; - table = null; +export function column(table: string, column: string): Ref; +export function column(column: string): Ref; +export function column(tableOrColumn: string, column?: string): Ref { + if (typeof column === "string") { + // Called with two arguments: table and column + return new Ref(tableOrColumn, column); + } else { + // Called with one argument: column + return new Ref(undefined, tableOrColumn); } - return new Ref(table, column); } /** @@ -104,6 +112,6 @@ export function column(table, column) { * @param {string} table The table name. * @returns {Ref} The generated reference. */ -export function all(table) { - return new Ref(table, '*'); +export function all(table: string): Ref { + return new Ref(table, "*"); } diff --git a/packages/sql/src/repeat.js b/packages/sql/src/repeat.js deleted file mode 100644 index d015dd36..00000000 --- a/packages/sql/src/repeat.js +++ /dev/null @@ -1,3 +0,0 @@ -export function repeat(length, str) { - return Array.from({ length }, () => str); -} diff --git a/packages/sql/src/repeat.ts b/packages/sql/src/repeat.ts new file mode 100644 index 00000000..25774043 --- /dev/null +++ b/packages/sql/src/repeat.ts @@ -0,0 +1,3 @@ +export function repeat(length: number, str: string): string[] { + return Array.from({ length }, () => str); +} diff --git a/packages/sql/src/to-sql.js b/packages/sql/src/to-sql.ts similarity index 76% rename from packages/sql/src/to-sql.js rename to packages/sql/src/to-sql.ts index 07cde018..bdbb5f0e 100644 --- a/packages/sql/src/to-sql.js +++ b/packages/sql/src/to-sql.ts @@ -5,8 +5,8 @@ * @param {*} value The value to convert to SQL. * @returns {string} A SQL string. */ -export function toSQL(value) { - return typeof value === 'string' +export function toSQL(value: string | any) { + return typeof value === "string" ? `"${value}"` // strings as column refs : literalToSQL(value); } @@ -22,25 +22,25 @@ export function toSQL(value) { * @param {*} value The literal value. * @returns {string} A SQL string. */ -export function literalToSQL(value) { +export function literalToSQL(value: any) { switch (typeof value) { - case 'boolean': - return value ? 'TRUE' : 'FALSE'; - case 'string': + case "boolean": + return value ? "TRUE" : "FALSE"; + case "string": return `'${value}'`; - case 'number': - return Number.isFinite(value) ? String(value) : 'NULL'; + case "number": + return Number.isFinite(value) ? String(value) : "NULL"; default: if (value == null) { - return 'NULL'; + return "NULL"; } else if (value instanceof Date) { const ts = +value; - if (Number.isNaN(ts)) return 'NULL'; + if (Number.isNaN(ts)) return "NULL"; const y = value.getUTCFullYear(); const m = value.getUTCMonth(); const d = value.getUTCDate(); return ts === Date.UTC(y, m, d) - ? `MAKE_DATE(${y}, ${m+1}, ${d})` // utc date + ? `MAKE_DATE(${y}, ${m + 1}, ${d})` // utc date : `EPOCH_MS(${ts})`; // timestamp } else if (value instanceof RegExp) { return `'${value.source}'`; diff --git a/packages/sql/src/windows.js b/packages/sql/src/windows.ts similarity index 63% rename from packages/sql/src/windows.js rename to packages/sql/src/windows.ts index 0143eb9b..ca2b86a8 100644 --- a/packages/sql/src/windows.js +++ b/packages/sql/src/windows.ts @@ -1,7 +1,9 @@ -import { SQLExpression, isParamLike, sql } from './expression.js'; -import { functionCall } from './functions.js'; -import { asColumn } from './ref.js'; -import { repeat } from './repeat.js'; +import { Param, SQLExpression, isParamLike, sql } from "./expression"; +import { functionCall } from "./functions"; +import { asColumn, Ref } from "./ref"; +import { repeat } from "./repeat"; + +export type Range = [number | null, number | null]; /** * Base class for individual window functions. @@ -9,93 +11,127 @@ import { repeat } from './repeat.js'; * rather than instantiate this class. */ export class WindowFunction extends SQLExpression { - constructor(op, func, type, name, group = '', order = '', frame = '') { + func: Ref; + type: string | null; + name?: string; + group?: string | SQLExpression; + order?: string | SQLExpression; + frame?: string | SQLExpression; + window: string; + + constructor( + op: string, + func: Ref, + type: string | null = null, + name?: string, + group: string | SQLExpression = "", + order: string | SQLExpression = "", + frame: string | SQLExpression = "" + ) { // build and parse expression let expr; const noWindowParams = !(group || order || frame); if (name && noWindowParams) { expr = name ? sql`${func} OVER "${name}"` : sql`${func} OVER ()`; } else { - const s1 = group && order ? ' ' : ''; - const s2 = (group || order) && frame ? ' ' : ''; - expr = sql`${func} OVER (${name ? `"${name}" ` : ''}${group}${s1}${order}${s2}${frame})`; + const s1 = group && order ? " " : ""; + const s2 = (group || order) && frame ? " " : ""; + expr = sql`${func} OVER (${ + name ? `"${name}" ` : "" + }${group}${s1}${order}${s2}${frame})`; } if (type) { expr = sql`(${expr})::${type}`; } const { _expr, _deps } = expr; - super(_expr, _deps, { window: op, func, type, name, group, order, frame }); + super(_expr, _deps); + this.window = op; + this.func = func; + this.type = type; + this.name = name; + this.group = group; + this.order = order; + this.frame = frame; + this.label = func.label ?? func.toString(); } get basis() { return this.column; } - get label() { - const { func } = this; - return func.label ?? func.toString(); - } - - over(name) { + over(name: string) { const { window: op, func, type, group, order, frame } = this; return new WindowFunction(op, func, type, name, group, order, frame); } - partitionby(...expr) { - const exprs = expr.flat().filter(x => x).map(asColumn); + partitionby(...expr: (string | SQLExpression)[]) { + const exprs = expr + .flat() + .filter((x) => x) + .map(asColumn); const group = sql( - ['PARTITION BY ', repeat(exprs.length - 1, ', '), ''], + ["PARTITION BY ", ...repeat(exprs.length - 1, ", "), ""], ...exprs ); const { window: op, func, type, name, order, frame } = this; return new WindowFunction(op, func, type, name, group, order, frame); } - orderby(...expr) { - const exprs = expr.flat().filter(x => x).map(asColumn); + orderby(...expr: (string | SQLExpression)[]) { + const exprs = expr + .flat() + .filter((x) => x) + .map(asColumn); const order = sql( - ['ORDER BY ', repeat(exprs.length - 1, ', '), ''], + ["ORDER BY ", ...repeat(exprs.length - 1, ", "), ""], ...exprs ); const { window: op, func, type, name, group, frame } = this; return new WindowFunction(op, func, type, name, group, order, frame); } - rows(expr) { - const frame = windowFrame('ROWS', expr); + rows(expr: SQLExpression | Range) { + const frame = windowFrame("ROWS", expr); const { window: op, func, type, name, group, order } = this; return new WindowFunction(op, func, type, name, group, order, frame); } - range(expr) { - const frame = windowFrame('RANGE', expr); + range(expr: SQLExpression | Range) { + const frame = windowFrame("RANGE", expr); const { window: op, func, type, name, group, order } = this; return new WindowFunction(op, func, type, name, group, order, frame); } } -function windowFrame(type, frame) { +function windowFrame(type: string, frame: SQLExpression | Range) { if (isParamLike(frame)) { - const expr = sql`${frame}`; - expr.toString = () => `${type} ${frameToSQL(frame.value)}`; + const expr = sql`${frame as SQLExpression}`; + expr.toString = () => + `${type} ${frameToSQL((frame as unknown as Param).value)}`; return expr; } - return `${type} ${frameToSQL(frame)}`; + return `${type} ${frameToSQL(frame as Range)}`; } -function frameToSQL(frame) { +function frameToSQL(frame: Range) { const [prev, next] = frame; - const a = prev === 0 ? 'CURRENT ROW' - : Number.isFinite(prev) ? `${Math.abs(prev)} PRECEDING` - : 'UNBOUNDED PRECEDING'; - const b = next === 0 ? 'CURRENT ROW' - : Number.isFinite(next) ? `${Math.abs(next)} FOLLOWING` - : 'UNBOUNDED FOLLOWING'; + const a = + prev === 0 + ? "CURRENT ROW" + : Number.isFinite(prev) + ? `${Math.abs(prev!)} PRECEDING` + : "UNBOUNDED PRECEDING"; + const b = + next === 0 + ? "CURRENT ROW" + : Number.isFinite(next) + ? `${Math.abs(next!)} FOLLOWING` + : "UNBOUNDED FOLLOWING"; return `BETWEEN ${a} AND ${b}`; } -export function winf(op, type) { - return (...values) => { +export function winf(op: string, type?: string) { + return (...values: any[]) => { const func = functionCall(op)(...values); return new WindowFunction(op, func, type); }; @@ -106,35 +142,35 @@ export function winf(op, type) { * within the partition, counting from 1. * @returns {WindowFunction} The generated window function. */ -export const row_number = winf('ROW_NUMBER', 'INTEGER'); +export const row_number = winf("ROW_NUMBER", "INTEGER"); /** * Create a window function that returns the rank of the current row with gaps. * This is the same as the row_number of its first peer. * @returns {WindowFunction} The generated window function. */ -export const rank = winf('RANK', 'INTEGER'); +export const rank = winf("RANK", "INTEGER"); /** * Create a window function that returns the rank of the current row without gaps, * The function counts peer groups. * @returns {WindowFunction} The generated window function. */ -export const dense_rank = winf('DENSE_RANK', 'INTEGER'); +export const dense_rank = winf("DENSE_RANK", "INTEGER"); /** * Create a window function that returns the relative rank of the current row. * (rank() - 1) / (total partition rows - 1). * @returns {WindowFunction} The generated window function. */ -export const percent_rank = winf('PERCENT_RANK'); +export const percent_rank = winf("PERCENT_RANK"); /** * Create a window function that returns the cumulative distribution. * (number of preceding or peer partition rows) / total partition rows. * @returns {WindowFunction} The generated window function. */ -export const cume_dist = winf('CUME_DIST'); +export const cume_dist = winf("CUME_DIST"); /** * Create a window function that r an integer ranging from 1 to the argument @@ -142,7 +178,7 @@ export const cume_dist = winf('CUME_DIST'); * @param {number|SQLExpression} num_buckets The number of quantile buckets. * @returns {WindowFunction} The generated window function. */ -export const ntile = winf('NTILE'); +export const ntile = winf("NTILE"); /** * Create a window function that returns the expression evaluated at the row @@ -155,7 +191,7 @@ export const ntile = winf('NTILE'); * @param {*} default The default value. * @returns {WindowFunction} The generated window function. */ -export const lag = winf('LAG'); +export const lag = winf("LAG"); /** * Create a window function that returns the expression evaluated at the row @@ -168,7 +204,7 @@ export const lag = winf('LAG'); * @param {*} default The default value. * @returns {WindowFunction} The generated window function. */ -export const lead = winf('LEAD'); +export const lead = winf("LEAD"); /** * Create a window function that returns the expression evaluated at the row @@ -176,7 +212,7 @@ export const lead = winf('LEAD'); * @param {string|SQLExpression} expr The expression to evaluate. * @returns {WindowFunction} The generated window function. */ -export const first_value = winf('FIRST_VALUE'); +export const first_value = winf("FIRST_VALUE"); /** * Create a window function that returns the expression evaluated at the row @@ -185,7 +221,7 @@ export const first_value = winf('FIRST_VALUE'); * @returns {WindowFunction} The generated window function. */ -export const last_value = winf('LAST_VALUE'); +export const last_value = winf("LAST_VALUE"); /** * Create a window function that returns the expression evaluated at the @@ -194,4 +230,4 @@ export const last_value = winf('LAST_VALUE'); * @param {number|SQLExpression} nth The 1-based window frame index. * @returns {WindowFunction} The generated window function. */ -export const nth_value = winf('NTH_VALUE'); +export const nth_value = winf("NTH_VALUE"); diff --git a/packages/sql/test/aggregate-test.js b/packages/sql/test/aggregate-test.js deleted file mode 100644 index a06c7acf..00000000 --- a/packages/sql/test/aggregate-test.js +++ /dev/null @@ -1,166 +0,0 @@ -import assert from 'node:assert'; -import { stubParam } from './stub-param.js'; -import { - agg, column, isSQLExpression, isParamLike, - argmax, argmin, arrayAgg, avg, corr, count, covarPop, entropy, first, - kurtosis, mean, mad, max, median, min, mode, last, product, quantile, - regrAvgX, regrAvgY, regrCount, regrIntercept, regrR2, regrSXX, regrSXY, - regrSYY, regrSlope, skewness, stddev, stddevPop, stringAgg, sum, - variance, varPop, sql -} from '../src/index.js'; - -describe('agg template tag', () => { - it('creates aggregate SQL expressions', () => { - const expr = agg`SUM(${column('foo')})`; - assert.ok(isSQLExpression(expr)); - assert.ok(!isParamLike(expr)); - assert.strictEqual(String(expr), 'SUM("foo")'); - assert.strictEqual(expr.column, 'foo'); - assert.deepStrictEqual(expr.columns, ['foo']); - }); - - it('creates parameterized aggregate SQL expressions', () => { - const col = stubParam(column('bar')); - assert.ok(isParamLike(col)); - - const expr = agg`SUM(${col})`; - assert.ok(isSQLExpression(expr)); - assert.ok(isParamLike(expr)); - assert.strictEqual(String(expr), 'SUM("bar")'); - assert.strictEqual(expr.column, 'bar'); - assert.deepStrictEqual(expr.columns, ['bar']); - - expr.addEventListener('value', value => { - assert.ok(isSQLExpression(value)); - assert.strictEqual(String(expr), `${value}`); - }); - col.update(column('baz')); - assert.strictEqual(String(expr), 'SUM("baz")'); - assert.strictEqual(expr.column, 'baz'); - assert.deepStrictEqual(expr.columns, ['baz']); - }); -}); - -describe('Aggregate functions', () => { - it('expose metadata', () => { - const expr = sum(column('foo')); - assert.strictEqual(expr.aggregate, 'SUM'); - assert.strictEqual(expr.column, 'foo'); - assert.deepStrictEqual(expr.columns, ['foo']); - }); - it('support distinct', () => { - const expr = count(column('foo')).distinct(); - assert.strictEqual(expr.aggregate, 'COUNT'); - assert.strictEqual(expr.isDistinct, true); - assert.strictEqual(expr.type, 'INTEGER'); - assert.strictEqual(String(expr), 'COUNT(DISTINCT "foo")::INTEGER'); - }); - it('support filter', () => { - const foo = column('foo'); - const expr = avg(foo).where(sql`${foo} > 5`); - assert.strictEqual(String(expr), 'AVG("foo") FILTER (WHERE "foo" > 5)'); - }); - it('include ARG_MAX', () => { - assert.strictEqual(String(argmax('foo', 'bar')), 'ARG_MAX("foo", "bar")'); - }); - it('include ARG_MIN', () => { - assert.strictEqual(String(argmin('foo', 'bar')), 'ARG_MIN("foo", "bar")'); - }); - it('include ARRAY_AGG', () => { - assert.strictEqual(String(arrayAgg('foo')), 'ARRAY_AGG("foo")'); - }); - it('include AVG', () => { - assert.strictEqual(String(avg('foo')), 'AVG("foo")'); - assert.strictEqual(String(mean('foo')), 'AVG("foo")'); - }); - it('include CORR', () => { - assert.strictEqual(String(corr('foo', 'bar')), 'CORR("foo", "bar")'); - }); - it('include COUNT', () => { - assert.strictEqual(String(count()), 'COUNT(*)::INTEGER'); - }); - it('include COVAR_POP', () => { - assert.strictEqual(String(covarPop('foo', 'bar')), 'COVAR_POP("foo", "bar")'); - }); - it('include ENTROPY', () => { - assert.strictEqual(String(entropy('foo')), 'ENTROPY("foo")'); - }); - it('include FIRST', () => { - assert.strictEqual(String(first('foo')), 'FIRST("foo")'); - }); - it('include KURTOSIS', () => { - assert.strictEqual(String(kurtosis('foo')), 'KURTOSIS("foo")'); - }); - it('include MAD', () => { - assert.strictEqual(String(mad('foo')), 'MAD("foo")'); - }); - it('include MAX', () => { - assert.strictEqual(String(max('foo')), 'MAX("foo")'); - }); - it('include MEDIAN', () => { - assert.strictEqual(String(median('foo')), 'MEDIAN("foo")'); - }); - it('include MIN', () => { - assert.strictEqual(String(min('foo')), 'MIN("foo")'); - }); - it('include MODE', () => { - assert.strictEqual(String(mode('foo')), 'MODE("foo")'); - }); - it('include LAST', () => { - assert.strictEqual(String(last('foo')), 'LAST("foo")'); - }); - it('include PRODUCT', () => { - assert.strictEqual(String(product('foo')), 'PRODUCT("foo")'); - }); - it('include QUANTILE', () => { - assert.strictEqual(String(quantile('foo', 0.25)), 'QUANTILE("foo", 0.25)'); - }); - it('include REGR_AVGX', () => { - assert.strictEqual(String(regrAvgX('x', 'y')), 'REGR_AVGX("x", "y")'); - }); - it('include REGR_AVGY', () => { - assert.strictEqual(String(regrAvgY('x', 'y')), 'REGR_AVGY("x", "y")'); - }); - it('include REGR_COUNT', () => { - assert.strictEqual(String(regrCount('x', 'y')), 'REGR_COUNT("x", "y")'); - }); - it('include REGR_INTERCEPT', () => { - assert.strictEqual(String(regrIntercept('x', 'y')), 'REGR_INTERCEPT("x", "y")'); - }); - it('include REGR_R2', () => { - assert.strictEqual(String(regrR2('x', 'y')), 'REGR_R2("x", "y")'); - }); - it('include REGR_SXX', () => { - assert.strictEqual(String(regrSXX('x', 'y')), 'REGR_SXX("x", "y")'); - }); - it('include REGR_SXY', () => { - assert.strictEqual(String(regrSXY('x', 'y')), 'REGR_SXY("x", "y")'); - }); - it('include REGR_SYY', () => { - assert.strictEqual(String(regrSYY('x', 'y')), 'REGR_SYY("x", "y")'); - }); - it('include REGR_SLOPE', () => { - assert.strictEqual(String(regrSlope('x', 'y')), 'REGR_SLOPE("x", "y")'); - }); - it('include SKEWNESS', () => { - assert.strictEqual(String(skewness('foo')), 'SKEWNESS("foo")'); - }); - it('include STDDEV', () => { - assert.strictEqual(String(stddev('foo')), 'STDDEV("foo")'); - }); - it('include STDDEV_POP', () => { - assert.strictEqual(String(stddevPop('foo')), 'STDDEV_POP("foo")'); - }); - it('include STRING_AGG', () => { - assert.strictEqual(String(stringAgg('foo')), 'STRING_AGG("foo")'); - }); - it('include SUM', () => { - assert.strictEqual(String(sum('foo')), 'SUM("foo")::DOUBLE'); - }); - it('include VARIANCE', () => { - assert.strictEqual(String(variance('foo')), 'VARIANCE("foo")'); - }); - it('include VAR_POP', () => { - assert.strictEqual(String(varPop('foo')), 'VAR_POP("foo")'); - }); -}); diff --git a/packages/sql/test/aggregate-test.ts b/packages/sql/test/aggregate-test.ts new file mode 100644 index 00000000..b0859392 --- /dev/null +++ b/packages/sql/test/aggregate-test.ts @@ -0,0 +1,206 @@ +import assert from "node:assert"; +import { stubParam } from "./stub-param"; +import { + agg, + column, + isSQLExpression, + isParamLike, + argmax, + argmin, + arrayAgg, + avg, + corr, + count, + covarPop, + entropy, + first, + kurtosis, + mean, + mad, + max, + median, + min, + mode, + last, + product, + quantile, + regrAvgX, + regrAvgY, + regrCount, + regrIntercept, + regrR2, + regrSXX, + regrSXY, + regrSYY, + regrSlope, + skewness, + stddev, + stddevPop, + stringAgg, + sum, + variance, + varPop, + sql, +} from "../src/index"; + +describe("agg template tag", () => { + it("creates aggregate SQL expressions", () => { + const expr = agg`SUM(${column("foo")})`; + assert.ok(isSQLExpression(expr)); + assert.ok(!isParamLike(expr)); + assert.strictEqual(String(expr), 'SUM("foo")'); + assert.strictEqual(expr.column, "foo"); + assert.deepStrictEqual(expr.columns, ["foo"]); + }); + + it("creates parameterized aggregate SQL expressions", () => { + const col = stubParam(column("bar")); + assert.ok(isParamLike(col)); + + const expr = agg`SUM(${col})`; + assert.ok(isSQLExpression(expr)); + assert.ok(isParamLike(expr)); + assert.strictEqual(String(expr), 'SUM("bar")'); + assert.strictEqual(expr.column, "bar"); + assert.deepStrictEqual(expr.columns, ["bar"]); + + expr.addEventListener("value", (value: any) => { + assert.ok(isSQLExpression(value)); + assert.strictEqual(String(expr), `${value}`); + }); + col.update(column("baz")); + assert.strictEqual(String(expr), 'SUM("baz")'); + assert.strictEqual(expr.column, "baz"); + assert.deepStrictEqual(expr.columns, ["baz"]); + }); +}); + +describe("Aggregate functions", () => { + it("expose metadata", () => { + const expr = sum(column("foo")); + assert.strictEqual(expr.aggregate, "SUM"); + assert.strictEqual(expr.column, "foo"); + assert.deepStrictEqual(expr.columns, ["foo"]); + }); + it("support distinct", () => { + const expr = count(column("foo")).distinct(); + assert.strictEqual(expr.aggregate, "COUNT"); + assert.strictEqual(expr.isDistinct, true); + assert.strictEqual(expr.type, "INTEGER"); + assert.strictEqual(String(expr), 'COUNT(DISTINCT "foo")::INTEGER'); + }); + it("support filter", () => { + const foo = column("foo"); + const expr = avg(foo).where(sql`${foo} > 5`); + assert.strictEqual(String(expr), 'AVG("foo") FILTER (WHERE "foo" > 5)'); + }); + it("include ARG_MAX", () => { + assert.strictEqual(String(argmax("foo", "bar")), 'ARG_MAX("foo", "bar")'); + }); + it("include ARG_MIN", () => { + assert.strictEqual(String(argmin("foo", "bar")), 'ARG_MIN("foo", "bar")'); + }); + it("include ARRAY_AGG", () => { + assert.strictEqual(String(arrayAgg("foo")), 'ARRAY_AGG("foo")'); + }); + it("include AVG", () => { + assert.strictEqual(String(avg("foo")), 'AVG("foo")'); + assert.strictEqual(String(mean("foo")), 'AVG("foo")'); + }); + it("include CORR", () => { + assert.strictEqual(String(corr("foo", "bar")), 'CORR("foo", "bar")'); + }); + it("include COUNT", () => { + assert.strictEqual(String(count()), "COUNT(*)::INTEGER"); + }); + it("include COVAR_POP", () => { + assert.strictEqual( + String(covarPop("foo", "bar")), + 'COVAR_POP("foo", "bar")' + ); + }); + it("include ENTROPY", () => { + assert.strictEqual(String(entropy("foo")), 'ENTROPY("foo")'); + }); + it("include FIRST", () => { + assert.strictEqual(String(first("foo")), 'FIRST("foo")'); + }); + it("include KURTOSIS", () => { + assert.strictEqual(String(kurtosis("foo")), 'KURTOSIS("foo")'); + }); + it("include MAD", () => { + assert.strictEqual(String(mad("foo")), 'MAD("foo")'); + }); + it("include MAX", () => { + assert.strictEqual(String(max("foo")), 'MAX("foo")'); + }); + it("include MEDIAN", () => { + assert.strictEqual(String(median("foo")), 'MEDIAN("foo")'); + }); + it("include MIN", () => { + assert.strictEqual(String(min("foo")), 'MIN("foo")'); + }); + it("include MODE", () => { + assert.strictEqual(String(mode("foo")), 'MODE("foo")'); + }); + it("include LAST", () => { + assert.strictEqual(String(last("foo")), 'LAST("foo")'); + }); + it("include PRODUCT", () => { + assert.strictEqual(String(product("foo")), 'PRODUCT("foo")'); + }); + it("include QUANTILE", () => { + assert.strictEqual(String(quantile("foo", 0.25)), 'QUANTILE("foo", 0.25)'); + }); + it("include REGR_AVGX", () => { + assert.strictEqual(String(regrAvgX("x", "y")), 'REGR_AVGX("x", "y")'); + }); + it("include REGR_AVGY", () => { + assert.strictEqual(String(regrAvgY("x", "y")), 'REGR_AVGY("x", "y")'); + }); + it("include REGR_COUNT", () => { + assert.strictEqual(String(regrCount("x", "y")), 'REGR_COUNT("x", "y")'); + }); + it("include REGR_INTERCEPT", () => { + assert.strictEqual( + String(regrIntercept("x", "y")), + 'REGR_INTERCEPT("x", "y")' + ); + }); + it("include REGR_R2", () => { + assert.strictEqual(String(regrR2("x", "y")), 'REGR_R2("x", "y")'); + }); + it("include REGR_SXX", () => { + assert.strictEqual(String(regrSXX("x", "y")), 'REGR_SXX("x", "y")'); + }); + it("include REGR_SXY", () => { + assert.strictEqual(String(regrSXY("x", "y")), 'REGR_SXY("x", "y")'); + }); + it("include REGR_SYY", () => { + assert.strictEqual(String(regrSYY("x", "y")), 'REGR_SYY("x", "y")'); + }); + it("include REGR_SLOPE", () => { + assert.strictEqual(String(regrSlope("x", "y")), 'REGR_SLOPE("x", "y")'); + }); + it("include SKEWNESS", () => { + assert.strictEqual(String(skewness("foo")), 'SKEWNESS("foo")'); + }); + it("include STDDEV", () => { + assert.strictEqual(String(stddev("foo")), 'STDDEV("foo")'); + }); + it("include STDDEV_POP", () => { + assert.strictEqual(String(stddevPop("foo")), 'STDDEV_POP("foo")'); + }); + it("include STRING_AGG", () => { + assert.strictEqual(String(stringAgg("foo")), 'STRING_AGG("foo")'); + }); + it("include SUM", () => { + assert.strictEqual(String(sum("foo")), 'SUM("foo")::DOUBLE'); + }); + it("include VARIANCE", () => { + assert.strictEqual(String(variance("foo")), 'VARIANCE("foo")'); + }); + it("include VAR_POP", () => { + assert.strictEqual(String(varPop("foo")), 'VAR_POP("foo")'); + }); +}); diff --git a/packages/sql/test/cast-test.js b/packages/sql/test/cast-test.js deleted file mode 100644 index 76dc974c..00000000 --- a/packages/sql/test/cast-test.js +++ /dev/null @@ -1,38 +0,0 @@ -import assert from 'node:assert'; -import { avg, cast, castDouble, castInteger, column } from '../src/index.js'; - -describe('cast', () => { - it('performs type casts', () => { - assert.strictEqual(String(cast('foo', 'DOUBLE')), 'CAST("foo" AS DOUBLE)'); - assert.strictEqual(String(cast(column('foo'), 'DOUBLE')), 'CAST("foo" AS DOUBLE)'); - - const expr = cast(avg('bar'), 'DOUBLE'); - assert.strictEqual(String(expr), 'CAST(AVG("bar") AS DOUBLE)'); - assert.strictEqual(expr.aggregate, 'AVG'); - assert.strictEqual(expr.column, 'bar'); - assert.deepStrictEqual(expr.columns, ['bar']); - assert.strictEqual(expr.label, 'avg(bar)'); - }); - it('performs double casts', () => { - assert.strictEqual(String(castDouble('foo')), 'CAST("foo" AS DOUBLE)'); - assert.strictEqual(String(castDouble(column('foo'))), 'CAST("foo" AS DOUBLE)'); - - const expr = castDouble(avg('bar')); - assert.strictEqual(String(expr), 'CAST(AVG("bar") AS DOUBLE)'); - assert.strictEqual(expr.aggregate, 'AVG'); - assert.strictEqual(expr.column, 'bar'); - assert.deepStrictEqual(expr.columns, ['bar']); - assert.strictEqual(expr.label, 'avg(bar)'); - }); - it('performs integer casts', () => { - assert.strictEqual(String(castInteger('foo')), 'CAST("foo" AS INTEGER)'); - assert.strictEqual(String(castInteger(column('foo'))), 'CAST("foo" AS INTEGER)'); - - const expr = castInteger(avg('bar')); - assert.strictEqual(String(expr), 'CAST(AVG("bar") AS INTEGER)'); - assert.strictEqual(expr.aggregate, 'AVG'); - assert.strictEqual(expr.column, 'bar'); - assert.deepStrictEqual(expr.columns, ['bar']); - assert.strictEqual(expr.label, 'avg(bar)'); - }); -}); diff --git a/packages/sql/test/cast-test.ts b/packages/sql/test/cast-test.ts new file mode 100644 index 00000000..21946b43 --- /dev/null +++ b/packages/sql/test/cast-test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert"; +import { avg, cast, castDouble, castInteger, column } from "../src/index"; + +describe("cast", () => { + it("performs type casts", () => { + assert.strictEqual(String(cast("foo", "DOUBLE")), 'CAST("foo" AS DOUBLE)'); + assert.strictEqual( + String(cast(column("foo"), "DOUBLE")), + 'CAST("foo" AS DOUBLE)' + ); + + const expr = cast(avg("bar"), "DOUBLE"); + assert.strictEqual(String(expr), 'CAST(AVG("bar") AS DOUBLE)'); + assert.strictEqual(expr.aggregate, "AVG"); + assert.strictEqual(expr.column, "bar"); + assert.deepStrictEqual(expr.columns, ["bar"]); + assert.strictEqual(expr.label, "avg(bar)"); + }); + it("performs double casts", () => { + assert.strictEqual(String(castDouble("foo")), 'CAST("foo" AS DOUBLE)'); + assert.strictEqual( + String(castDouble(column("foo"))), + 'CAST("foo" AS DOUBLE)' + ); + + const expr = castDouble(avg("bar")); + assert.strictEqual(String(expr), 'CAST(AVG("bar") AS DOUBLE)'); + assert.strictEqual(expr.aggregate, "AVG"); + assert.strictEqual(expr.column, "bar"); + assert.deepStrictEqual(expr.columns, ["bar"]); + assert.strictEqual(expr.label, "avg(bar)"); + }); + it("performs integer casts", () => { + assert.strictEqual(String(castInteger("foo")), 'CAST("foo" AS INTEGER)'); + assert.strictEqual( + String(castInteger(column("foo"))), + 'CAST("foo" AS INTEGER)' + ); + + const expr = castInteger(avg("bar")); + assert.strictEqual(String(expr), 'CAST(AVG("bar") AS INTEGER)'); + assert.strictEqual(expr.aggregate, "AVG"); + assert.strictEqual(expr.column, "bar"); + assert.deepStrictEqual(expr.columns, ["bar"]); + assert.strictEqual(expr.label, "avg(bar)"); + }); +}); diff --git a/packages/sql/test/desc-test.js b/packages/sql/test/desc-test.js deleted file mode 100644 index 4ccc50cf..00000000 --- a/packages/sql/test/desc-test.js +++ /dev/null @@ -1,27 +0,0 @@ -import assert from 'node:assert'; -import { stubParam } from './stub-param.js'; -import { column, desc, isSQLExpression, isParamLike } from '../src/index.js'; - -describe('desc', () => { - it('creates descending order annotations', () => { - const expr = desc('foo'); - assert.ok(isSQLExpression(expr)); - assert.ok(!isParamLike(expr)); - assert.strictEqual(String(expr), '"foo" DESC NULLS LAST'); - assert.strictEqual(expr.column, 'foo'); - assert.deepStrictEqual(expr.columns, ['foo']); - - const param = stubParam(column('bar')); - const expr2 = desc(param); - assert.ok(isSQLExpression(expr2)); - assert.ok(isParamLike(expr2)); - assert.strictEqual(String(expr2), '"bar" DESC NULLS LAST'); - - expr2.addEventListener('value', value => { - assert.ok(isSQLExpression(value)); - assert.strictEqual(String(expr2), `${value}`); - }); - param.update(column('baz')); - assert.strictEqual(String(expr2), '"baz" DESC NULLS LAST'); - }); -}); diff --git a/packages/sql/test/desc-test.ts b/packages/sql/test/desc-test.ts new file mode 100644 index 00000000..3c2a9e85 --- /dev/null +++ b/packages/sql/test/desc-test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert"; +import { stubParam } from "./stub-param"; +import { column, desc, isSQLExpression, isParamLike } from "../src/index"; +import { SQLExpression } from "../src/expression"; + +describe("desc", () => { + it("creates descending order annotations", () => { + const expr = desc("foo"); + assert.ok(isSQLExpression(expr)); + assert.ok(!isParamLike(expr)); + assert.strictEqual(String(expr), '"foo" DESC NULLS LAST'); + assert.strictEqual(expr.column, "foo"); + assert.deepStrictEqual(expr.columns, ["foo"]); + + const param = stubParam(column("bar")); + const expr2 = desc(param as unknown as SQLExpression); + assert.ok(isSQLExpression(expr2)); + assert.ok(isParamLike(expr2)); + assert.strictEqual(String(expr2), '"bar" DESC NULLS LAST'); + + expr2.addEventListener!("value", (value) => { + assert.ok(isSQLExpression(value)); + assert.strictEqual(String(expr2), `${value}`); + }); + param.update(column("baz")); + assert.strictEqual(String(expr2), '"baz" DESC NULLS LAST'); + }); +}); diff --git a/packages/sql/test/expression-test.js b/packages/sql/test/expression-test.ts similarity index 52% rename from packages/sql/test/expression-test.js rename to packages/sql/test/expression-test.ts index 936a8ee1..ae19f017 100644 --- a/packages/sql/test/expression-test.js +++ b/packages/sql/test/expression-test.ts @@ -1,48 +1,48 @@ -import assert from 'node:assert'; -import { stubParam } from './stub-param.js'; -import { column, isSQLExpression, isParamLike, sql } from '../src/index.js'; +import assert from "node:assert"; +import { stubParam } from "./stub-param"; +import { column, isSQLExpression, isParamLike, sql } from "../src/index"; -describe('sql template tag', () => { - it('creates basic SQL expressions', () => { +describe("sql template tag", () => { + it("creates basic SQL expressions", () => { const expr = sql`1 + 1`; assert.ok(isSQLExpression(expr)); assert.ok(!isParamLike(expr)); - assert.strictEqual(String(expr), '1 + 1'); + assert.strictEqual(String(expr), "1 + 1"); assert.strictEqual(expr.column, undefined); assert.deepStrictEqual(expr.columns, []); }); - it('creates interpolated SQL expressions', () => { - const expr = sql`${column('foo')} * ${column('bar')}`; + it("creates interpolated SQL expressions", () => { + const expr = sql`${column("foo")} * ${column("bar")}`; assert.ok(isSQLExpression(expr)); assert.ok(!isParamLike(expr)); assert.strictEqual(String(expr), '"foo" * "bar"'); - assert.strictEqual(expr.column, 'foo'); - assert.deepStrictEqual(expr.columns, ['foo', 'bar']); + assert.strictEqual(expr.column, "foo"); + assert.deepStrictEqual(expr.columns, ["foo", "bar"]); }); - it('creates nested SQL expressions', () => { - const base = sql`${column('foo')} * 4`; + it("creates nested SQL expressions", () => { + const base = sql`${column("foo")} * 4`; const expr = sql`${base} + 1`; assert.ok(isSQLExpression(expr)); assert.strictEqual(String(expr), '"foo" * 4 + 1'); assert.ok(!isParamLike(expr)); - assert.strictEqual(expr.column, 'foo'); - assert.deepStrictEqual(expr.columns, ['foo']); + assert.strictEqual(expr.column, "foo"); + assert.deepStrictEqual(expr.columns, ["foo"]); }); - it('creates parameterized SQL expressions', () => { + it("creates parameterized SQL expressions", () => { const param = stubParam(4); assert.ok(isParamLike(param)); - const expr = sql`${column('foo')} * ${param}`; + const expr = sql`${column("foo")} * ${param}`; assert.ok(isSQLExpression(expr)); assert.strictEqual(String(expr), '"foo" * 4'); assert.ok(isParamLike(expr)); - assert.strictEqual(expr.column, 'foo'); - assert.deepStrictEqual(expr.columns, ['foo']); + assert.strictEqual(expr.column, "foo"); + assert.deepStrictEqual(expr.columns, ["foo"]); - expr.addEventListener('value', value => { + expr.addEventListener!("value", (value) => { assert.ok(isSQLExpression(value)); assert.strictEqual(String(expr), `${value}`); }); @@ -50,19 +50,19 @@ describe('sql template tag', () => { assert.strictEqual(String(expr), '"foo" * 5'); }); - it('creates nested parameterized SQL expressions', () => { + it("creates nested parameterized SQL expressions", () => { const param = stubParam(4); assert.ok(isParamLike(param)); - const base = sql`${column('foo')} * ${param}`; + const base = sql`${column("foo")} * ${param}`; const expr = sql`${base} + 1`; assert.ok(isSQLExpression(expr)); assert.strictEqual(String(expr), '"foo" * 4 + 1'); assert.ok(isParamLike(expr)); - assert.strictEqual(expr.column, 'foo'); - assert.deepStrictEqual(expr.columns, ['foo']); + assert.strictEqual(expr.column, "foo"); + assert.deepStrictEqual(expr.columns, ["foo"]); - expr.addEventListener('value', value => { + expr.addEventListener!("value", (value) => { assert.ok(isSQLExpression(value)); assert.strictEqual(String(expr), `${value}`); }); diff --git a/packages/sql/test/function-test.js b/packages/sql/test/function-test.js deleted file mode 100644 index 89f41833..00000000 --- a/packages/sql/test/function-test.js +++ /dev/null @@ -1,78 +0,0 @@ -import assert from 'node:assert'; -import { - contains, isFinite, isInfinite, isNaN, literal, length, - lower, prefix, regexp_matches, suffix, upper -} from '../src/index.js'; - -describe('Function calls', () => { - it('includes REGEXP_MATCHES', () => { - const expr = regexp_matches('foo', literal('(an)*')) - assert.strictEqual(String(expr), `REGEXP_MATCHES("foo", '(an)*')`); - assert.strictEqual(expr.func, 'REGEXP_MATCHES'); - assert.strictEqual(expr.args.length, 2); - assert.deepStrictEqual(expr.columns, ['foo']); - }); - it('includes CONTAINS', () => { - const expr = contains('foo', literal('oo')) - assert.strictEqual(String(expr), `CONTAINS("foo", 'oo')`); - assert.strictEqual(expr.func, 'CONTAINS'); - assert.strictEqual(expr.args.length, 2); - assert.deepStrictEqual(expr.columns, ['foo']); - }); - it('includes PREFIX', () => { - const expr = prefix('foo', literal('fo')) - assert.strictEqual(String(expr), `PREFIX("foo", 'fo')`); - assert.strictEqual(expr.func, 'PREFIX'); - assert.strictEqual(expr.args.length, 2); - assert.deepStrictEqual(expr.columns, ['foo']); - }); - it('includes SUFFIX', () => { - const expr = suffix('foo', literal('oo')) - assert.strictEqual(String(expr), `SUFFIX("foo", 'oo')`); - assert.strictEqual(expr.func, 'SUFFIX'); - assert.strictEqual(expr.args.length, 2); - assert.deepStrictEqual(expr.columns, ['foo']); - }); - it('includes LOWER', () => { - const expr = lower('foo') - assert.strictEqual(String(expr), `LOWER("foo")`); - assert.strictEqual(expr.func, 'LOWER'); - assert.strictEqual(expr.args.length, 1); - assert.deepStrictEqual(expr.columns, ['foo']); - }); - it('includes UPPER', () => { - const expr = upper('foo') - assert.strictEqual(String(expr), `UPPER("foo")`); - assert.strictEqual(expr.func, 'UPPER'); - assert.strictEqual(expr.args.length, 1); - assert.deepStrictEqual(expr.columns, ['foo']); - }); - it('includes LENGTH', () => { - const expr = length('foo') - assert.strictEqual(String(expr), `LENGTH("foo")`); - assert.strictEqual(expr.func, 'LENGTH'); - assert.strictEqual(expr.args.length, 1); - assert.deepStrictEqual(expr.columns, ['foo']); - }); - it('includes ISNAN', () => { - const expr = isNaN('foo') - assert.strictEqual(String(expr), `ISNAN("foo")`); - assert.strictEqual(expr.func, 'ISNAN'); - assert.strictEqual(expr.args.length, 1); - assert.deepStrictEqual(expr.columns, ['foo']); - }); - it('includes ISFINITE', () => { - const expr = isFinite('foo') - assert.strictEqual(String(expr), `ISFINITE("foo")`); - assert.strictEqual(expr.func, 'ISFINITE'); - assert.strictEqual(expr.args.length, 1); - assert.deepStrictEqual(expr.columns, ['foo']); - }); - it('includes ISINF', () => { - const expr = isInfinite('foo') - assert.strictEqual(String(expr), `ISINF("foo")`); - assert.strictEqual(expr.func, 'ISINF'); - assert.strictEqual(expr.args.length, 1); - assert.deepStrictEqual(expr.columns, ['foo']); - }); -}); diff --git a/packages/sql/test/function-test.ts b/packages/sql/test/function-test.ts new file mode 100644 index 00000000..bdd5eb8d --- /dev/null +++ b/packages/sql/test/function-test.ts @@ -0,0 +1,87 @@ +import assert from "node:assert"; +import { + contains, + isFinite, + isInfinite, + isNaN, + literal, + length, + lower, + prefix, + regexp_matches, + suffix, + upper, +} from "../src/index"; + +describe("Function calls", () => { + it("includes REGEXP_MATCHES", () => { + const expr = regexp_matches("foo", literal("(an)*")); + assert.strictEqual(String(expr), `REGEXP_MATCHES("foo", '(an)*')`); + assert.strictEqual(expr.func, "REGEXP_MATCHES"); + assert.strictEqual(expr.args.length, 2); + assert.deepStrictEqual(expr.columns, ["foo"]); + }); + it("includes CONTAINS", () => { + const expr = contains("foo", literal("oo")); + assert.strictEqual(String(expr), `CONTAINS("foo", 'oo')`); + assert.strictEqual(expr.func, "CONTAINS"); + assert.strictEqual(expr.args.length, 2); + assert.deepStrictEqual(expr.columns, ["foo"]); + }); + it("includes PREFIX", () => { + const expr = prefix("foo", literal("fo")); + assert.strictEqual(String(expr), `PREFIX("foo", 'fo')`); + assert.strictEqual(expr.func, "PREFIX"); + assert.strictEqual(expr.args.length, 2); + assert.deepStrictEqual(expr.columns, ["foo"]); + }); + it("includes SUFFIX", () => { + const expr = suffix("foo", literal("oo")); + assert.strictEqual(String(expr), `SUFFIX("foo", 'oo')`); + assert.strictEqual(expr.func, "SUFFIX"); + assert.strictEqual(expr.args.length, 2); + assert.deepStrictEqual(expr.columns, ["foo"]); + }); + it("includes LOWER", () => { + const expr = lower("foo"); + assert.strictEqual(String(expr), `LOWER("foo")`); + assert.strictEqual(expr.func, "LOWER"); + assert.strictEqual(expr.args.length, 1); + assert.deepStrictEqual(expr.columns, ["foo"]); + }); + it("includes UPPER", () => { + const expr = upper("foo"); + assert.strictEqual(String(expr), `UPPER("foo")`); + assert.strictEqual(expr.func, "UPPER"); + assert.strictEqual(expr.args.length, 1); + assert.deepStrictEqual(expr.columns, ["foo"]); + }); + it("includes LENGTH", () => { + const expr = length("foo"); + assert.strictEqual(String(expr), `LENGTH("foo")`); + assert.strictEqual(expr.func, "LENGTH"); + assert.strictEqual(expr.args.length, 1); + assert.deepStrictEqual(expr.columns, ["foo"]); + }); + it("includes ISNAN", () => { + const expr = isNaN("foo"); + assert.strictEqual(String(expr), `ISNAN("foo")`); + assert.strictEqual(expr.func, "ISNAN"); + assert.strictEqual(expr.args.length, 1); + assert.deepStrictEqual(expr.columns, ["foo"]); + }); + it("includes ISFINITE", () => { + const expr = isFinite("foo"); + assert.strictEqual(String(expr), `ISFINITE("foo")`); + assert.strictEqual(expr.func, "ISFINITE"); + assert.strictEqual(expr.args.length, 1); + assert.deepStrictEqual(expr.columns, ["foo"]); + }); + it("includes ISINF", () => { + const expr = isInfinite("foo"); + assert.strictEqual(String(expr), `ISINF("foo")`); + assert.strictEqual(expr.func, "ISINF"); + assert.strictEqual(expr.args.length, 1); + assert.deepStrictEqual(expr.columns, ["foo"]); + }); +}); diff --git a/packages/sql/test/literal-test.js b/packages/sql/test/literal-test.ts similarity index 68% rename from packages/sql/test/literal-test.js rename to packages/sql/test/literal-test.ts index a252447b..ac7805b6 100644 --- a/packages/sql/test/literal-test.js +++ b/packages/sql/test/literal-test.ts @@ -1,8 +1,8 @@ -import assert from 'node:assert'; -import { literal } from '../src/index.js'; +import assert from "node:assert"; +import { literal } from "../src/index"; -describe('literal', () => { - it('handles booleans', () => { +describe("literal", () => { + it("handles booleans", () => { const trueExpr = literal(true); assert.strictEqual(trueExpr.value, true); assert.strictEqual(String(trueExpr), `TRUE`); @@ -11,23 +11,23 @@ describe('literal', () => { assert.strictEqual(falseExpr.value, false); assert.strictEqual(String(falseExpr), `FALSE`); }); - it('handles dates', () => { - const date = new Date('2012-01-01'); + it("handles dates", () => { + const date = new Date("2012-01-01"); const dateExpr = literal(date); assert.strictEqual(dateExpr.value, date); - assert.strictEqual(String(dateExpr), 'MAKE_DATE(2012, 1, 1)'); + assert.strictEqual(String(dateExpr), "MAKE_DATE(2012, 1, 1)"); - const timestamp = new Date('2012-01-01T17:51:12.833Z'); + const timestamp = new Date("2012-01-01T17:51:12.833Z"); const timestampExpr = literal(timestamp); assert.strictEqual(timestampExpr.value, timestamp); assert.strictEqual(String(timestampExpr), `EPOCH_MS(${+timestamp})`); - const badDate = new Date('foobar'); + const badDate = new Date("foobar"); const badDateExpr = literal(badDate); assert.strictEqual(badDateExpr.value, badDate); - assert.strictEqual(String(badDateExpr), 'NULL'); + assert.strictEqual(String(badDateExpr), "NULL"); }); - it('handles nulls', () => { + it("handles nulls", () => { const nullExpr = literal(null); assert.strictEqual(nullExpr.value, null); assert.strictEqual(String(nullExpr), `NULL`); @@ -36,7 +36,7 @@ describe('literal', () => { assert.strictEqual(undefinedExpr.value, undefined); assert.strictEqual(String(undefinedExpr), `NULL`); }); - it('handles numbers', () => { + it("handles numbers", () => { const numberExpr = literal(1); assert.strictEqual(numberExpr.value, 1); assert.strictEqual(String(numberExpr), `1`); @@ -49,13 +49,13 @@ describe('literal', () => { assert.strictEqual(infinityExpr.value, Infinity); assert.strictEqual(String(infinityExpr), `NULL`); }); - it('handles strings', () => { - const stringExpr = literal('str'); - assert.strictEqual(stringExpr.value, 'str'); + it("handles strings", () => { + const stringExpr = literal("str"); + assert.strictEqual(stringExpr.value, "str"); assert.strictEqual(String(stringExpr), `'str'`); - const emptyExpr = literal(''); - assert.strictEqual(emptyExpr.value, ''); + const emptyExpr = literal(""); + assert.strictEqual(emptyExpr.value, ""); assert.strictEqual(String(emptyExpr), `''`); }); }); diff --git a/packages/sql/test/load-test.js b/packages/sql/test/load-test.ts similarity index 62% rename from packages/sql/test/load-test.js rename to packages/sql/test/load-test.ts index 9ed889ed..8b5c2be5 100644 --- a/packages/sql/test/load-test.js +++ b/packages/sql/test/load-test.ts @@ -1,14 +1,14 @@ -import assert from 'node:assert'; -import { loadCSV } from '../src/index.js'; +import assert from "node:assert"; +import { loadCSV } from "../src/index"; -describe('loadCSV', () => { - it('accepts query options', () => { +describe("loadCSV", () => { + it("accepts query options", () => { const base = { - select: ['colA', 'colB'], - where: 'colX > 5' + select: ["colA", "colB"], + where: "colX > 5", }; assert.strictEqual( - loadCSV('table', 'data.csv', base), + loadCSV("table", "data.csv", base), `CREATE TEMP TABLE IF NOT EXISTS table AS SELECT colA, colB FROM read_csv('data.csv', auto_detect=true, sample_size=-1) WHERE colX > 5` ); @@ -16,26 +16,26 @@ describe('loadCSV', () => { ...base, view: true, temp: false, - replace: true + replace: true, }; assert.strictEqual( - loadCSV('table', 'data.csv', ext), + loadCSV("table", "data.csv", ext), `CREATE OR REPLACE VIEW table AS SELECT colA, colB FROM read_csv('data.csv', auto_detect=true, sample_size=-1) WHERE colX > 5` ); }); - it('accepts DuckDB options', () => { + it("accepts DuckDB options", () => { const opt = { auto_detect: false, all_varchar: true, - columns: {line: 'VARCHAR'}, - force_not_null: ['line'], - new_line: '\\n', + columns: { line: "VARCHAR" }, + force_not_null: ["line"], + new_line: "\\n", header: false, - skip: 2 + skip: 2, }; assert.strictEqual( - loadCSV('table', 'data.csv', opt), + loadCSV("table", "data.csv", opt), `CREATE TEMP TABLE IF NOT EXISTS table AS SELECT * FROM read_csv('data.csv', auto_detect=false, sample_size=-1, all_varchar=true, columns={'line': 'VARCHAR'}, force_not_null=['line'], new_line='\\n', header=false, skip=2)` ); }); diff --git a/packages/sql/test/operator-test.js b/packages/sql/test/operator-test.js deleted file mode 100644 index ea8dd97c..00000000 --- a/packages/sql/test/operator-test.js +++ /dev/null @@ -1,104 +0,0 @@ -import assert from 'node:assert'; -import { - column, and, or, not, - isNull, isNotNull, - eq, neq, lt, gt, lte, gte, - isDistinct, isNotDistinct, - isBetween, isNotBetween -} from '../src/index.js'; - -describe('Logical operators', () => { - it('include AND expressions', () => { - assert.strictEqual(String(and()), ''); - assert.strictEqual(String(and('foo')), '"foo"'); - assert.strictEqual(String(and(null, true)), 'TRUE'); - assert.strictEqual(String(and(true, true)), '(TRUE AND TRUE)'); - assert.strictEqual(String(and(true, null, false)), '(TRUE AND FALSE)'); - assert.strictEqual(and().op, 'AND'); - assert.strictEqual(and().children.length, 0); - assert.strictEqual(and('foo').children.length, 1); - assert.strictEqual(and(null, true).children.length, 1); - assert.strictEqual(and(true, true).children.length, 2); - }); - it('include OR expressions', () => { - assert.strictEqual(String(or()), ''); - assert.strictEqual(String(or('foo')), '"foo"'); - assert.strictEqual(String(or(null, true)), 'TRUE'); - assert.strictEqual(String(or(false, true)), '(FALSE OR TRUE)'); - assert.strictEqual(String(or(false, null, false)), '(FALSE OR FALSE)'); - assert.strictEqual(or().op, 'OR'); - assert.strictEqual(or().children.length, 0); - assert.strictEqual(or('foo').children.length, 1); - assert.strictEqual(or(null, true).children.length, 1); - assert.strictEqual(or(false, true).children.length, 2); - }); -}); - -describe('Unary operators', () => { - it('include NOT expressions', () => { - assert.strictEqual(String(not(column('foo'))), '(NOT "foo")'); - assert.strictEqual(String(not('foo')), '(NOT "foo")'); - }); - it('include IS NULL expressions', () => { - assert.strictEqual(String(isNull(column('foo'))), '("foo" IS NULL)'); - assert.strictEqual(String(isNull('foo')), '("foo" IS NULL)'); - }); - it('include IS NOT NULL expressions', () => { - assert.strictEqual(String(isNotNull(column('foo'))), '("foo" IS NOT NULL)'); - assert.strictEqual(String(isNotNull('foo')), '("foo" IS NOT NULL)'); - }); -}); - -describe('Binary operators', () => { - it('include equality comparisons', () => { - assert.strictEqual(String(eq(column('foo'), 1)), '("foo" = 1)'); - assert.strictEqual(String(eq('foo', 1)), '("foo" = 1)'); - }); - it('include inequality comparisons', () => { - assert.strictEqual(String(neq(column('foo'), 1)), '("foo" <> 1)'); - assert.strictEqual(String(neq('foo', 1)), '("foo" <> 1)'); - }); - it('include less than comparisons', () => { - assert.strictEqual(String(lt(column('foo'), 1)), '("foo" < 1)'); - assert.strictEqual(String(lt('foo', 1)), '("foo" < 1)'); - }); - it('include less than or equal comparisons', () => { - assert.strictEqual(String(lte(column('foo'), 1)), '("foo" <= 1)'); - assert.strictEqual(String(lte('foo', 1)), '("foo" <= 1)'); - }); - it('include greater than comparisons', () => { - assert.strictEqual(String(gt(column('foo'), 1)), '("foo" > 1)'); - assert.strictEqual(String(gt('foo', 1)), '("foo" > 1)'); - }); - it('include greater than or equal comparisons', () => { - assert.strictEqual(String(gte(column('foo'), 1)), '("foo" >= 1)'); - assert.strictEqual(String(gte('foo', 1)), '("foo" >= 1)'); - }); - it('include IS DISTINCT FROM comparisons', () => { - assert.strictEqual(String(isDistinct(column('foo'), null)), '("foo" IS DISTINCT FROM NULL)'); - assert.strictEqual(String(isDistinct('foo', null)), '("foo" IS DISTINCT FROM NULL)'); - }); - it('include IS NOT DISTINCT FROM comparisons', () => { - assert.strictEqual(String(isNotDistinct(column('foo'), null)), '("foo" IS NOT DISTINCT FROM NULL)'); - assert.strictEqual(String(isNotDistinct('foo', null)), '("foo" IS NOT DISTINCT FROM NULL)'); - }); -}); - -describe('Range operators', () => { - it('test within inclusive ranges', () => { - assert.strictEqual(String(isBetween('a', null)), ''); - assert.strictEqual(String(isBetween('a', [0, 1])), '("a" BETWEEN 0 AND 1)'); - }); - it('test within exclusive ranges', () => { - assert.strictEqual(String(isBetween('a', null, true)), ''); - assert.strictEqual(String(isBetween('a', [0, 1], true)), '(0 <= "a" AND "a" < 1)'); - }); - it('test not within inclusive ranges', () => { - assert.strictEqual(String(isNotBetween('a', null)), ''); - assert.strictEqual(String(isNotBetween('a', [0, 1])), '("a" NOT BETWEEN 0 AND 1)'); - }); - it('test not within exclusive ranges', () => { - assert.strictEqual(String(isNotBetween('a', null, true)), ''); - assert.strictEqual(String(isNotBetween('a', [0, 1], true)), 'NOT (0 <= "a" AND "a" < 1)'); - }); -}); diff --git a/packages/sql/test/operator-test.ts b/packages/sql/test/operator-test.ts new file mode 100644 index 00000000..e2a30dad --- /dev/null +++ b/packages/sql/test/operator-test.ts @@ -0,0 +1,136 @@ +import assert from "node:assert"; +import { + column, + and, + or, + not, + isNull, + isNotNull, + eq, + neq, + lt, + gt, + lte, + gte, + isDistinct, + isNotDistinct, + isBetween, + isNotBetween, +} from "../src/index"; + +describe("Logical operators", () => { + it("include AND expressions", () => { + assert.strictEqual(String(and()), ""); + assert.strictEqual(String(and("foo")), '"foo"'); + assert.strictEqual(String(and(null, true)), "TRUE"); + assert.strictEqual(String(and(true, true)), "(TRUE AND TRUE)"); + assert.strictEqual(String(and(true, null, false)), "(TRUE AND FALSE)"); + assert.strictEqual(and().op, "AND"); + assert.strictEqual(and().children.length, 0); + assert.strictEqual(and("foo").children.length, 1); + assert.strictEqual(and(null, true).children.length, 1); + assert.strictEqual(and(true, true).children.length, 2); + }); + it("include OR expressions", () => { + assert.strictEqual(String(or()), ""); + assert.strictEqual(String(or("foo")), '"foo"'); + assert.strictEqual(String(or(null, true)), "TRUE"); + assert.strictEqual(String(or(false, true)), "(FALSE OR TRUE)"); + assert.strictEqual(String(or(false, null, false)), "(FALSE OR FALSE)"); + assert.strictEqual(or().op, "OR"); + assert.strictEqual(or().children.length, 0); + assert.strictEqual(or("foo").children.length, 1); + assert.strictEqual(or(null, true).children.length, 1); + assert.strictEqual(or(false, true).children.length, 2); + }); +}); + +describe("Unary operators", () => { + it("include NOT expressions", () => { + assert.strictEqual(String(not(column("foo"))), '(NOT "foo")'); + assert.strictEqual(String(not("foo")), '(NOT "foo")'); + }); + it("include IS NULL expressions", () => { + assert.strictEqual(String(isNull(column("foo"))), '("foo" IS NULL)'); + assert.strictEqual(String(isNull("foo")), '("foo" IS NULL)'); + }); + it("include IS NOT NULL expressions", () => { + assert.strictEqual(String(isNotNull(column("foo"))), '("foo" IS NOT NULL)'); + assert.strictEqual(String(isNotNull("foo")), '("foo" IS NOT NULL)'); + }); +}); + +describe("Binary operators", () => { + it("include equality comparisons", () => { + assert.strictEqual(String(eq(column("foo"), 1)), '("foo" = 1)'); + assert.strictEqual(String(eq("foo", 1)), '("foo" = 1)'); + }); + it("include inequality comparisons", () => { + assert.strictEqual(String(neq(column("foo"), 1)), '("foo" <> 1)'); + assert.strictEqual(String(neq("foo", 1)), '("foo" <> 1)'); + }); + it("include less than comparisons", () => { + assert.strictEqual(String(lt(column("foo"), 1)), '("foo" < 1)'); + assert.strictEqual(String(lt("foo", 1)), '("foo" < 1)'); + }); + it("include less than or equal comparisons", () => { + assert.strictEqual(String(lte(column("foo"), 1)), '("foo" <= 1)'); + assert.strictEqual(String(lte("foo", 1)), '("foo" <= 1)'); + }); + it("include greater than comparisons", () => { + assert.strictEqual(String(gt(column("foo"), 1)), '("foo" > 1)'); + assert.strictEqual(String(gt("foo", 1)), '("foo" > 1)'); + }); + it("include greater than or equal comparisons", () => { + assert.strictEqual(String(gte(column("foo"), 1)), '("foo" >= 1)'); + assert.strictEqual(String(gte("foo", 1)), '("foo" >= 1)'); + }); + it("include IS DISTINCT FROM comparisons", () => { + assert.strictEqual( + String(isDistinct(column("foo"), null)), + '("foo" IS DISTINCT FROM NULL)' + ); + assert.strictEqual( + String(isDistinct("foo", null)), + '("foo" IS DISTINCT FROM NULL)' + ); + }); + it("include IS NOT DISTINCT FROM comparisons", () => { + assert.strictEqual( + String(isNotDistinct(column("foo"), null)), + '("foo" IS NOT DISTINCT FROM NULL)' + ); + assert.strictEqual( + String(isNotDistinct("foo", null)), + '("foo" IS NOT DISTINCT FROM NULL)' + ); + }); +}); + +describe("Range operators", () => { + it("test within inclusive ranges", () => { + assert.strictEqual(String(isBetween("a", null)), ""); + assert.strictEqual(String(isBetween("a", [0, 1])), '("a" BETWEEN 0 AND 1)'); + }); + it("test within exclusive ranges", () => { + assert.strictEqual(String(isBetween("a", null, true)), ""); + assert.strictEqual( + String(isBetween("a", [0, 1], true)), + '(0 <= "a" AND "a" < 1)' + ); + }); + it("test not within inclusive ranges", () => { + assert.strictEqual(String(isNotBetween("a", null)), ""); + assert.strictEqual( + String(isNotBetween("a", [0, 1])), + '("a" NOT BETWEEN 0 AND 1)' + ); + }); + it("test not within exclusive ranges", () => { + assert.strictEqual(String(isNotBetween("a", null, true)), ""); + assert.strictEqual( + String(isNotBetween("a", [0, 1], true)), + 'NOT (0 <= "a" AND "a" < 1)' + ); + }); +}); diff --git a/packages/sql/test/query-test.js b/packages/sql/test/query-test.js deleted file mode 100644 index 9202ebde..00000000 --- a/packages/sql/test/query-test.js +++ /dev/null @@ -1,459 +0,0 @@ -import assert from 'node:assert'; -import { - column, desc, gt, lt, max, min, relation, sql, Query -} from '../src/index.js'; - -describe('Query', () => { - it('selects column name strings', () => { - const query = 'SELECT "foo", "bar", "baz" FROM "data"'; - - assert.strictEqual( - Query - .select('foo', 'bar', 'baz') - .from('data') - .toString(), - query - ); - - assert.strictEqual( - Query - .select('foo', 'bar', 'baz') - .from(relation('data')) - .toString(), - query - ); - - assert.strictEqual( - Query - .select(['foo', 'bar', 'baz']) - .from('data') - .toString(), - query - ); - - assert.strictEqual( - Query - .select({ foo: 'foo', bar: 'bar', baz: 'baz' }) - .from('data') - .toString(), - query - ); - - assert.strictEqual( - Query - .select('foo') - .select('bar') - .select('baz') - .from('data') - .toString(), - query - ); - }); - - it('selects column ref objects', () => { - const foo = column('foo'); - const bar = column('bar'); - const baz = column('baz'); - - assert.strictEqual( - Query - .select(foo, bar, baz) - .from('data') - .toString(), - 'SELECT "foo", "bar", "baz" FROM "data"' - ); - - assert.strictEqual( - Query - .select([foo, bar, baz]) - .from('data') - .toString(), - 'SELECT "foo", "bar", "baz" FROM "data"' - ); - - assert.strictEqual( - Query - .select({ foo, bar, baz }) - .from('data') - .toString(), - 'SELECT "foo", "bar", "baz" FROM "data"' - ); - - assert.strictEqual( - Query - .select(foo) - .select(bar) - .select(baz) - .from('data') - .toString(), - 'SELECT "foo", "bar", "baz" FROM "data"' - ); - }); - - it('selects distinct columns', () => { - assert.strictEqual( - Query - .select('foo', 'bar', 'baz') - .distinct() - .from('data') - .toString(), - 'SELECT DISTINCT "foo", "bar", "baz" FROM "data"' - ); - }); - - it('selects aggregates', () => { - const foo = column('foo'); - - assert.strictEqual( - Query - .select({ min: min('foo'), max: max('foo') }) - .from('data') - .toString(), - 'SELECT MIN("foo") AS "min", MAX("foo") AS "max" FROM "data"' - ); - - assert.strictEqual( - Query - .select({ min: min(foo), max: max(foo) }) - .from('data') - .toString(), - 'SELECT MIN("foo") AS "min", MAX("foo") AS "max" FROM "data"' - ); - - assert.strictEqual( - Query - .select({ min: min('foo').where(gt('bar', 5)) }) - .from('data') - .toString(), - 'SELECT MIN("foo") FILTER (WHERE ("bar" > 5)) AS "min" FROM "data"' - ); - }); - - it('selects grouped aggregates', () => { - const foo = column('foo'); - const bar = column('bar'); - const baz = column('baz'); - - const query = [ - 'SELECT MIN("foo") AS "min", MAX("foo") AS "max", "bar", "baz"', - 'FROM "data"', - 'GROUP BY "bar", "baz"' - ].join(' '); - - assert.strictEqual( - Query - .select({ min: min('foo'), max: max('foo'), bar: 'bar', baz: 'baz' }) - .from('data') - .groupby('bar', 'baz') - .toString(), - query - ); - - assert.strictEqual( - Query - .select({ min: min(foo), max: max(foo), bar: bar, baz: baz }) - .from('data') - .groupby(bar, baz) - .toString(), - query - ); - - assert.strictEqual( - Query - .select({ min: min(foo), max: max(foo), bar, baz }) - .from('data') - .groupby([bar, baz]) - .toString(), - query - ); - - assert.strictEqual( - Query - .select({ min: min(foo), max: max(foo), bar, baz }) - .from('data') - .groupby(bar) - .groupby(baz) - .toString(), - query - ); - }); - - it('selects filtered aggregates', () => { - const foo = column('foo'); - const bar = column('bar'); - - const query = [ - 'SELECT MIN("foo") AS "min", "bar"', - 'FROM "data"', - 'GROUP BY "bar"', - 'HAVING ("min" > 50) AND ("min" < 100)' - ].join(' '); - - assert.strictEqual( - Query - .select({ min: min(foo), bar }) - .from('data') - .groupby(bar) - .having(gt('min', 50), lt('min', 100)) - .toString(), - query - ); - - assert.strictEqual( - Query - .select({ min: min(foo), bar }) - .from('data') - .groupby(bar) - .having([gt('min', 50), lt('min', 100)]) - .toString(), - query - ); - - assert.strictEqual( - Query - .select({ min: min(foo), bar }) - .from('data') - .groupby(bar) - .having(gt('min', 50)) - .having(lt('min', 100)) - .toString(), - query - ); - - assert.strictEqual( - Query - .select({ min: min(foo), bar }) - .from('data') - .groupby(bar) - .having(sql`("min" > 50) AND ("min" < 100)`) - .toString(), - query - ); - }); - - it('selects filtered rows', () => { - const foo = column('foo'); - const bar = column('bar'); - - const query = [ - 'SELECT "foo"', - 'FROM "data"', - 'WHERE ("bar" > 50) AND ("bar" < 100)' - ].join(' '); - - assert.strictEqual( - Query - .select(foo) - .from('data') - .where(gt(bar, 50), lt(bar, 100)) - .toString(), - query - ); - - assert.strictEqual( - Query - .select(foo) - .from('data') - .where([gt(bar, 50), lt(bar, 100)]) - .toString(), - query - ); - - assert.strictEqual( - Query - .select(foo) - .from('data') - .where(gt(bar, 50)) - .where(lt(bar, 100)) - .toString(), - query - ); - - assert.strictEqual( - Query - .select(foo) - .from('data') - .where(sql`("bar" > 50) AND ("bar" < 100)`) - .toString(), - query - ); - }); - - it('selects ordered rows', () => { - const bar = column('bar'); - const baz = column('baz'); - - const query = [ - 'SELECT *', - 'FROM "data"', - 'ORDER BY "bar", "baz" DESC NULLS LAST' - ].join(' '); - - assert.strictEqual( - Query - .select('*') - .from('data') - .orderby(bar, desc(baz)) - .toString(), - query - ); - - assert.strictEqual( - Query - .select('*') - .from('data') - .orderby([bar, desc(baz)]) - .toString(), - query - ); - - assert.strictEqual( - Query - .select('*') - .from('data') - .orderby(bar) - .orderby(desc(baz)) - .toString(), - query - ); - - assert.strictEqual( - Query - .select('*') - .from('data') - .orderby(sql`"bar", "baz" DESC NULLS LAST`) - .toString(), - query - ); - }); - - it('selects sampled rows', () => { - assert.strictEqual( - Query - .select('*') - .from('data') - .sample(10) - .toString(), - 'SELECT * FROM "data" USING SAMPLE 10 ROWS' - ); - - assert.strictEqual( - Query - .select('*') - .from('data') - .sample({ rows: 10 }) - .toString(), - 'SELECT * FROM "data" USING SAMPLE 10 ROWS' - ); - - assert.strictEqual( - Query - .select('*') - .from('data') - .sample(0.3) - .toString(), - 'SELECT * FROM "data" USING SAMPLE 30 PERCENT' - ); - - assert.strictEqual( - Query - .select('*') - .from('data') - .sample({ perc: 30 }) - .toString(), - 'SELECT * FROM "data" USING SAMPLE 30 PERCENT' - ); - - assert.strictEqual( - Query - .select('*') - .from('data') - .sample({ rows: 100, method: 'bernoulli' }) - .toString(), - 'SELECT * FROM "data" USING SAMPLE 100 ROWS (bernoulli)' - ); - - assert.strictEqual( - Query - .select('*') - .from('data') - .sample({ rows: 100, method: 'bernoulli', seed: 12345 }) - .toString(), - 'SELECT * FROM "data" USING SAMPLE 100 ROWS (bernoulli, 12345)' - ); - }); - - it('selects from multiple relations', () => { - const query = [ - 'SELECT "a"."foo" AS "foo", "b"."bar" AS "bar"', - 'FROM "data1" AS "a", "data2" AS "b"' - ].join(' '); - - assert.strictEqual( - Query - .select({ - foo: column('a', 'foo'), - bar: column('b', 'bar') - }) - .from({ a: 'data1', b: 'data2' }) - .toString(), - query - ); - }); - - it('selects over windows', () => { - assert.strictEqual( - Query - .select({ lead: sql`lead("foo") OVER "win"` }) - .from('data') - .window({ win: sql`ORDER BY "foo" ASC` }) - .toString(), - 'SELECT lead("foo") OVER "win" AS "lead" FROM "data" WINDOW "win" AS (ORDER BY "foo" ASC)' - ); - }); - - it('selects from subqueries', () => { - assert.strictEqual( - Query - .select('foo', 'bar') - .from(Query.select('*').from('data')) - .toString(), - 'SELECT "foo", "bar" FROM (SELECT * FROM "data")' - ); - - assert.strictEqual( - Query - .select('foo', 'bar') - .from({ a: Query.select('*').from('data') }) - .toString(), - 'SELECT "foo", "bar" FROM (SELECT * FROM "data") AS "a"' - ); - }); - - it('selects with common table expressions', () => { - assert.strictEqual( - Query - .with({ a: Query.select('*').from('data') }) - .select('foo', 'bar') - .from('a') - .toString(), - 'WITH "a" AS (SELECT * FROM "data") SELECT "foo", "bar" FROM "a"' - ); - - assert.strictEqual( - Query - .with({ - a: Query.select('foo').from('data1'), - b: Query.select('bar').from('data2') - }) - .select('*') - .from('a', 'b') - .toString(), - [ - 'WITH "a" AS (SELECT "foo" FROM "data1"),', - '"b" AS (SELECT "bar" FROM "data2")', - 'SELECT * FROM "a", "b"' - ].join(' ') - ); - }); -}); diff --git a/packages/sql/test/query-test.ts b/packages/sql/test/query-test.ts new file mode 100644 index 00000000..8eadc444 --- /dev/null +++ b/packages/sql/test/query-test.ts @@ -0,0 +1,382 @@ +import assert from "node:assert"; +import { + column, + desc, + gt, + lt, + max, + min, + relation, + sql, + Query, +} from "../src/index"; + +describe("Query", () => { + it("selects column name strings", () => { + const query = 'SELECT "foo", "bar", "baz" FROM "data"'; + + assert.strictEqual( + Query.select("foo", "bar", "baz").from("data").toString(), + query + ); + + assert.strictEqual( + Query.select("foo", "bar", "baz").from(relation("data")).toString(), + query + ); + + assert.strictEqual( + Query.select("foo", "bar", "baz").from("data").toString(), + query + ); + + assert.strictEqual( + Query.select("foo", "bar", "baz").from("data").toString(), + query + ); + + assert.strictEqual( + Query.select("foo").select("bar").select("baz").from("data").toString(), + query + ); + }); + + it("selects column ref objects", () => { + const foo = column("foo"); + const bar = column("bar"); + const baz = column("baz"); + + assert.strictEqual( + Query.select(foo, bar, baz).from("data").toString(), + 'SELECT "foo", "bar", "baz" FROM "data"' + ); + + assert.strictEqual( + Query.select([foo, bar, baz]).from("data").toString(), + 'SELECT "foo", "bar", "baz" FROM "data"' + ); + + assert.strictEqual( + Query.select({ foo, bar, baz }).from("data").toString(), + 'SELECT "foo", "bar", "baz" FROM "data"' + ); + + assert.strictEqual( + Query.select(foo).select(bar).select(baz).from("data").toString(), + 'SELECT "foo", "bar", "baz" FROM "data"' + ); + }); + + it("selects distinct columns", () => { + assert.strictEqual( + Query.select("foo", "bar", "baz").distinct().from("data").toString(), + 'SELECT DISTINCT "foo", "bar", "baz" FROM "data"' + ); + }); + + it("selects aggregates", () => { + const foo = column("foo"); + + assert.strictEqual( + Query.select({ min: min("foo"), max: max("foo") }) + .from("data") + .toString(), + 'SELECT MIN("foo") AS "min", MAX("foo") AS "max" FROM "data"' + ); + + assert.strictEqual( + Query.select({ min: min(foo), max: max(foo) }) + .from("data") + .toString(), + 'SELECT MIN("foo") AS "min", MAX("foo") AS "max" FROM "data"' + ); + + assert.strictEqual( + Query.select({ min: min("foo").where(gt("bar", 5)) }) + .from("data") + .toString(), + 'SELECT MIN("foo") FILTER (WHERE ("bar" > 5)) AS "min" FROM "data"' + ); + }); + + it("selects grouped aggregates", () => { + const foo = column("foo"); + const bar = column("bar"); + const baz = column("baz"); + + const query = [ + 'SELECT MIN("foo") AS "min", MAX("foo") AS "max", "bar", "baz"', + 'FROM "data"', + 'GROUP BY "bar", "baz"', + ].join(" "); + + assert.strictEqual( + Query.select({ min: min("foo"), max: max("foo"), bar: "bar", baz: "baz" }) + .from("data") + .groupby("bar", "baz") + .toString(), + query + ); + + assert.strictEqual( + Query.select({ min: min(foo), max: max(foo), bar: bar, baz: baz }) + .from("data") + .groupby(bar, baz) + .toString(), + query + ); + + assert.strictEqual( + Query.select({ min: min(foo), max: max(foo), bar, baz }) + .from("data") + .groupby([bar, baz]) + .toString(), + query + ); + + assert.strictEqual( + Query.select({ min: min(foo), max: max(foo), bar, baz }) + .from("data") + .groupby(bar) + .groupby(baz) + .toString(), + query + ); + }); + + it("selects filtered aggregates", () => { + const foo = column("foo"); + const bar = column("bar"); + + const query = [ + 'SELECT MIN("foo") AS "min", "bar"', + 'FROM "data"', + 'GROUP BY "bar"', + 'HAVING ("min" > 50) AND ("min" < 100)', + ].join(" "); + + assert.strictEqual( + Query.select({ min: min(foo), bar }) + .from("data") + .groupby(bar) + .having(gt("min", 50), lt("min", 100)) + .toString(), + query + ); + + assert.strictEqual( + Query.select({ min: min(foo), bar }) + .from("data") + .groupby(bar) + .having([gt("min", 50), lt("min", 100)]) + .toString(), + query + ); + + assert.strictEqual( + Query.select({ min: min(foo), bar }) + .from("data") + .groupby(bar) + .having(gt("min", 50)) + .having(lt("min", 100)) + .toString(), + query + ); + + assert.strictEqual( + Query.select({ min: min(foo), bar }) + .from("data") + .groupby(bar) + .having(sql`("min" > 50) AND ("min" < 100)`) + .toString(), + query + ); + }); + + it("selects filtered rows", () => { + const foo = column("foo"); + const bar = column("bar"); + + const query = [ + 'SELECT "foo"', + 'FROM "data"', + 'WHERE ("bar" > 50) AND ("bar" < 100)', + ].join(" "); + + assert.strictEqual( + Query.select(foo) + .from("data") + .where(gt(bar, 50), lt(bar, 100)) + .toString(), + query + ); + + assert.strictEqual( + Query.select(foo) + .from("data") + .where([gt(bar, 50), lt(bar, 100)]) + .toString(), + query + ); + + assert.strictEqual( + Query.select(foo) + .from("data") + .where(gt(bar, 50)) + .where(lt(bar, 100)) + .toString(), + query + ); + + assert.strictEqual( + Query.select(foo) + .from("data") + .where(sql`("bar" > 50) AND ("bar" < 100)`) + .toString(), + query + ); + }); + + it("selects ordered rows", () => { + const bar = column("bar"); + const baz = column("baz"); + + const query = [ + "SELECT *", + 'FROM "data"', + 'ORDER BY "bar", "baz" DESC NULLS LAST', + ].join(" "); + + assert.strictEqual( + Query.select("*").from("data").orderby(bar, desc(baz)).toString(), + query + ); + + assert.strictEqual( + Query.select("*") + .from("data") + .orderby([bar, desc(baz)]) + .toString(), + query + ); + + assert.strictEqual( + Query.select("*").from("data").orderby(bar).orderby(desc(baz)).toString(), + query + ); + + assert.strictEqual( + Query.select("*") + .from("data") + .orderby(sql`"bar", "baz" DESC NULLS LAST`) + .toString(), + query + ); + }); + + it("selects sampled rows", () => { + assert.strictEqual( + Query.select("*").from("data").sample(10).toString(), + 'SELECT * FROM "data" USING SAMPLE 10 ROWS' + ); + + assert.strictEqual( + Query.select("*").from("data").sample({ rows: 10 }).toString(), + 'SELECT * FROM "data" USING SAMPLE 10 ROWS' + ); + + assert.strictEqual( + Query.select("*").from("data").sample(0.3).toString(), + 'SELECT * FROM "data" USING SAMPLE 30 PERCENT' + ); + + assert.strictEqual( + Query.select("*").from("data").sample({ perc: 30 }).toString(), + 'SELECT * FROM "data" USING SAMPLE 30 PERCENT' + ); + + assert.strictEqual( + Query.select("*") + .from("data") + .sample({ rows: 100, method: "bernoulli" }) + .toString(), + 'SELECT * FROM "data" USING SAMPLE 100 ROWS (bernoulli)' + ); + + assert.strictEqual( + Query.select("*") + .from("data") + .sample({ rows: 100, method: "bernoulli", seed: 12345 }) + .toString(), + 'SELECT * FROM "data" USING SAMPLE 100 ROWS (bernoulli, 12345)' + ); + }); + + it("selects from multiple relations", () => { + const query = [ + 'SELECT "a"."foo" AS "foo", "b"."bar" AS "bar"', + 'FROM "data1" AS "a", "data2" AS "b"', + ].join(" "); + + assert.strictEqual( + Query.select({ + foo: column("a", "foo"), + bar: column("b", "bar"), + }) + .from({ a: "data1", b: "data2" }) + .toString(), + query + ); + }); + + it("selects over windows", () => { + assert.strictEqual( + Query.select({ lead: sql`lead("foo") OVER "win"` }) + .from("data") + .window({ win: sql`ORDER BY "foo" ASC` }) + .toString(), + 'SELECT lead("foo") OVER "win" AS "lead" FROM "data" WINDOW "win" AS (ORDER BY "foo" ASC)' + ); + }); + + it("selects from subqueries", () => { + assert.strictEqual( + Query.select("foo", "bar") + .from(Query.select("*").from("data")) + .toString(), + 'SELECT "foo", "bar" FROM (SELECT * FROM "data")' + ); + + assert.strictEqual( + Query.select("foo", "bar") + .from({ a: Query.select("*").from("data") }) + .toString(), + 'SELECT "foo", "bar" FROM (SELECT * FROM "data") AS "a"' + ); + }); + + it("selects with common table expressions", () => { + assert.strictEqual( + Query.with({ a: Query.select("*").from("data") }) + .select("foo", "bar") + .from("a") + .toString(), + 'WITH "a" AS (SELECT * FROM "data") SELECT "foo", "bar" FROM "a"' + ); + + assert.strictEqual( + Query.with({ + a: Query.select("foo").from("data1"), + b: Query.select("bar").from("data2"), + }) + .select("*") + .from("a", "b") + .toString(), + [ + 'WITH "a" AS (SELECT "foo" FROM "data1"),', + '"b" AS (SELECT "bar" FROM "data2")', + 'SELECT * FROM "a", "b"', + ].join(" ") + ); + }); +}); diff --git a/packages/sql/test/stub-param.js b/packages/sql/test/stub-param.js deleted file mode 100644 index 1f1cc749..00000000 --- a/packages/sql/test/stub-param.js +++ /dev/null @@ -1,8 +0,0 @@ -export function stubParam(value) { - let cb; - return { - value, - addEventListener(type, callback) { cb = callback; }, - update(v) { cb(this.value = v); } - }; -} diff --git a/packages/sql/test/stub-param.ts b/packages/sql/test/stub-param.ts new file mode 100644 index 00000000..a34686f2 --- /dev/null +++ b/packages/sql/test/stub-param.ts @@ -0,0 +1,14 @@ +import { Param } from "../src/expression"; + +export function stubParam(value: any): Param { + let cb: (v: any) => void; + return { + value, + addEventListener(type: any, callback: (v: any) => void) { + cb = callback; + }, + update(v: any) { + cb((this.value = v)); + }, + }; +} diff --git a/packages/sql/test/window-test.js b/packages/sql/test/window-test.js deleted file mode 100644 index 79f54493..00000000 --- a/packages/sql/test/window-test.js +++ /dev/null @@ -1,132 +0,0 @@ -import assert from 'node:assert'; -import { stubParam } from './stub-param.js'; -import { - column, desc, isParamLike, isSQLExpression, - row_number, rank, dense_rank, percent_rank, cume_dist, - ntile, lag, lead, first_value, last_value, nth_value -} from '../src/index.js'; - -describe('Window functions', () => { - it('expose metadata', () => { - const expr = lead('foo'); - assert.strictEqual(expr.window, 'LEAD'); - assert.strictEqual(expr.column, 'foo'); - assert.deepStrictEqual(expr.columns, ['foo']); - }); - it('support window name', () => { - const expr = cume_dist().over('win'); - assert.strictEqual(expr.window, 'CUME_DIST'); - assert.strictEqual(expr.name, 'win'); - assert.strictEqual(String(expr), 'CUME_DIST() OVER "win"'); - }); - it('support partition by', () => { - const expr = cume_dist().partitionby('foo', 'bar'); - assert.strictEqual(String(expr), 'CUME_DIST() OVER (PARTITION BY "foo", "bar")'); - }); - it('support order by', () => { - const expr = cume_dist().orderby('a', desc('b')); - assert.strictEqual(String(expr), 'CUME_DIST() OVER (ORDER BY "a", "b" DESC NULLS LAST)'); - }); - it('support rows frame', () => { - assert.strictEqual( - String(first_value('foo').rows([0, null])), - 'FIRST_VALUE("foo") OVER (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)' - ); - assert.strictEqual( - String(first_value('foo').rows([null, null])), - 'FIRST_VALUE("foo") OVER (ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)' - ); - assert.strictEqual( - String(first_value('foo').rows([0, 2])), - 'FIRST_VALUE("foo") OVER (ROWS BETWEEN CURRENT ROW AND 2 FOLLOWING)' - ); - assert.strictEqual( - String(first_value('foo').rows([2, 0])), - 'FIRST_VALUE("foo") OVER (ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)' - ); - }); - it('support range frame', () => { - assert.strictEqual( - String(first_value('foo').range([0, null])), - 'FIRST_VALUE("foo") OVER (RANGE BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)' - ); - assert.strictEqual( - String(first_value('foo').range([null, null])), - 'FIRST_VALUE("foo") OVER (RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)' - ); - assert.strictEqual( - String(first_value('foo').range([0, 2])), - 'FIRST_VALUE("foo") OVER (RANGE BETWEEN CURRENT ROW AND 2 FOLLOWING)' - ); - assert.strictEqual( - String(first_value('foo').range([2, 0])), - 'FIRST_VALUE("foo") OVER (RANGE BETWEEN 2 PRECEDING AND CURRENT ROW)' - ); - }); - it('support window name, partition by, order by, and frame', () => { - const expr = cume_dist() - .over('base') - .partitionby('foo', 'bar') - .orderby('a', desc('b')) - .rows([0, +Infinity]); - assert.strictEqual(String(expr), 'CUME_DIST() OVER (' - + '"base" ' - + 'PARTITION BY "foo", "bar" ' - + 'ORDER BY "a", "b" DESC NULLS LAST ' - + 'ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)' - ); - }); - it('support parameterized expressions', () => { - const col = stubParam(column('bar')); - assert.ok(isParamLike(col)); - - const expr = cume_dist(col); - assert.ok(isSQLExpression(expr)); - assert.ok(isParamLike(expr)); - assert.strictEqual(String(expr), 'CUME_DIST("bar") OVER ()'); - assert.strictEqual(expr.column, 'bar'); - assert.deepStrictEqual(expr.columns, ['bar']); - - expr.addEventListener('value', value => { - assert.ok(isSQLExpression(value)); - assert.strictEqual(String(expr), `${value}`); - }); - col.update(column('baz')); - assert.strictEqual(String(expr), 'CUME_DIST("baz") OVER ()'); - assert.strictEqual(expr.column, 'baz'); - assert.deepStrictEqual(expr.columns, ['baz']); - }); - it('include ROW_NUMBER', () => { - assert.strictEqual(String(row_number()), '(ROW_NUMBER() OVER ())::INTEGER'); - }); - it('include RANK', () => { - assert.strictEqual(String(rank()), '(RANK() OVER ())::INTEGER'); - }); - it('include DENSE_RANK', () => { - assert.strictEqual(String(dense_rank()), '(DENSE_RANK() OVER ())::INTEGER'); - }); - it('include PERCENT_RANK', () => { - assert.strictEqual(String(percent_rank()), 'PERCENT_RANK() OVER ()'); - }); - it('include CUME_DIST', () => { - assert.strictEqual(String(cume_dist()), 'CUME_DIST() OVER ()'); - }); - it('include NTILE', () => { - assert.strictEqual(String(ntile(5)), 'NTILE(5) OVER ()'); - }); - it('include LAG', () => { - assert.strictEqual(String(lag('foo', 2)), 'LAG("foo", 2) OVER ()'); - }); - it('include LEAD', () => { - assert.strictEqual(String(lead('foo', 2)), 'LEAD("foo", 2) OVER ()'); - }); - it('include FIRST_VALUE', () => { - assert.strictEqual(String(first_value('foo')), 'FIRST_VALUE("foo") OVER ()'); - }); - it('include LAST_VALUE', () => { - assert.strictEqual(String(last_value('foo')), 'LAST_VALUE("foo") OVER ()'); - }); - it('include NTH_VALUE', () => { - assert.strictEqual(String(nth_value('foo', 2)), 'NTH_VALUE("foo", 2) OVER ()'); - }); -}); diff --git a/packages/sql/test/window-test.ts b/packages/sql/test/window-test.ts new file mode 100644 index 00000000..0cf5d65e --- /dev/null +++ b/packages/sql/test/window-test.ts @@ -0,0 +1,158 @@ +import assert from "node:assert"; +import { stubParam } from "./stub-param"; +import { + column, + desc, + isParamLike, + isSQLExpression, + row_number, + rank, + dense_rank, + percent_rank, + cume_dist, + ntile, + lag, + lead, + first_value, + last_value, + nth_value, +} from "../src/index"; + +describe("Window functions", () => { + it("expose metadata", () => { + const expr = lead("foo"); + assert.strictEqual(expr.window, "LEAD"); + assert.strictEqual(expr.column, "foo"); + assert.deepStrictEqual(expr.columns, ["foo"]); + }); + it("support window name", () => { + const expr = cume_dist().over("win"); + assert.strictEqual(expr.window, "CUME_DIST"); + assert.strictEqual(expr.name, "win"); + assert.strictEqual(String(expr), 'CUME_DIST() OVER "win"'); + }); + it("support partition by", () => { + const expr = cume_dist().partitionby("foo", "bar"); + assert.strictEqual( + String(expr), + 'CUME_DIST() OVER (PARTITION BY "foo", "bar")' + ); + }); + it("support order by", () => { + const expr = cume_dist().orderby("a", desc("b")); + assert.strictEqual( + String(expr), + 'CUME_DIST() OVER (ORDER BY "a", "b" DESC NULLS LAST)' + ); + }); + it("support rows frame", () => { + assert.strictEqual( + String(first_value("foo").rows([0, null])), + 'FIRST_VALUE("foo") OVER (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)' + ); + assert.strictEqual( + String(first_value("foo").rows([null, null])), + 'FIRST_VALUE("foo") OVER (ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)' + ); + assert.strictEqual( + String(first_value("foo").rows([0, 2])), + 'FIRST_VALUE("foo") OVER (ROWS BETWEEN CURRENT ROW AND 2 FOLLOWING)' + ); + assert.strictEqual( + String(first_value("foo").rows([2, 0])), + 'FIRST_VALUE("foo") OVER (ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)' + ); + }); + it("support range frame", () => { + assert.strictEqual( + String(first_value("foo").range([0, null])), + 'FIRST_VALUE("foo") OVER (RANGE BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)' + ); + assert.strictEqual( + String(first_value("foo").range([null, null])), + 'FIRST_VALUE("foo") OVER (RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)' + ); + assert.strictEqual( + String(first_value("foo").range([0, 2])), + 'FIRST_VALUE("foo") OVER (RANGE BETWEEN CURRENT ROW AND 2 FOLLOWING)' + ); + assert.strictEqual( + String(first_value("foo").range([2, 0])), + 'FIRST_VALUE("foo") OVER (RANGE BETWEEN 2 PRECEDING AND CURRENT ROW)' + ); + }); + it("support window name, partition by, order by, and frame", () => { + const expr = cume_dist() + .over("base") + .partitionby("foo", "bar") + .orderby("a", desc("b")) + .rows([0, +Infinity]); + assert.strictEqual( + String(expr), + "CUME_DIST() OVER (" + + '"base" ' + + 'PARTITION BY "foo", "bar" ' + + 'ORDER BY "a", "b" DESC NULLS LAST ' + + "ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)" + ); + }); + it("support parameterized expressions", () => { + const col = stubParam(column("bar")); + assert.ok(isParamLike(col)); + + const expr = cume_dist(col); + assert.ok(isSQLExpression(expr)); + assert.ok(isParamLike(expr)); + assert.strictEqual(String(expr), 'CUME_DIST("bar") OVER ()'); + assert.strictEqual(expr.column, "bar"); + assert.deepStrictEqual(expr.columns, ["bar"]); + + expr.addEventListener!("value", (value) => { + assert.ok(isSQLExpression(value)); + assert.strictEqual(String(expr), `${value}`); + }); + col.update(column("baz")); + assert.strictEqual(String(expr), 'CUME_DIST("baz") OVER ()'); + assert.strictEqual(expr.column, "baz"); + assert.deepStrictEqual(expr.columns, ["baz"]); + }); + it("include ROW_NUMBER", () => { + assert.strictEqual(String(row_number()), "(ROW_NUMBER() OVER ())::INTEGER"); + }); + it("include RANK", () => { + assert.strictEqual(String(rank()), "(RANK() OVER ())::INTEGER"); + }); + it("include DENSE_RANK", () => { + assert.strictEqual(String(dense_rank()), "(DENSE_RANK() OVER ())::INTEGER"); + }); + it("include PERCENT_RANK", () => { + assert.strictEqual(String(percent_rank()), "PERCENT_RANK() OVER ()"); + }); + it("include CUME_DIST", () => { + assert.strictEqual(String(cume_dist()), "CUME_DIST() OVER ()"); + }); + it("include NTILE", () => { + assert.strictEqual(String(ntile(5)), "NTILE(5) OVER ()"); + }); + it("include LAG", () => { + assert.strictEqual(String(lag("foo", 2)), 'LAG("foo", 2) OVER ()'); + }); + it("include LEAD", () => { + assert.strictEqual(String(lead("foo", 2)), 'LEAD("foo", 2) OVER ()'); + }); + it("include FIRST_VALUE", () => { + assert.strictEqual( + String(first_value("foo")), + 'FIRST_VALUE("foo") OVER ()' + ); + }); + it("include LAST_VALUE", () => { + assert.strictEqual(String(last_value("foo")), 'LAST_VALUE("foo") OVER ()'); + }); + it("include NTH_VALUE", () => { + assert.strictEqual( + String(nth_value("foo", 2)), + 'NTH_VALUE("foo", 2) OVER ()' + ); + }); +}); diff --git a/packages/sql/tsconfig.json b/packages/sql/tsconfig.json new file mode 100644 index 00000000..f25728e7 --- /dev/null +++ b/packages/sql/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "CommonJS", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "downlevelIteration": true + }, + "include": ["src/index.ts"] +}