├── .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 |
--------------------------------------------------------------------------------