├── .gitignore ├── README.md ├── .prettierrc.js ├── .changeset ├── config.json └── README.md ├── .github └── workflows │ ├── test-prs.yml │ └── release.yml ├── tsconfig.json ├── eslint.config.js ├── test └── select │ └── index.test.ts ├── package.json └── src └── index.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | docs 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

Clickify

3 |

Secure query builder for Clickhouse

4 |
5 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | export default { 2 | useTabs: false, 3 | singleQuote: false, 4 | trailingComma: "all", 5 | printWidth: 100, 6 | semi: true, 7 | tabWidth: 4, 8 | }; 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/test-prs.yml: -------------------------------------------------------------------------------- 1 | name: Test Pull Requests 2 | 3 | on: 4 | pull_request: 5 | branches: [ "main" ] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Use Node.js 20.x 14 | uses: actions/setup-node@v3 15 | with: 16 | node-version: 20.x 17 | cache: 'npm' 18 | - run: npm ci 19 | - run: npm run build 20 | - run: npm test 21 | 22 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Base Options: */ 4 | "esModuleInterop": true, 5 | "skipLibCheck": true, 6 | "target": "es2022", 7 | "verbatimModuleSyntax": true, 8 | "allowJs": true, 9 | "resolveJsonModule": true, 10 | "moduleDetection": "force", 11 | /* Strictness */ 12 | "strict": true, 13 | "noUncheckedIndexedAccess": true, 14 | /* If NOT transpiling with TypeScript: */ 15 | "moduleResolution": "Bundler", 16 | "module": "ESNext", 17 | "noEmit": true, 18 | /* If your code doesn't run in the DOM: */ 19 | "lib": [ 20 | "es2022" 21 | ] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | import tseslint from "typescript-eslint"; 4 | import eslintPluginPrettier from "eslint-plugin-prettier/recommended"; 5 | 6 | export default [ 7 | { 8 | ignores: ["dist/*", "docs/*", ".github/*", ".changeset/*"], 9 | }, 10 | 11 | // Use node globals 12 | { languageOptions: { globals: globals.node } }, 13 | 14 | // Use recommended JS config 15 | pluginJs.configs.recommended, 16 | 17 | // Use recommended TS config 18 | ...tseslint.configs.recommended, 19 | 20 | // Allow unused variables that start with _ 21 | { 22 | rules: { 23 | "no-unused-vars": "off", 24 | "@typescript-eslint/no-unused-vars": [ 25 | "warn", 26 | { 27 | argsIgnorePattern: "^_", 28 | varsIgnorePattern: "^_", 29 | caughtErrorsIgnorePattern: "^_", 30 | }, 31 | ], 32 | }, 33 | }, 34 | 35 | // Delegate to prettier as last plugin 36 | eslintPluginPrettier, 37 | ]; 38 | -------------------------------------------------------------------------------- /test/select/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test } from "vitest"; 2 | import { select, table } from "../../src"; 3 | 4 | describe("select", () => { 5 | test("single column", ({ expect }) => { 6 | const myTable = table("table"); 7 | 8 | const { query, parameters } = select(myTable.column("a")).from(myTable).toQuery(); 9 | 10 | expect(query).eq("SELECT {column_0: Identifier} FROM {table_0: Identifier};"); 11 | expect(parameters).toMatchObject({ 12 | column_0: "a", 13 | table_0: "table", 14 | }); 15 | }); 16 | 17 | test("no column throws error", ({ expect }) => { 18 | const query = select().from(table("table")); 19 | 20 | expect(() => { 21 | query.toQuery(); 22 | }).toThrowError(); 23 | }); 24 | 25 | test("multiple columns", ({ expect }) => { 26 | const myTable = table("table"); 27 | 28 | const { query, parameters } = select(myTable.column("a"), myTable.column("b")) 29 | .from(myTable) 30 | .toQuery(); 31 | 32 | expect(query).eq( 33 | "SELECT {column_0: Identifier}, {column_1: Identifier} FROM {table_0: Identifier};", 34 | ); 35 | expect(parameters).toMatchObject({ 36 | column_0: "a", 37 | column_1: "b", 38 | table_0: "table", 39 | }); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | permissions: 11 | contents: write 12 | pull-requests: write 13 | pages: write 14 | id-token: write 15 | 16 | jobs: 17 | release: 18 | name: Release 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Checkout Repo 22 | uses: actions/checkout@v4 23 | 24 | - name: Setup Node.js 20.x 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: 20.x 28 | 29 | - name: Install Dependencies 30 | run: npm ci 31 | 32 | - name: Create release pull request or publish to npm 33 | id: changesets 34 | uses: changesets/action@v1 35 | with: 36 | publish: npm run ci:release 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 40 | 41 | generate-docs: 42 | needs: release 43 | 44 | name: Generate and deploy documentation 45 | 46 | environment: 47 | name: github-pages 48 | url: ${{ steps.deployment.outputs.page_url }} 49 | 50 | runs-on: ubuntu-latest 51 | 52 | steps: 53 | - name: Checkout Repo 54 | uses: actions/checkout@v4 55 | 56 | - name: Setup Node.js 20.x 57 | uses: actions/setup-node@v4 58 | with: 59 | node-version: 20.x 60 | 61 | - name: Install Dependencies 62 | run: npm ci 63 | 64 | - name: Generate Docs 65 | run: npm run doc 66 | 67 | - name: Setup pages 68 | uses: actions/configure-pages@v4 69 | 70 | - name: Upload docs 71 | uses: actions/upload-pages-artifact@v3 72 | with: 73 | path: ./docs 74 | 75 | - name: Deploy to Github Pages 76 | id: deployment 77 | uses: actions/deploy-pages@v4 78 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "clickify", 3 | "version": "0.0.0", 4 | "description": "Secure query builder for Clickhouse", 5 | "type": "module", 6 | "scripts": { 7 | "lint": "tsc && eslint .", 8 | "format": "eslint --fix .", 9 | "build": "tsup ./src/index.ts", 10 | "doc": "typedoc ./src --media ./media --plugin typedoc-plugin-extras --footerLastModified true --plugin typedoc-material-theme --themeColor '#03284e' --plugin typedoc-plugin-rename-defaults", 11 | "test": "vitest", 12 | "ci:release": "npm run build && changeset publish" 13 | }, 14 | "keywords": [ 15 | "clickhouse", 16 | "sql", 17 | "query-builder", 18 | "query", 19 | "builder" 20 | ], 21 | "files": [ 22 | "dist", 23 | "README.md" 24 | ], 25 | "main": "./dist/index.cjs", 26 | "types": "./dist/index.d.cts", 27 | "exports": { 28 | ".": { 29 | "import": { 30 | "default": "./dist/index.js", 31 | "types": "./dist/index.d.ts" 32 | }, 33 | "require": { 34 | "default": "./dist/index.cjs", 35 | "types": "./dist/index.d.cts" 36 | } 37 | } 38 | }, 39 | "author": { 40 | "name": "Tom Anderson", 41 | "email": "tom@ando.gq", 42 | "url": "https://ando.gq" 43 | }, 44 | "repository": { 45 | "type": "git", 46 | "url": "git+https://github.com/clear/clickify.git" 47 | }, 48 | "license": "ISC", 49 | "devDependencies": { 50 | "@changesets/cli": "^2.27.1", 51 | "@eslint/js": "^9.0.0", 52 | "@types/node": "^20.10.7", 53 | "eslint": "^8.57.0", 54 | "eslint-config-prettier": "^9.1.0", 55 | "eslint-plugin-prettier": "^5.1.3", 56 | "globals": "^15.0.0", 57 | "npm-run-all": "^4.1.5", 58 | "prettier": "3.2.5", 59 | "tsup": "^8.0.1", 60 | "typedoc": "^0.25.7", 61 | "typedoc-material-theme": "^1.0.2", 62 | "typedoc-plugin-extras": "^3.0.0", 63 | "typedoc-plugin-rename-defaults": "^0.7.0", 64 | "typescript": "^5.3.3", 65 | "typescript-eslint": "^7.6.0", 66 | "vitest": "^1.2.0" 67 | }, 68 | "publishConfig": { 69 | "access": "public" 70 | }, 71 | "tsup": { 72 | "format": [ 73 | "esm", 74 | "cjs" 75 | ], 76 | "splitting": true, 77 | "cjsInterop": true 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | function getValueType(value: Table | Column): "table" | "column" { 2 | if (isColumn(value)) { 3 | return "column"; 4 | } 5 | 6 | if (isTable(value)) { 7 | return "table"; 8 | } 9 | 10 | throw ClickifyError.Internal(`could not determine type of value (${value})`); 11 | } 12 | 13 | function getValue(value: Table | Column): string { 14 | if (isColumn(value)) { 15 | return value.column; 16 | } 17 | 18 | if (isTable(value)) { 19 | return value.table; 20 | } 21 | 22 | throw ClickifyError.Internal(`could not determine value (${value})`); 23 | } 24 | 25 | function getClickhouseType(value: Table | Column): "Identifier" { 26 | if (isColumn(value) || isTable(value)) { 27 | return "Identifier"; 28 | } 29 | 30 | throw ClickifyError.Internal(`could not determine clickhouse type of value (${value})`); 31 | } 32 | 33 | function createQuery() { 34 | const query: (string | string[])[] = []; 35 | const parameters: Record<"table" | "column", string[]> = { 36 | table: [], 37 | column: [], 38 | }; 39 | 40 | function buildParameterName(type: string, index: number) { 41 | return `${type}_${index}`; 42 | } 43 | 44 | function parameterise(value: Table | Column): string { 45 | const valueType = getValueType(value); 46 | const parameterName = buildParameterName(valueType, parameters[valueType].length); 47 | const clickhouseType = getClickhouseType(value); 48 | 49 | const parameter = `{${parameterName}: ${clickhouseType}}`; 50 | 51 | parameters[valueType].push(getValue(value)); 52 | 53 | return parameter; 54 | } 55 | 56 | return { 57 | push(value: string | Table | Column) { 58 | if (typeof value === "string") { 59 | query.push(value); 60 | return this; 61 | } 62 | 63 | query.push(parameterise(value)); 64 | 65 | return this; 66 | }, 67 | 68 | list(list: Table[] | Column[]) { 69 | query.push(list.map(parameterise)); 70 | 71 | return this; 72 | }, 73 | 74 | build(options?: { semi?: boolean }) { 75 | let queryStr = query 76 | .map((part) => { 77 | if (typeof part === "string") { 78 | return part; 79 | } 80 | 81 | return part.join(", "); 82 | }) 83 | .join(" "); 84 | 85 | if (options?.semi !== false) { 86 | queryStr += ";"; 87 | } 88 | 89 | return { 90 | query: queryStr, 91 | parameters: Object.entries(parameters).reduce( 92 | (finalParameters, [parameterType, parameters]) => { 93 | parameters.forEach((parameter, i) => { 94 | finalParameters[buildParameterName(parameterType, i)] = parameter; 95 | }); 96 | 97 | return finalParameters; 98 | }, 99 | {} as Record, 100 | ), 101 | }; 102 | }, 103 | }; 104 | } 105 | 106 | class ClickifyError extends Error { 107 | static ColumnsRequired() { 108 | return new ClickifyError("columns are required to make a query"); 109 | } 110 | 111 | static TableRequired() { 112 | return new ClickifyError("a table must be provided to query"); 113 | } 114 | 115 | static Internal(message: string) { 116 | return new ClickifyError(`Internal Clickify error: ${message}`); 117 | } 118 | } 119 | 120 | type Table = { table: string }; 121 | type Column = { table: string; column: string }; 122 | 123 | export function table(table: string) { 124 | return { 125 | table, 126 | column: (column: string) => ({ table, column }), 127 | }; 128 | } 129 | 130 | export function isTable(value: Table | Column): value is Table { 131 | return "table" in value; 132 | } 133 | 134 | export function isColumn(value: Table | Column): value is Column { 135 | return "column" in value && typeof value.column === "string"; 136 | } 137 | 138 | export function select(...cols: Column[]) { 139 | const columns = cols; 140 | let table: Table | null = null; 141 | 142 | return { 143 | select(column: Column) { 144 | columns.push(column); 145 | 146 | return this; 147 | }, 148 | from(t: Table) { 149 | table = t; 150 | 151 | return this; 152 | }, 153 | 154 | toQuery() { 155 | if (columns.length === 0) { 156 | throw ClickifyError.ColumnsRequired(); 157 | } 158 | 159 | if (!table) { 160 | throw ClickifyError.TableRequired(); 161 | } 162 | 163 | const query = createQuery(); 164 | 165 | // Begin select statement with columns 166 | query.push("SELECT"); 167 | query.list(columns); 168 | 169 | // Select table that the query is for 170 | query.push("FROM"); 171 | query.push(table); 172 | 173 | return query.build(); 174 | }, 175 | }; 176 | } 177 | --------------------------------------------------------------------------------