├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .idea ├── .gitignore ├── better-sql.iml └── modules.xml ├── .nycrc.json ├── .prettierignore ├── .prettierrc ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── package.json ├── playground ├── .gitignore ├── .prettierrc ├── package.json ├── snowpack.config.js ├── src │ ├── index.html │ ├── main.ts │ └── style.css └── tsconfig.json ├── src ├── code-gen.ts ├── index.ts └── parse.ts ├── test ├── index.ts ├── lang.spec.ts ├── sample.ts └── typescript.spec.ts ├── tsconfig.cjs.json ├── tsconfig.esm.json └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent coding styles between different editors and IDEs 2 | # http://editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 2 9 | 10 | # We recommend you to keep these unchanged 11 | end_of_line = lf 12 | charset = utf-8 13 | trim_trailing_whitespace = true 14 | insert_final_newline = true 15 | 16 | [*.md] 17 | trim_trailing_whitespace = false 18 | 19 | [*.xml] 20 | indent_style = space 21 | indent_size = 4 22 | 23 | [*.json] 24 | indent_style = space 25 | indent_size = 2 26 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | test/ 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint"], 5 | "extends": [ 6 | "eslint:recommended", 7 | "plugin:@typescript-eslint/eslint-recommended", 8 | "plugin:@typescript-eslint/recommended" 9 | ], 10 | "rules": { 11 | "spaced-comment": ["error", "always", { "markers": ["/"] }], 12 | "curly": ["error", "multi-line"], 13 | "guard-for-in": "error", 14 | "no-console": [ 15 | "error", 16 | { 17 | "allow": ["warn", "debug", "error"] 18 | } 19 | ], 20 | "no-var": "error", 21 | "prefer-const": "error", 22 | "no-duplicate-imports": "off", 23 | "@typescript-eslint/no-duplicate-imports": ["error"], 24 | "@typescript-eslint/explicit-module-boundary-types": "off", 25 | "@typescript-eslint/no-explicit-any": "off", 26 | "@typescript-eslint/no-namespace": "off", 27 | "no-useless-escape": "off", 28 | "no-invalid-this": "off", 29 | "@typescript-eslint/no-unused-vars": [ 30 | "error", 31 | { "argsIgnorePattern": "^_" } 32 | ], 33 | "prefer-spread": "off", 34 | "prefer-rest-params": "off", 35 | "@typescript-eslint/no-this-alias": "off", 36 | "@typescript-eslint/ban-types": "off", 37 | "space-before-function-paren": [ 38 | "error", 39 | { "anonymous": "never", "named": "never" } 40 | ], 41 | "newline-per-chained-call": ["error", { "ignoreChainWithDepth": 3 }] 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | package-lock.json 3 | pnpm-lock.yaml 4 | pnpm-debug.log 5 | dist/ 6 | .cache/ 7 | *.tgz 8 | .env 9 | .nyc_output 10 | coverage/ 11 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !modules.xml 3 | !*.iml 4 | !dictionaries/ 5 | !.gitignore 6 | 7 | -------------------------------------------------------------------------------- /.idea/better-sql.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.nycrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": "src/**/*.ts", 3 | "exclude": "src/**/*.{test,spec}.ts" 4 | } 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | playground/build/ 3 | pnpm-lock.yaml 4 | *.macro.ts 5 | src/assets/* 6 | src/components.d.ts 7 | src/polyfill-array.ts 8 | coverage/ 9 | .nyc_output/ 10 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "jsxSingleQuote": false, 4 | "overrides": [ 5 | { 6 | "files": ["*.scss", "*.css"], 7 | "options": { 8 | "singleQuote": false 9 | } 10 | } 11 | ], 12 | "semi": false, 13 | "arrowParens": "avoid", 14 | "trailingComma": "all" 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "ts-node", 9 | "type": "node", 10 | "request": "launch", 11 | "program": "${workspaceRoot}/node_modules/mocha/bin/_mocha", 12 | "args": ["${relativeFile}"], 13 | "runtimeArgs": ["-r", "ts-node/register"], 14 | "cwd": "${workspaceRoot}", 15 | "protocol": "inspector", 16 | "internalConsoleOptions": "openOnSessionStart" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) [2022], [Beeno Tung (Tung Cheung Leong)] 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # better-sql.ts 2 | 3 | Generate sql query from a concise query syntax inspired from [EdgeDB](https://www.edgedb.com/blog/edgedb-1-0) and [GraphQL](https://graphql.org/). 4 | 5 | [![npm Package Version](https://img.shields.io/npm/v/better-sql.ts)](https://www.npmjs.com/package/better-sql.ts) 6 | 7 | Online Playground: https://better-sql.surge.sh 8 | 9 | 10 | 11 | ## Supported Features 12 | 13 | - [x] output typical sql query, compatible with mysql, postgres, sqlite, e.t.c. 14 | - [x] automatically add table name on columns if not specified 15 | - [x] inner join with nested `table {fields}` 16 | - [x] left join with nested `table [fields]` 17 | - [x] nested `select` sub-query 18 | - [x] `where` statement 19 | - [x] `having` statement 20 | - [x] `group by` statement 21 | - [x] aggregate function, e.g. `sum(score)` 22 | - [x] `order by` statement 23 | - [x] `limit` and `offset` statement 24 | 25 | ## TypeScript Signature 26 | 27 | ```typescript 28 | export function queryToSQL(query: string): string 29 | ``` 30 | 31 | ## Usage 32 | 33 | ```typescript 34 | import { queryToSQL } from 'better-sql.ts' 35 | import { db } from './db' 36 | 37 | let keyword = '%script%' 38 | let query = 'select post [...] where title like :keyword' 39 | let sql = queryToSQL(query) 40 | let result = db.query(sql, { keyword }) 41 | ``` 42 | 43 | ## Example 44 | 45 | 46 | 47 | 48 | 77 | 105 | 106 | 107 |
49 | A query in better-sql: 50 | 51 | ```sql 52 | select post [ 53 | id as post_id 54 | title 55 | author_id 56 | user as author { nickname, avatar } where delete_time is null 57 | type_id 58 | post_type { 59 | name as type 60 | is_hidden 61 | } where is_hidden = 0 or user.is_admin = 1 62 | ] where created_at >= :since 63 | and delete_time is null 64 | and title like :keyword 65 | order by created_at desc 66 | limit 25 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | ``` 75 | 76 | 78 | is converted into formatted sql as below: 79 | 80 | ```sql 81 | select 82 | post.id as post_id 83 | , post.title 84 | , post.author_id 85 | , author.nickname 86 | , author.avatar 87 | , post.type_id 88 | , post_type.name as type 89 | , post_type.is_hidden 90 | from post 91 | inner join user as author on author.id = post.author_id 92 | inner join post_type on post_type.id = post.post_type_id 93 | where author.delete_time is null 94 | and (post_type.is_hidden = 0 95 | or user.is_admin = 1) 96 | and post.created_at >= :since 97 | and post.delete_time is null 98 | and post.title like :keyword 99 | order by 100 | post.created_at desc 101 | limit 25 102 | ``` 103 | 104 |
108 | 109 | Details refers to [sample.ts](./test/sample.ts) and [lang.spec.ts](./test/lang.spec.ts) 110 | 111 | ## License 112 | 113 | This project is licensed with [BSD-2-Clause](./LICENSE) 114 | 115 | This is free, libre, and open-source software. It comes down to four essential freedoms [[ref]](https://seirdy.one/2021/01/27/whatsapp-and-the-domestication-of-users.html#fnref:2): 116 | 117 | - The freedom to run the program as you wish, for any purpose 118 | - The freedom to study how the program works, and change it so it does your computing as you wish 119 | - The freedom to redistribute copies so you can help others 120 | - The freedom to distribute copies of your modified versions to others 121 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "better-sql.ts", 3 | "version": "1.0.2", 4 | "sideEffects": false, 5 | "description": "Generate sql query from a concise query syntax inspired from EdgeDB and GraphQL", 6 | "keywords": [ 7 | "sql", 8 | "syntax", 9 | "code-generation", 10 | "query-language" 11 | ], 12 | "author": { 13 | "name": "Beeno Tung", 14 | "email": "aabbcc1241@yahoo.com.hk", 15 | "url": "https://beeno-tung.surge.sh" 16 | }, 17 | "license": "BSD-2-Clause", 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/beenotung/better-sql.git" 21 | }, 22 | "homepage": "https://github.com/beenotung/better-sql#readme", 23 | "bugs": { 24 | "url": "https://github.com/beenotung/better-sql/issues" 25 | }, 26 | "main": "dist/cjs/index.js", 27 | "types": "dist/cjs/index.d.ts", 28 | "module": "dist/esm/index.js", 29 | "directories": { 30 | "test": "test" 31 | }, 32 | "files": [ 33 | "dist" 34 | ], 35 | "scripts": { 36 | "build": "run-s clean tsc", 37 | "clean": "rimraf dist", 38 | "format": "run-s format:*", 39 | "format:prettier": "prettier --write .", 40 | "format:json": "format-json-cli", 41 | "format:eslint": "eslint --ext .ts --fix .", 42 | "tsc": "run-p tsc:*", 43 | "tsc:cjs": "tsc -p tsconfig.cjs.json", 44 | "tsc:esm": "tsc -p tsconfig.esm.json", 45 | "test": "run-s format tsc test:ts mocha", 46 | "test:ts": "ts-node test/index.ts", 47 | "mocha": "ts-mocha \"{src,test}/**/*.spec.ts\"", 48 | "coverage": "nyc ts-mocha --reporter=progress \"{src,test}/**/*.spec.ts\"", 49 | "report:update": "nyc --reporter=lcov ts-mocha --reporter=progress \"{src,test}/**/*.spec.ts\"", 50 | "report:open": "open-cli coverage/lcov-report/index.html", 51 | "report": "run-s report:update report:open", 52 | "prepublishOnly": "run-s test build" 53 | }, 54 | "dependencies": {}, 55 | "devDependencies": { 56 | "@types/chai": "^4.3.1", 57 | "@types/mocha": "8", 58 | "@types/node": "*", 59 | "@types/sinon": "^10.0.11", 60 | "@typescript-eslint/eslint-plugin": "^5.30.7", 61 | "@typescript-eslint/parser": "^5.30.7", 62 | "chai": "^4.3.6", 63 | "eslint": "^8.20.0", 64 | "format-json-cli": "^1.0.1", 65 | "mocha": "8", 66 | "npm-run-all": "^4.1.5", 67 | "nyc": "^15.1.0", 68 | "open-cli": "^7.0.1", 69 | "prettier": "^2.7.1", 70 | "rimraf": "^3.0.2", 71 | "sinon": "^14.0.0", 72 | "ts-mocha": "8", 73 | "ts-node": "^10.9.1", 74 | "ts-node-dev": "^2.0.0", 75 | "typescript": "^4.7.4" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /playground/.gitignore: -------------------------------------------------------------------------------- 1 | # npm 2 | node_modules/ 3 | package-lock.json 4 | pnpm-lock.yaml 5 | yarn.lock 6 | 7 | # build output 8 | build/ 9 | dist/ 10 | *.tsbuildinfo 11 | 12 | # os 13 | .DS_Store 14 | Thumbs.db 15 | -------------------------------------------------------------------------------- /playground/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "deploy": "run-s build upload", 4 | "upload": "run-p upload:*", 5 | "upload:better-sql": "surge build https://better-sql.surge.sh", 6 | "upload:bsql": "surge build https://bsql.surge.sh", 7 | "format": "prettier --write src", 8 | "start": "snowpack dev --port 8100", 9 | "build": "snowpack build", 10 | "test": "tsc --noEmit", 11 | "tsc": "tsc -p ." 12 | }, 13 | "devDependencies": { 14 | "@types/node": "^17.0.42", 15 | "npm-run-all": "^4.1.5", 16 | "prettier": "^2.6.2", 17 | "snowpack": "^3.8.8", 18 | "surge": "^0.23.1", 19 | "ts-node": "^10.8.1", 20 | "ts-node-dev": "^1.1.8", 21 | "typescript": "^4.7.3" 22 | }, 23 | "dependencies": { 24 | "better-sql.ts": "file:.." 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /playground/snowpack.config.js: -------------------------------------------------------------------------------- 1 | // Snowpack Configuration File 2 | // See all supported options: https://www.snowpack.dev/reference/configuration 3 | 4 | /** @type {import("snowpack").SnowpackUserConfig } */ 5 | module.exports = { 6 | mount: { 7 | /* ... */ 8 | src: '/', 9 | }, 10 | plugins: [ 11 | /* ... */ 12 | ], 13 | packageOptions: { 14 | /* ... */ 15 | }, 16 | devOptions: { 17 | /* ... */ 18 | }, 19 | buildOptions: { 20 | /* ... */ 21 | }, 22 | } 23 | -------------------------------------------------------------------------------- /playground/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | better-sql playground 8 | 12 | 13 | 14 | 15 |

better-sql playground

16 |

Live Demo

17 |

18 | This live demo requires Javascript to work. Please enable it for this 19 | domain then refresh the page. 20 |

21 |
22 |
23 | better-sql query 24 |
25 | 26 |
27 | 28 |
29 |
30 | => 31 |
32 | generated sql query 33 |
34 | 35 |
36 | 41 |
42 |
43 |
44 | 45 |

Installation

46 | npm install better-sql.ts 47 |

Usage

48 |
import { queryToSQL } from 'better-sql.ts'
49 | import { db } from './db'
50 | 
51 | let keyword = '%script%'
52 | let query = 'select post [...] where title like :keyword'
53 | let sql = queryToSQL(query)
54 | let result = db.query(sql, { keyword })
55 | 
56 |

Links

57 | 95 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /playground/src/main.ts: -------------------------------------------------------------------------------- 1 | import { queryToSQL } from 'better-sql.ts' 2 | 3 | declare let noscriptMsg: HTMLDivElement 4 | declare let errorMsg: HTMLDivElement 5 | 6 | declare let loadExampleBtn: HTMLButtonElement 7 | declare let copySQLBtn: HTMLButtonElement 8 | 9 | declare let queryInput: HTMLTextAreaElement 10 | declare let sqlOutput: HTMLTextAreaElement 11 | 12 | declare let querySpace: HTMLDivElement 13 | declare let sqlSpace: HTMLDivElement 14 | 15 | loadExampleBtn.addEventListener('click', loadExample) 16 | copySQLBtn.addEventListener('click', copySQL) 17 | 18 | queryInput.addEventListener('input', updateQuery) 19 | 20 | queryInput.addEventListener('keypress', checkBracket) 21 | queryInput.addEventListener('keypress', checkEnter) 22 | 23 | const brackets: Record = { 24 | '[': ']', 25 | '{': '}', 26 | '(': ')', 27 | } 28 | 29 | function checkBracket(event: KeyboardEvent) { 30 | const open = event.key 31 | const close = brackets[open] 32 | if (!close) return 33 | const start = queryInput.selectionStart 34 | const end = queryInput.selectionEnd 35 | if (start !== end) return 36 | let text = queryInput.value 37 | const before = text.slice(0, start) 38 | const after = text.slice(start) 39 | text = before + open + close + after 40 | queryInput.value = text 41 | queryInput.selectionStart = start + 1 42 | queryInput.selectionEnd = end + 1 43 | event.preventDefault() 44 | updateQuery() 45 | } 46 | 47 | function checkEnter(event: KeyboardEvent) { 48 | if (event.key !== 'Enter') return 49 | 50 | const start = queryInput.selectionStart 51 | const end = queryInput.selectionEnd 52 | if (start !== end) return 53 | 54 | let text = queryInput.value 55 | const before = text.slice(0, start) 56 | 57 | const open = before[before.length - 1] 58 | const isOpen = open in brackets 59 | 60 | const after = text.slice(start) 61 | const lastLine = before.split('\n').pop() || '' 62 | let indent = lastLine.match(/ */)?.[0] || '' 63 | const outerIndent = indent 64 | if (isOpen) { 65 | indent += ' ' 66 | } 67 | 68 | text = before + '\r\n' + indent 69 | if (isOpen) { 70 | text += '\r\n' + outerIndent 71 | } 72 | text += after 73 | 74 | queryInput.value = text 75 | queryInput.selectionStart = start + 1 + indent.length 76 | queryInput.selectionEnd = end + 1 + indent.length 77 | event.preventDefault() 78 | 79 | updateQuery() 80 | } 81 | 82 | function updateTextAreaHeight() { 83 | const height = Math.max( 84 | calcHeight(queryInput, querySpace), 85 | calcHeight(sqlOutput, sqlSpace), 86 | ) 87 | queryInput.style.minHeight = height + 'px' 88 | sqlOutput.style.minHeight = height + 'px' 89 | } 90 | 91 | function calcHeight(textarea: HTMLTextAreaElement, space: HTMLDivElement) { 92 | space.textContent = textarea.value 93 | const style = getComputedStyle(textarea) 94 | space.style.fontSize = style.fontSize 95 | space.style.fontFamily = style.fontFamily 96 | return space.getBoundingClientRect().height 97 | } 98 | 99 | function updateQuery() { 100 | try { 101 | const sql = queryToSQL(queryInput.value) 102 | sqlOutput.value = sql 103 | errorMsg.hidden = true 104 | } catch (error) { 105 | console.error('Failed to convert query into sql:', error) 106 | errorMsg.hidden = false 107 | errorMsg.textContent = String(error) 108 | } 109 | updateTextAreaHeight() 110 | } 111 | 112 | const sampleQueries = [ 113 | /* sql */ ` 114 | select post [ 115 | id as post_id 116 | title 117 | author_id 118 | user as author { nickname, avatar } where delete_time is null 119 | type_id 120 | post_type { 121 | name as type 122 | is_hidden 123 | } where is_hidden = 0 or user.is_admin = 1 124 | ] where created_at >= :since 125 | and delete_time is null 126 | and title like :keyword 127 | order by created_at desc 128 | limit 25 129 | `, 130 | /* sql */ ` 131 | select reply [ 132 | post { 133 | title 134 | user as author { 135 | nickname as author 136 | } 137 | } 138 | user as guest { 139 | nickname as vistor 140 | } 141 | comment 142 | ] 143 | `, 144 | ].map(query => query.trim()) 145 | 146 | function loadExample() { 147 | for (const query of sampleQueries) { 148 | if (queryInput.value === query) { 149 | continue 150 | } 151 | queryInput.value = query 152 | updateQuery() 153 | break 154 | } 155 | } 156 | 157 | let copySQLTimeout: ReturnType 158 | 159 | function copySQL() { 160 | if (copySQLTimeout) { 161 | clearTimeout(copySQLTimeout) 162 | } 163 | sqlOutput.select() 164 | sqlOutput.setSelectionRange(0, sqlOutput.value.length) 165 | if (document.execCommand('copy')) { 166 | copySQLBtn.textContent = 'Copied to clipboard' 167 | copySQLBtn.style.color = 'darkgreen' 168 | } else { 169 | copySQLBtn.textContent = 'Clipboard not supported, please copy manually' 170 | copySQLBtn.style.color = 'red' 171 | } 172 | copySQLTimeout = setTimeout(() => { 173 | copySQLBtn.textContent = 'Copy SQL query' 174 | copySQLBtn.style.color = '' 175 | }, 2500) 176 | } 177 | 178 | updateQuery() 179 | 180 | noscriptMsg.remove() 181 | -------------------------------------------------------------------------------- /playground/src/style.css: -------------------------------------------------------------------------------- 1 | .error { 2 | border: 1px solid red; 3 | padding: 0.5rem; 4 | display: inline-block; 5 | margin: 0; 6 | } 7 | 8 | [hidden] { 9 | display: none; 10 | } 11 | 12 | #demo-container { 13 | display: flex; 14 | align-items: center; 15 | } 16 | 17 | #demo-container fieldset { 18 | min-width: 350px; 19 | } 20 | 21 | legend { 22 | padding: 0.25rem; 23 | } 24 | 25 | .demo-button-container { 26 | margin: 2px; 27 | margin-bottom: 0.5rem; 28 | } 29 | 30 | textarea { 31 | width: calc(50vw - 4.5rem); 32 | height: fit-content; 33 | padding: 0.5rem; 34 | font-size: 1rem; 35 | } 36 | @media screen and (max-width: 700px) { 37 | #demo-container { 38 | flex-wrap: wrap; 39 | } 40 | textarea { 41 | width: calc(100% - 1rem); 42 | } 43 | } 44 | 45 | #demo-container fieldset { 46 | position: relative; 47 | } 48 | .space { 49 | position: absolute; 50 | top: 0; 51 | white-space: pre-wrap; 52 | visibility: hidden; 53 | pointer-events: none; 54 | } 55 | 56 | textarea { 57 | background-color: black; 58 | color: white; 59 | } 60 | 61 | code { 62 | background-color: black; 63 | color: white; 64 | padding: 0.5rem; 65 | border-radius: 0.25rem; 66 | display: inline-block; 67 | } 68 | 69 | #links-container fieldset { 70 | display: inline-block; 71 | } 72 | #links-container svg { 73 | height: 1.25rem; 74 | } 75 | -------------------------------------------------------------------------------- /playground/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "commonjs", 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "skipLibCheck": true, 9 | "outDir": "dist", 10 | "incremental": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/code-gen.ts: -------------------------------------------------------------------------------- 1 | import { AST } from './parse' 2 | 3 | type Condition = { 4 | tableName: string 5 | expr: AST.WhereExpr 6 | } 7 | 8 | export function generateSQL(ast: AST.Select): string { 9 | return toSQL(ast) 10 | } 11 | 12 | function toSQL(ast: AST.Select): string { 13 | const table = ast.table 14 | const selectFields: string[] = [] 15 | 16 | let selectStr: string = ast.selectStr || 'select' 17 | if (ast.distinct) { 18 | selectStr += ' ' + ast.distinct 19 | } 20 | 21 | function toCase(word: string) { 22 | return word 23 | .split(' ') 24 | .map(word => { 25 | switch (ast.selectStr) { 26 | case 'SELECT': 27 | return word.toUpperCase() 28 | case 'Select': 29 | return word[0].toUpperCase() + word.slice(1).toLowerCase() 30 | default: 31 | return word.toLowerCase() 32 | } 33 | }) 34 | .join(' ') 35 | } 36 | const context = { toCase } 37 | 38 | const fromStr: string = toCase('from') 39 | let fromSQL = fromStr + ' ' + nameWithAlias(table, toCase) 40 | 41 | let whereStr: string | undefined 42 | let havingStr: string | undefined 43 | 44 | const whereConditions: Condition[] = [] 45 | const havingConditions: Condition[] = [] 46 | 47 | let groupByStr: string | undefined 48 | const groupByFields: string[] = [] 49 | 50 | let orderByStr: string | undefined 51 | const orderByFields: string[] = [] 52 | 53 | let { limit, offset } = table 54 | 55 | const on = toCase('on') 56 | const id = toCase('id') 57 | const and = toCase('and') 58 | 59 | function processTable(table: AST.Table) { 60 | const tableName = table.alias || table.name 61 | const { where, groupBy, having, orderBy } = table 62 | 63 | limit = limit || table.limit 64 | offset = offset || table.offset 65 | 66 | table.fields.forEach(field => { 67 | if (field.type === 'column') { 68 | selectFields.push( 69 | nameWithTablePrefix({ 70 | field: nameWithAlias(field, toCase), 71 | tableName, 72 | }), 73 | ) 74 | } else if (field.type === 'table') { 75 | const subTable = nameWithAlias(field, toCase) 76 | const subTableName = field.alias || field.name 77 | const join = field.single ? toCase('inner join') : toCase('left join') 78 | fromSQL += ` 79 | ${join} ${subTable} ${on} ${subTableName}.${id} = ${tableName}.${subTableName}_${id}` 80 | processTable(field) 81 | } else if (field.type === 'subQuery') { 82 | let sql = toSQL(field.select) 83 | sql = addIndentation(sql) 84 | sql = `(${sql})` 85 | if (field.alias) { 86 | const asStr = field.asStr || toCase('as') 87 | sql += ` ${asStr} ${field.alias}` 88 | } 89 | selectFields.push(sql) 90 | } 91 | }) 92 | if (where) { 93 | whereStr = whereStr || where.whereStr 94 | whereConditions.push({ tableName, expr: where.expr }) 95 | } 96 | if (having) { 97 | havingStr = havingStr || having.havingStr 98 | havingConditions.push({ tableName, expr: having.expr }) 99 | } 100 | if (groupBy) { 101 | groupByStr = groupByStr || groupBy.groupByStr 102 | groupBy.fields.forEach(field => { 103 | groupByFields.push(nameWithTablePrefix({ field, tableName })) 104 | }) 105 | } 106 | if (orderBy) { 107 | orderByStr = orderByStr || orderBy.orderByStr 108 | orderBy.fields.forEach(({ name, order }) => { 109 | let field = nameWithTablePrefix({ field: name, tableName }) 110 | if (order) { 111 | field += ' ' + order 112 | } 113 | orderByFields.push(field) 114 | }) 115 | } 116 | } 117 | 118 | processTable(table) 119 | 120 | const selectSQL = ' ' + selectFields.join('\n, ') 121 | 122 | let sql = ` 123 | ${selectStr} 124 | ${selectSQL} 125 | ${fromSQL} 126 | ` 127 | 128 | function addConditions(conditions: Condition[], conditionStr: string) { 129 | if (conditions.length === 0) { 130 | return 131 | } 132 | sql += conditionStr + ' ' 133 | if (conditions.length === 1) { 134 | sql += whereToSQL(conditions[0].tableName, conditions[0].expr, context) 135 | } else { 136 | sql += conditions 137 | .map(condition => { 138 | let sql = whereToSQL(condition.tableName, condition.expr, context) 139 | if (needParentheses(condition.expr)) { 140 | sql = `(${sql})` 141 | } 142 | return sql 143 | }) 144 | .join(`\n ${and} `) 145 | } 146 | sql += ` 147 | ` 148 | } 149 | 150 | addConditions(whereConditions, whereStr || toCase('where')) 151 | 152 | if (havingConditions.length > 0 && groupByFields.length === 0) { 153 | console.warn('using "having" without "group by"') 154 | } 155 | 156 | if (groupByFields.length > 0) { 157 | groupByStr = groupByStr || toCase('group by') 158 | sql += `${groupByStr} 159 | ${groupByFields.join(` 160 | , `)} 161 | ` 162 | } 163 | 164 | addConditions(havingConditions, havingStr || toCase('having')) 165 | 166 | if (orderByFields.length > 0) { 167 | orderByStr = orderByStr || toCase('order by') 168 | sql += `${orderByStr} 169 | ${orderByFields.join(` 170 | , `)} 171 | ` 172 | } 173 | 174 | if (table.single && !limit) { 175 | limit = toCase('limit') + ' 1' 176 | } 177 | if (limit) { 178 | sql += `${limit} 179 | ` 180 | } 181 | if (offset) { 182 | sql += `${offset} 183 | ` 184 | } 185 | return sql 186 | } 187 | 188 | type Named = { 189 | name: string 190 | alias?: string 191 | asStr?: string 192 | } 193 | 194 | function nameWithAlias(named: Named, toCase: (word: string) => string): string { 195 | const asStr = named.asStr || toCase('as') 196 | let sql = named.name 197 | if (named.alias) { 198 | sql += ' ' + asStr + ' ' + named.alias 199 | } 200 | return sql 201 | } 202 | 203 | export function nameWithTablePrefix(input: { 204 | field: string 205 | tableName: string 206 | }) { 207 | let field = input.field 208 | if (shouldAddTablePrefix(field)) { 209 | const match = field.match(/.*\((.*)\).*/) 210 | if (match) { 211 | field = field.replace(match[1], input.tableName + '.' + match[1]) 212 | } else { 213 | field = input.tableName + '.' + field 214 | } 215 | } 216 | return field 217 | } 218 | 219 | function shouldAddTablePrefix(field: string): boolean { 220 | switch (field[0]) { 221 | case ':': 222 | case '$': 223 | case '@': 224 | case '?': 225 | case "'": 226 | case '"': 227 | return false 228 | } 229 | switch (field) { 230 | case '0': 231 | case '0.0': 232 | return false 233 | } 234 | if (field.toLowerCase() === 'null') return false 235 | return !(+field || field.includes('.') || field.includes('*')) 236 | } 237 | 238 | function whereToSQL( 239 | tableName: string, 240 | expr: AST.WhereValueExpr, 241 | context: { 242 | toCase: (word: string) => string 243 | }, 244 | ): string { 245 | if (typeof expr === 'string') { 246 | return nameWithTablePrefix({ field: expr, tableName }) 247 | } 248 | const { toCase } = context 249 | switch (expr.type) { 250 | case 'not': { 251 | const notStr = expr.notStr || toCase('not') 252 | return notStr + ' ' + whereToSQL(tableName, expr.expr, context) 253 | } 254 | case 'parenthesis': { 255 | return '(' + whereToSQL(tableName, expr.expr, context) + ')' 256 | } 257 | case 'compare': { 258 | let sql = whereToSQL(tableName, expr.left, context) 259 | switch (expr.op.toLowerCase()) { 260 | case 'and': 261 | sql += '\n ' + expr.op 262 | break 263 | case 'or': 264 | sql += '\n ' + expr.op 265 | break 266 | default: 267 | sql += ' ' + expr.op 268 | } 269 | sql += ' ' + whereToSQL(tableName, expr.right, context) 270 | return sql 271 | } 272 | case 'between': { 273 | const betweenStr = expr.betweenStr || toCase('between') 274 | const andStr = expr.andStr || toCase('and') 275 | let sql: string = whereToSQL(tableName, expr.expr, context) 276 | if (expr.not) { 277 | sql += ' ' + expr.not 278 | } 279 | sql += 280 | ' ' + 281 | betweenStr + 282 | ' ' + 283 | whereToSQL(tableName, expr.left, context) + 284 | ' ' + 285 | andStr + 286 | ' ' + 287 | whereToSQL(tableName, expr.right, context) 288 | return sql 289 | } 290 | case 'in': { 291 | const inStr = expr.inStr || toCase('in') 292 | let sql: string = whereToSQL(tableName, expr.expr, context) 293 | if (expr.not) { 294 | sql += ' ' + expr.not 295 | } 296 | let subQuery = toSQL(expr.select) 297 | subQuery = addIndentation(subQuery) 298 | sql += ` ${inStr} (${subQuery})` 299 | return sql 300 | } 301 | case 'select': { 302 | let sql = toSQL(expr) 303 | sql = addIndentation(sql) 304 | return sql 305 | } 306 | } 307 | } 308 | 309 | function addIndentation(sql: string): string { 310 | return sql 311 | .split('\n') 312 | .map( 313 | (line, i, lines) => 314 | (i === 0 || i === lines.length - 1 ? '' : ' ') + line, 315 | ) 316 | .join('\n') 317 | } 318 | 319 | function needParentheses(expr: AST.WhereValueExpr): boolean { 320 | for (;;) { 321 | if (typeof expr === 'string') { 322 | return false 323 | } 324 | switch (expr.type) { 325 | case 'not': 326 | expr = expr.expr 327 | continue 328 | case 'compare': 329 | return ( 330 | expr.op.toLowerCase() === 'or' || 331 | needParentheses(expr.left) || 332 | needParentheses(expr.right) 333 | ) 334 | case 'parenthesis': 335 | case 'between': 336 | case 'in': 337 | return false 338 | case 'select': 339 | return true 340 | default: { 341 | const _expr: never = expr 342 | throw new Error('Unexpected expression: ' + JSON.stringify(_expr)) 343 | } 344 | } 345 | } 346 | } 347 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { generateSQL } from './code-gen' 2 | import { decode } from './parse' 3 | 4 | export function queryToSQL(query: string): string { 5 | const ast = decode(query) 6 | const sql = generateSQL(ast) 7 | return sql.trim() 8 | } 9 | -------------------------------------------------------------------------------- /src/parse.ts: -------------------------------------------------------------------------------- 1 | export function decode(text: string) { 2 | const tokens = tokenize(text) 3 | const { rest, ast } = parse(tokens) 4 | if (rest.length > 0) { 5 | console.error('unconsumed tokens:', rest) 6 | throw new Error(`unexpected token: "${rest[0].type}"`) 7 | } 8 | return ast 9 | } 10 | 11 | export namespace Token { 12 | export type Word = { 13 | type: 'word' 14 | value: string 15 | } 16 | export type Symbol = { 17 | type: 'symbol' 18 | value: string 19 | } 20 | export type Newline = { 21 | type: 'newline' 22 | } 23 | export type Any = Word | Symbol | Newline 24 | } 25 | 26 | const wordRegexStr = 'a-zA-Z_0-9:@$?.*\'"-' 27 | const wordWithBracketRegexStr = `[${wordRegexStr}]+[${wordRegexStr}\(]+[${wordRegexStr}\)]+` 28 | const wordRegex = new RegExp(`^${wordWithBracketRegexStr}|^[${wordRegexStr}]+`) 29 | const symbols = Object.fromEntries('{}[]()<>!=,'.split('').map(c => [c, true])) 30 | const keywords = ['<>', '!=', '<=', '>='] 31 | 32 | // const aggregateFunctions = ['count', 'sum', 'avg', 'min', 'max', 'total'] 33 | 34 | export function tokenize(text: string): Token.Any[] { 35 | const tokens: Token.Any[] = [] 36 | text 37 | .trim() 38 | .split('\n') 39 | .map(line => line.trim()) 40 | .filter(line => line) 41 | .forEach(line => { 42 | tokens.push({ type: 'newline' }) 43 | let rest = line 44 | main: for (;;) { 45 | rest = rest.trim() 46 | 47 | for (const keyword of keywords) { 48 | if ( 49 | rest.startsWith(keyword) && 50 | (rest[keyword.length] === ' ' || rest[keyword.length] === '\n') 51 | ) { 52 | rest = rest.slice(keyword.length) 53 | tokens.push({ type: 'symbol', value: keyword }) 54 | continue main 55 | } 56 | } 57 | 58 | const parts = rest 59 | .split(' ') 60 | .map(part => part.trim()) 61 | .filter(part => part.length > 0) 62 | if ( 63 | parts[0]?.toLowerCase() === 'is' && 64 | parts[1]?.toLowerCase() === 'not' && 65 | parts[2]?.toLowerCase().startsWith('null') 66 | ) { 67 | tokens.push({ type: 'symbol', value: parts[0] + ' ' + parts[1] }) 68 | tokens.push({ type: 'word', value: parts[2].slice(0, 'null'.length) }) 69 | rest = rest.slice(rest.indexOf(parts[2] + 'null'.length)) 70 | continue 71 | } 72 | if ( 73 | parts[0]?.toLowerCase() === 'is' && 74 | parts[1]?.toLowerCase().startsWith('null') 75 | ) { 76 | tokens.push({ type: 'symbol', value: parts[0] }) 77 | tokens.push({ type: 'word', value: parts[1].slice(0, 'null'.length) }) 78 | rest = rest.slice(rest.indexOf(parts[1]) + 'null'.length) 79 | continue 80 | } 81 | 82 | const char = rest[0] 83 | if (!char) return 84 | if (char in symbols) { 85 | rest = rest.slice(1) 86 | tokens.push({ type: 'symbol', value: char }) 87 | continue 88 | } 89 | const match = rest.match(wordRegex) 90 | if (match) { 91 | const value = match[0] 92 | if (value.includes(')') && !value.includes('(')) { 93 | const idx = value.indexOf(')') 94 | const before = value.slice(0, idx) 95 | const after = value.slice(idx + 1) 96 | for (const value of [before, ')', after]) { 97 | if (value === ')') { 98 | tokens.push({ type: 'symbol', value }) 99 | } else if (value.length > 0) { 100 | tokens.push({ type: 'word', value }) 101 | } 102 | } 103 | } else { 104 | tokens.push({ type: 'word', value }) 105 | } 106 | rest = rest.slice(value.length) 107 | continue 108 | } 109 | console.error('unknown token:', { char, rest }) 110 | throw new Error('unknown token: ' + JSON.stringify(char)) 111 | } 112 | }) 113 | return tokens.slice(1) 114 | } 115 | 116 | export namespace AST { 117 | export type Expression = Select 118 | export type Select = { 119 | type: 'select' 120 | distinct?: string 121 | selectStr?: string 122 | table: Table 123 | } 124 | export type Table = { 125 | type: 'table' 126 | name: string 127 | alias?: string 128 | asStr?: string 129 | single: boolean 130 | join?: string 131 | fields: Field[] 132 | where?: Where 133 | groupBy?: GroupBy 134 | having?: Having 135 | orderBy?: OrderBy 136 | limit?: string 137 | offset?: string 138 | } 139 | export type Field = Column | Table | SubQuery 140 | export type Column = { 141 | type: 'column' 142 | name: string 143 | alias?: string 144 | asStr?: string 145 | } 146 | export type SubQuery = { 147 | type: 'subQuery' 148 | select: Select 149 | alias?: string 150 | asStr?: string 151 | } 152 | export type Where = { 153 | whereStr?: string 154 | expr: WhereExpr 155 | } 156 | export type Having = { 157 | havingStr?: string 158 | expr: WhereExpr 159 | } 160 | export type WhereExpr = 161 | | { 162 | type: 'compare' 163 | left: WhereValueExpr 164 | op: string 165 | right: WhereValueExpr 166 | } 167 | | { 168 | type: 'not' 169 | notStr?: 'not' | string 170 | expr: WhereExpr 171 | } 172 | | { 173 | type: 'parenthesis' 174 | expr: WhereValueExpr 175 | } 176 | | { 177 | type: 'between' 178 | betweenStr?: string 179 | expr: WhereValueExpr 180 | not?: string 181 | andStr?: string 182 | left: string 183 | right: string 184 | } 185 | | { 186 | type: 'in' 187 | inStr?: string 188 | not?: string 189 | expr: WhereValueExpr 190 | select: Select 191 | } 192 | export type WhereValueExpr = WhereExpr | string | Select 193 | export type GroupBy = { 194 | groupByStr?: string 195 | fields: string[] 196 | } 197 | export type OrderBy = { 198 | orderByStr?: string 199 | fields: OrderByField[] 200 | } 201 | export type OrderByField = { 202 | name: string 203 | order?: string 204 | } 205 | } 206 | 207 | export function parse(tokens: Token.Any[]): { 208 | rest: Token.Any[] 209 | ast: AST.Expression 210 | } { 211 | const rest = skipNewline(tokens) 212 | if (rest.length === 0) { 213 | throw new Error('empty tokens') 214 | } 215 | if (isWord(rest[0], 'select')) { 216 | return parseSelect(rest) 217 | } 218 | throw new Error('missing "select" token') 219 | } 220 | 221 | function parseSelect(tokens: Token.Any[]) { 222 | let rest = tokens 223 | const selectStr = remarkStr(rest[0], 'select') 224 | rest = rest.slice(1) 225 | rest = skipNewline(rest) 226 | 227 | let distinct: string | undefined 228 | if (isWord(rest[0], 'distinct')) { 229 | distinct = (rest[0] as Token.Word).value 230 | rest = rest.slice(1) 231 | rest = skipNewline(rest) 232 | } 233 | 234 | const tableResult = parseTable(rest) 235 | rest = tableResult.rest 236 | rest = skipNewline(rest) 237 | 238 | const { table } = tableResult 239 | const ast: AST.Select = { 240 | type: 'select', 241 | table, 242 | } 243 | if (selectStr) { 244 | ast.selectStr = selectStr 245 | } 246 | if (distinct) { 247 | ast.distinct = distinct 248 | } 249 | return { ast, rest } 250 | } 251 | 252 | function parseTable(tokens: Token.Any[]) { 253 | let rest = tokens 254 | 255 | const tableNameResult = parseWord(rest, 'table name') 256 | const tableName = tableNameResult.value 257 | rest = tableNameResult.rest 258 | 259 | if (rest.length === 0) { 260 | throw new Error(`unexpected termination after table name "${tableName}"`) 261 | } 262 | 263 | let alias: string | undefined 264 | let asStr: string | undefined 265 | if (isWord(rest[0], 'as')) { 266 | asStr = remarkStr(rest[0], 'as') 267 | rest = rest.slice(1) 268 | const aliasResult = parseWord(rest, `alias of table "${tableName}"`) 269 | rest = aliasResult.rest 270 | alias = aliasResult.value 271 | } 272 | 273 | const fieldsResult = parseFields(rest, tableName) 274 | rest = fieldsResult.rest 275 | 276 | const table: AST.Table = { 277 | type: 'table', 278 | name: tableName, 279 | single: fieldsResult.single, 280 | fields: fieldsResult.fields, 281 | alias, 282 | asStr, 283 | where: fieldsResult.where, 284 | having: fieldsResult.having, 285 | groupBy: fieldsResult.groupBy, 286 | orderBy: fieldsResult.orderBy, 287 | limit: fieldsResult.limit, 288 | offset: fieldsResult.offset, 289 | } 290 | trimUndefined(table) 291 | return { table, rest } 292 | } 293 | 294 | function parseFields(tokens: Token.Any[], tableName: string) { 295 | let rest = tokens 296 | 297 | const openBracketResult = parseOpenBracket( 298 | rest, 299 | `open bracket for table "${tableName}"`, 300 | ) 301 | rest = openBracketResult.rest 302 | const { closeBracket, single } = openBracketResult 303 | 304 | const fields: AST.Field[] = [] 305 | 306 | function popField(message: string) { 307 | const field = fields.pop() 308 | if (!field) { 309 | throw new Error(message) 310 | } 311 | return field 312 | } 313 | 314 | for (;;) { 315 | if (rest.length === 0) { 316 | throw new Error( 317 | `missing close bracket "${closeBracket}" for table "${tableName}"`, 318 | ) 319 | } 320 | 321 | const token = rest[0] 322 | 323 | if (token.type === 'symbol' && token.value === closeBracket) { 324 | rest = rest.slice(1) 325 | break 326 | } 327 | 328 | if (token.type === 'word') { 329 | const value = token.value 330 | rest = rest.slice(1) 331 | if (value.toLowerCase() === 'as') { 332 | const field = popField(`missing field name before "as" alias`) 333 | if (field.type === 'table') { 334 | throw new Error(`expected "as" alias after table "${field.name}"`) 335 | } 336 | const wordResult = parseWord(rest, `alias of ${toFieldName(field)}`) 337 | rest = wordResult.rest 338 | field.alias = wordResult.value 339 | if (value !== 'as') { 340 | field.asStr = value 341 | } 342 | fields.push(field) 343 | continue 344 | } 345 | fields.push({ type: 'column', name: value }) 346 | continue 347 | } 348 | 349 | if (token.type === 'symbol' && token.value === '(') { 350 | rest = rest.slice(1) 351 | rest = skipNewline(rest) 352 | 353 | const selectResult = parseSelect(rest) 354 | rest = selectResult.rest 355 | rest = skipNewline(rest) 356 | const select = selectResult.ast 357 | 358 | if (rest[0]?.type !== 'symbol' || rest[0].value !== ')') { 359 | throw new Error( 360 | `missing close bracket ")" after inline select sub-query`, 361 | ) 362 | } 363 | rest = rest.slice(1) 364 | rest = skipNewline(rest) 365 | 366 | let asStr: string | undefined 367 | let alias: string | undefined 368 | if (isWord(rest[0], 'as')) { 369 | asStr = remarkStr(rest[0], 'as') 370 | rest = rest.slice(1) 371 | const wordResult = parseWord(rest, `alias of inline select sub-query`) 372 | rest = wordResult.rest 373 | alias = wordResult.value 374 | } 375 | const field: AST.SubQuery = { type: 'subQuery', alias, asStr, select } 376 | trimUndefined(field) 377 | fields.push(field) 378 | continue 379 | } 380 | 381 | if ( 382 | token.type === 'newline' || 383 | (token.type === 'symbol' && token.value === ',') 384 | ) { 385 | rest = rest.slice(1) 386 | continue 387 | } 388 | 389 | if (isOpenBracket(token)) { 390 | const field = popField( 391 | `missing relation table name in fields of table "${tableName}"`, 392 | ) 393 | if (field.type !== 'column') { 394 | const open = (token as Token.Symbol).value 395 | const close = getCloseBracket(open) 396 | throw new Error( 397 | `missing "select " expression before "${open}fields${close}" expression`, 398 | ) 399 | } 400 | const fieldsResult = parseFields(rest, field.name) 401 | rest = fieldsResult.rest 402 | const table: AST.Table = { 403 | type: 'table', 404 | name: field.name, 405 | single: fieldsResult.single, 406 | fields: fieldsResult.fields, 407 | alias: field.alias, 408 | asStr: field.asStr, 409 | where: fieldsResult.where, 410 | having: fieldsResult.having, 411 | groupBy: fieldsResult.groupBy, 412 | orderBy: fieldsResult.orderBy, 413 | limit: fieldsResult.limit, 414 | offset: fieldsResult.offset, 415 | } 416 | trimUndefined(table) 417 | fields.push(table) 418 | continue 419 | } 420 | 421 | throw new Error( 422 | `expected table fields, got token: ${JSON.stringify(token)}`, 423 | ) 424 | } 425 | 426 | let where: AST.Where | undefined 427 | let having: AST.Having | undefined 428 | let groupBy: AST.GroupBy | undefined 429 | let orderBy: AST.OrderBy | undefined 430 | let limit: string | undefined 431 | let offset: string | undefined 432 | 433 | for (;;) { 434 | rest = skipNewline(rest) 435 | if (isWord(rest[0], 'where')) { 436 | const whereResult = parseWhere(rest, tableName) 437 | rest = whereResult.rest 438 | where = whereResult.where 439 | continue 440 | } 441 | if (isWord(rest[0], 'having')) { 442 | const havingResult = parseHaving(rest, tableName) 443 | rest = havingResult.rest 444 | having = havingResult.having 445 | continue 446 | } 447 | if (isWord(rest[0], 'group') && isWord(rest[1], 'by')) { 448 | const groupByResult = parseGroupBy(rest, tableName) 449 | rest = groupByResult.rest 450 | groupBy = groupByResult.groupBy 451 | continue 452 | } 453 | if (isWord(rest[0], 'order') && isWord(rest[1], 'by')) { 454 | const orderByResult = parseOrderBy(rest, tableName) 455 | rest = orderByResult.rest 456 | orderBy = orderByResult.orderBy 457 | continue 458 | } 459 | if (isWord(rest[0], 'limit')) { 460 | limit = takeWord(rest[0]) 461 | rest = rest.slice(1) 462 | const word = parseWord(rest, `"limit" after table "${tableName}"`) 463 | rest = word.rest 464 | limit += ' ' + word.value 465 | continue 466 | } 467 | if (isWord(rest[0], 'offset')) { 468 | offset = takeWord(rest[0]) 469 | rest = rest.slice(1) 470 | const word = parseWord(rest, `"offset" after table "${tableName}"`) 471 | rest = word.rest 472 | offset += ' ' + word.value 473 | continue 474 | } 475 | break 476 | } 477 | 478 | return { 479 | single, 480 | fields, 481 | rest, 482 | where, 483 | having, 484 | groupBy, 485 | orderBy, 486 | limit, 487 | offset, 488 | } 489 | } 490 | 491 | function toFieldName(field: AST.Field): string { 492 | return field.type === 'table' 493 | ? `table "${field.name}"` 494 | : field.type === 'column' 495 | ? `column "${field.name}"` 496 | : `sub-query of table "${ 497 | field.select.table.alias || field.select.table.name 498 | }"` 499 | } 500 | 501 | function parseWord(tokens: Token.Any[], name: string) { 502 | let rest = tokens 503 | for (;;) { 504 | if (rest.length === 0) { 505 | throw new Error(`missing ${name}`) 506 | } 507 | const token = rest[0] 508 | if (token.type === 'newline') { 509 | rest = rest.slice(1) 510 | continue 511 | } 512 | if (token.type === 'word') { 513 | return { value: token.value, rest: rest.slice(1) } 514 | } 515 | throw new Error(`expect ${name}, got: ${JSON.stringify(token)}`) 516 | } 517 | } 518 | 519 | function parseSymbol(tokens: Token.Any[], name: string) { 520 | let rest = tokens 521 | for (;;) { 522 | if (rest.length === 0) { 523 | throw new Error(`missing ${name}`) 524 | } 525 | const token = rest[0] 526 | if (token.type === 'newline') { 527 | rest = rest.slice(1) 528 | continue 529 | } 530 | if (token.type === 'symbol') { 531 | return { value: token.value, rest: rest.slice(1) } 532 | } 533 | throw new Error(`expect ${name} but got: ${JSON.stringify(token)}`) 534 | } 535 | } 536 | 537 | function isOpenBracket(token: Token.Any): boolean { 538 | return token.type === 'symbol' && (token.value === '{' || token.value === '[') 539 | } 540 | 541 | function getCloseBracket(openBracket: string): string { 542 | switch (openBracket) { 543 | case '[': 544 | return ']' 545 | case '{': 546 | return '}' 547 | default: 548 | throw new Error(`unexpected openBracket ${JSON.stringify(openBracket)}`) 549 | } 550 | } 551 | 552 | function parseOpenBracket(tokens: Token.Any[], name: string) { 553 | let rest = tokens 554 | 555 | const result = parseSymbol(rest, name) 556 | const openBracket = result.value 557 | rest = result.rest 558 | let single: boolean 559 | let closeBracket: string 560 | 561 | switch (openBracket) { 562 | case '[': { 563 | single = false 564 | closeBracket = ']' 565 | break 566 | } 567 | case '{': { 568 | single = true 569 | closeBracket = '}' 570 | break 571 | } 572 | default: { 573 | throw new Error( 574 | `expect "[" or "{" as ${name}, got: ${JSON.stringify(openBracket)}`, 575 | ) 576 | } 577 | } 578 | 579 | return { rest, single, openBracket, closeBracket } 580 | } 581 | 582 | function skipNewline(tokens: Token.Any[]) { 583 | let rest = tokens 584 | while (rest.length > 0 && rest[0].type === 'newline') { 585 | rest = rest.slice(1) 586 | } 587 | return rest 588 | } 589 | 590 | function parseWhere( 591 | tokens: Token.Any[], 592 | tableName: string, 593 | ): { rest: Token.Any[]; where: AST.Where } { 594 | let rest = tokens 595 | const whereStr = takeWord(rest[0], 'where') 596 | rest = rest.slice(1) 597 | const partResult = parseWhereExpr(rest, tableName) 598 | rest = partResult.rest 599 | const expr = partResult.expr 600 | const where: AST.Where = { expr } 601 | if (whereStr !== 'where') { 602 | where.whereStr = whereStr 603 | } 604 | return { rest, where } 605 | } 606 | 607 | function parseHaving( 608 | tokens: Token.Any[], 609 | tableName: string, 610 | ): { rest: Token.Any[]; having: AST.Where } { 611 | let rest = tokens 612 | const havingStr = takeWord(rest[0], 'having') 613 | rest = rest.slice(1) 614 | const partResult = parseWhereExpr(rest, tableName) 615 | rest = partResult.rest 616 | const expr = partResult.expr 617 | const having: AST.Having = { expr } 618 | if (havingStr !== 'having') { 619 | having.havingStr = havingStr 620 | } 621 | return { rest, having } 622 | } 623 | 624 | function parseWhereExpr( 625 | tokens: Token.Any[], 626 | tableName: string, 627 | ): { expr: AST.WhereExpr; rest: Token.Any[] } { 628 | let rest = tokens 629 | rest = skipNewline(rest) 630 | if (rest.length === 0) { 631 | throw new Error(`empty where statement after table "${tableName}"`) 632 | } 633 | 634 | if (isWord(rest[0], 'not')) { 635 | const notStr = remarkStr(rest[0], 'not') 636 | rest = rest.slice(1) 637 | const result = parseWhereExpr(rest, tableName) 638 | rest = result.rest 639 | let expr = result.expr 640 | expr = { type: 'not', expr } 641 | if (notStr) { 642 | expr.notStr = notStr 643 | } 644 | return { rest, expr } 645 | } 646 | 647 | let expr: AST.WhereExpr 648 | if (isSymbol(rest[0], '(')) { 649 | rest = rest.slice(1) 650 | const result = parseWhereExpr(rest, tableName) 651 | rest = result.rest 652 | rest = skipNewline(rest) 653 | if (!isSymbol(rest[0], ')')) { 654 | throw new Error( 655 | `missing close parenthesis in where statement after table "${tableName}"`, 656 | ) 657 | } 658 | rest = rest.slice(1) 659 | expr = { type: 'parenthesis', expr: result.expr } 660 | } else { 661 | const leftResult = parseWhereValueExpr(rest, { 662 | tableName, 663 | name: `left-hand side of where statement after table "${tableName}"`, 664 | }) 665 | rest = leftResult.rest 666 | const field = leftResult.value 667 | 668 | rest = skipNewline(rest) 669 | let not: string | undefined 670 | if (isWord(rest[0], 'not')) { 671 | not = takeWord(rest[0]) 672 | rest = rest.slice(1) 673 | rest = skipNewline(rest) 674 | } 675 | 676 | if (isWord(rest[0], 'between')) { 677 | const betweenStr = remarkStr(rest[0], 'between') 678 | rest = rest.slice(1) 679 | 680 | const leftResult = parseWord( 681 | rest, 682 | `left value of "between" statement after table "${tableName}"`, 683 | ) 684 | rest = leftResult.rest 685 | const left = leftResult.value 686 | 687 | rest = skipNewline(rest) 688 | const andStr = remarkStr(rest[0], 'and') 689 | rest = rest.slice(1) 690 | 691 | const rightResult = parseWord( 692 | rest, 693 | `right value of "between" statement after table "${tableName}"`, 694 | ) 695 | rest = rightResult.rest 696 | const right = rightResult.value 697 | 698 | expr = { 699 | type: 'between', 700 | betweenStr, 701 | expr: field, 702 | not, 703 | andStr, 704 | left, 705 | right, 706 | } 707 | trimUndefined(expr) 708 | } else if (isWord(rest[0], 'in')) { 709 | const inStr = remarkStr(rest[0], 'in') 710 | rest = rest.slice(1) 711 | 712 | const openBracketResult = parseSymbol(rest, 'open bracket after "in"') 713 | if (openBracketResult.value !== '(') { 714 | throw new Error( 715 | `expected open bracket after "in", got ${JSON.stringify(rest[0])}`, 716 | ) 717 | } 718 | rest = openBracketResult.rest 719 | rest = skipNewline(rest) 720 | 721 | const selectResult = parseSelect(rest) 722 | rest = selectResult.rest 723 | const select = selectResult.ast 724 | 725 | const closeBracketResult = parseSymbol(rest, 'close bracket after "in"') 726 | if (closeBracketResult.value !== ')') { 727 | throw new Error( 728 | `expected close bracket after "in", got ${JSON.stringify(rest[0])}`, 729 | ) 730 | } 731 | rest = closeBracketResult.rest 732 | 733 | expr = { 734 | type: 'in', 735 | inStr, 736 | not, 737 | expr: field, 738 | select, 739 | } 740 | trimUndefined(expr) 741 | } else { 742 | let op: string 743 | if (rest[0]?.type === 'symbol') { 744 | const opResult = parseSymbol( 745 | rest, 746 | `operator of where statement after table "${tableName}"`, 747 | ) 748 | rest = opResult.rest 749 | op = opResult.value 750 | } else { 751 | const opResult = parseWord( 752 | rest, 753 | `"like" or user-defined function of where statement after table "${tableName}"`, 754 | ) 755 | rest = opResult.rest 756 | op = opResult.value 757 | } 758 | if (not) { 759 | op = 'not ' + op 760 | } 761 | 762 | const rightResult = parseWhereValueExpr(rest, { 763 | tableName, 764 | name: `right-hand side of where statement after table "${tableName}"`, 765 | }) 766 | rest = rightResult.rest 767 | const right = rightResult.value 768 | 769 | expr = { 770 | type: 'compare', 771 | left: field, 772 | op, 773 | right, 774 | } 775 | } 776 | } 777 | 778 | rest = skipNewline(rest) 779 | 780 | check_logic: while (rest.length > 0 && rest[0].type === 'word') { 781 | const word = rest[0].value.toLowerCase() 782 | switch (word) { 783 | case 'and': 784 | case 'or': { 785 | const op = rest[0].value 786 | rest = rest.slice(1) 787 | const exprResult = parseWhereExpr(rest, tableName) 788 | rest = exprResult.rest 789 | expr = { type: 'compare', left: expr, op, right: exprResult.expr } 790 | rest = skipNewline(rest) 791 | continue 792 | } 793 | default: 794 | break check_logic 795 | } 796 | } 797 | 798 | return { expr, rest } 799 | } 800 | 801 | function isWord(token: Token.Any | undefined, word: string) { 802 | return ( 803 | token && 804 | token.type === 'word' && 805 | token.value.toLowerCase() === word.toLowerCase() 806 | ) 807 | } 808 | 809 | function takeWord(token: Token.Any | undefined, word?: string) { 810 | if (token && token.type === 'word') { 811 | if (word && token.value.toLowerCase() !== word) { 812 | throw new Error( 813 | `assert word to be "${word}", but got: ${JSON.stringify(token.value)}`, 814 | ) 815 | } 816 | return token.value 817 | } 818 | throw new Error(`assert token to be word, but got: ${JSON.stringify(token)}`) 819 | } 820 | 821 | function isSymbol(token: Token.Any | undefined, symbol: string) { 822 | return ( 823 | token && token.type === 'symbol' && token.value.toLowerCase() === symbol 824 | ) 825 | } 826 | 827 | function remarkStr(word: Token.Any, expect: string): string | undefined { 828 | if (word.type === 'word' && word.value !== expect) { 829 | return word.value 830 | } 831 | } 832 | 833 | function parseGroupBy( 834 | tokens: Token.Any[], 835 | tableName: string, 836 | ): { rest: Token.Any[]; groupBy: AST.GroupBy } { 837 | let rest = skipNewline(tokens) 838 | 839 | const groupByStr = takeWord(rest[0], 'group') + ' ' + takeWord(rest[1], 'by') 840 | rest = rest.slice(2) 841 | rest = skipNewline(rest) 842 | 843 | if (rest.length === 0) { 844 | throw new Error(`empty "group by" statement after table "${tableName}"`) 845 | } 846 | 847 | const fields: string[] = [] 848 | 849 | const word = parseWord( 850 | rest, 851 | `first field name of "group by" statement after table "${tableName}"`, 852 | ) 853 | rest = word.rest 854 | fields.push(word.value) 855 | for (; rest.length > 0; ) { 856 | rest = skipNewline(rest) 857 | if (!isSymbol(rest[0], ',')) { 858 | break 859 | } 860 | rest = rest.slice(1) 861 | const word = parseWord( 862 | rest, 863 | `more field name of "group by" statement after table "${tableName}"`, 864 | ) 865 | rest = word.rest 866 | fields.push(word.value) 867 | } 868 | 869 | const ast: AST.GroupBy = { fields } 870 | 871 | if (groupByStr !== 'group by') { 872 | ast.groupByStr = groupByStr 873 | } 874 | 875 | return { rest, groupBy: ast } 876 | } 877 | 878 | function parseOrderBy( 879 | tokens: Token.Any[], 880 | tableName: string, 881 | ): { rest: Token.Any[]; orderBy: AST.OrderBy } { 882 | let rest = tokens 883 | const orderByStr = takeWord(rest[0], 'order') + ' ' + takeWord(rest[1], 'by') 884 | rest = rest.slice(2) 885 | rest = skipNewline(rest) 886 | 887 | if (rest.length === 0) { 888 | throw new Error(`empty "order by" statement after table "${tableName}"`) 889 | } 890 | 891 | const fields: AST.OrderByField[] = [] 892 | 893 | for (;;) { 894 | const word = parseWord( 895 | rest, 896 | `field name of "order by" statement after table "${tableName}"`, 897 | ) 898 | rest = word.rest 899 | rest = skipNewline(rest) 900 | const orders: string[] = [] 901 | if (isWord(rest[0], 'collate')) { 902 | orders.push(takeWord(rest[0])) 903 | orders.push(takeWord(rest[1])) 904 | rest = rest.slice(2) 905 | rest = skipNewline(rest) 906 | } 907 | if (isWord(rest[0], 'asc') || isWord(rest[0], 'desc')) { 908 | orders.push(takeWord(rest[0])) 909 | rest = rest.slice(1) 910 | rest = skipNewline(rest) 911 | if ( 912 | isWord(rest[0], 'nulls') && 913 | (isWord(rest[1], 'first') || isWord(rest[1], 'last')) 914 | ) { 915 | orders.push(takeWord(rest[0])) 916 | orders.push(takeWord(rest[1])) 917 | rest = rest.slice(2) 918 | rest = skipNewline(rest) 919 | } 920 | } 921 | const field: AST.OrderByField = { name: word.value } 922 | if (orders.length > 0) { 923 | field.order = orders.join(' ') 924 | } 925 | fields.push(field) 926 | 927 | if (isSymbol(rest[0], ',')) { 928 | rest = rest.slice(1) 929 | continue 930 | } 931 | break 932 | } 933 | 934 | const ast: AST.OrderBy = { fields } 935 | 936 | if (orderByStr !== 'order by') { 937 | ast.orderByStr = orderByStr 938 | } 939 | 940 | return { rest, orderBy: ast } 941 | } 942 | 943 | function trimUndefined(ast: T) { 944 | for (const key in ast) { 945 | if (ast[key] === undefined) { 946 | delete ast[key] 947 | } 948 | } 949 | } 950 | 951 | function parseWhereValueExpr( 952 | tokens: Token.Any[], 953 | options: { tableName: string; name: string }, 954 | ): { rest: Token.Any[]; value: AST.WhereValueExpr } { 955 | const { tableName, name } = options 956 | let rest = skipNewline(tokens) 957 | 958 | if (isWord(rest[0], 'select')) { 959 | const result = parseSelect(rest) 960 | rest = result.rest 961 | return { rest, value: result.ast } 962 | } 963 | 964 | if (isSymbol(rest[0], '(')) { 965 | rest = rest.slice(1) 966 | const result = parseWhereValueExpr(rest, options) 967 | rest = result.rest 968 | rest = skipNewline(rest) 969 | if (!isSymbol(rest[0], ')')) { 970 | throw new Error( 971 | `missing close parenthesis in where statement after table "${tableName}"`, 972 | ) 973 | } 974 | rest = rest.slice(1) 975 | return { rest, value: { type: 'parenthesis', expr: result.value } } 976 | } 977 | 978 | return parseWord(rest, name) 979 | } 980 | -------------------------------------------------------------------------------- /test/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beenotung/better-sql/a3a24e69bbb5056201f261cdd0f9657c18235288/test/index.ts -------------------------------------------------------------------------------- /test/lang.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { AST, decode } from '../src/parse' 3 | import { generateSQL, nameWithTablePrefix } from '../src/code-gen' 4 | 5 | function expectAST(actual: T, expected: T) { 6 | expect(actual).to.deep.equals(expected) 7 | } 8 | 9 | describe('language TestSuit', () => { 10 | context('select expression', () => { 11 | context('single/multi row', () => { 12 | it('should parse multi-row select expression', () => { 13 | let query = `select user [ id ]` 14 | let ast = decode(query) 15 | expectAST(ast, { 16 | type: 'select', 17 | table: { 18 | type: 'table', 19 | name: 'user', 20 | single: false, 21 | fields: [{ type: 'column', name: 'id' }], 22 | }, 23 | }) 24 | let sql = generateSQL(ast) 25 | expect(sql).to.equals(/* sql */ ` 26 | select 27 | user.id 28 | from user 29 | `) 30 | }) 31 | 32 | it('should parse single-row select expression', () => { 33 | let query = `select user { id }` 34 | let ast = decode(query) 35 | expectAST(ast, { 36 | type: 'select', 37 | table: { 38 | type: 'table', 39 | name: 'user', 40 | single: true, 41 | fields: [{ type: 'column', name: 'id' }], 42 | }, 43 | }) 44 | let sql = generateSQL(ast) 45 | expect(sql).to.equals(/* sql */ ` 46 | select 47 | user.id 48 | from user 49 | limit 1 50 | `) 51 | }) 52 | }) 53 | 54 | context('inline/multi-line expression', () => { 55 | it('should parse inline expression', () => { 56 | let query = `select user { id, nickname }` 57 | let ast = decode(query) 58 | expectAST(ast, { 59 | type: 'select', 60 | table: { 61 | type: 'table', 62 | name: 'user', 63 | single: true, 64 | fields: [ 65 | { type: 'column', name: 'id' }, 66 | { type: 'column', name: 'nickname' }, 67 | ], 68 | }, 69 | }) 70 | let sql = generateSQL(ast) 71 | expect(sql).to.equals(/* sql */ ` 72 | select 73 | user.id 74 | , user.nickname 75 | from user 76 | limit 1 77 | `) 78 | }) 79 | 80 | it('should parse multi-line expression', () => { 81 | let query = ` 82 | select user { 83 | id 84 | nickname 85 | } 86 | ` 87 | let ast = decode(query) 88 | expectAST(ast, { 89 | type: 'select', 90 | table: { 91 | type: 'table', 92 | name: 'user', 93 | single: true, 94 | fields: [ 95 | { type: 'column', name: 'id' }, 96 | { type: 'column', name: 'nickname' }, 97 | ], 98 | }, 99 | }) 100 | let sql = generateSQL(ast) 101 | expect(sql).to.equals(/* sql */ ` 102 | select 103 | user.id 104 | , user.nickname 105 | from user 106 | limit 1 107 | `) 108 | }) 109 | }) 110 | 111 | context('nested select expression', () => { 112 | it('should parse single inner join select', () => { 113 | let query = ` 114 | select post [ 115 | title 116 | author { 117 | nickname 118 | } 119 | ] 120 | ` 121 | let ast = decode(query) 122 | expectAST(ast, { 123 | type: 'select', 124 | table: { 125 | type: 'table', 126 | name: 'post', 127 | single: false, 128 | fields: [ 129 | { type: 'column', name: 'title' }, 130 | { 131 | type: 'table', 132 | name: 'author', 133 | single: true, 134 | fields: [{ type: 'column', name: 'nickname' }], 135 | }, 136 | ], 137 | }, 138 | }) 139 | let sql = generateSQL(ast) 140 | expect(sql).to.equals(/* sql */ ` 141 | select 142 | post.title 143 | , author.nickname 144 | from post 145 | inner join author on author.id = post.author_id 146 | `) 147 | }) 148 | 149 | it('should parse single left join select', () => { 150 | let query = ` 151 | select post [ 152 | title 153 | author [ 154 | nickname 155 | ] 156 | ] 157 | ` 158 | let ast = decode(query) 159 | expectAST(ast, { 160 | type: 'select', 161 | table: { 162 | type: 'table', 163 | name: 'post', 164 | single: false, 165 | fields: [ 166 | { type: 'column', name: 'title' }, 167 | { 168 | type: 'table', 169 | name: 'author', 170 | single: false, 171 | fields: [{ type: 'column', name: 'nickname' }], 172 | }, 173 | ], 174 | }, 175 | }) 176 | let sql = generateSQL(ast) 177 | expect(sql).to.equals(/* sql */ ` 178 | select 179 | post.title 180 | , author.nickname 181 | from post 182 | left join author on author.id = post.author_id 183 | `) 184 | }) 185 | 186 | it('should parse multi-nested inner join select', () => { 187 | let query = ` 188 | select cart [ 189 | user_id 190 | user { 191 | nickname 192 | } 193 | product_id 194 | product { 195 | price 196 | shop { 197 | name 198 | } 199 | } 200 | ] 201 | ` 202 | let ast = decode(query) 203 | expectAST(ast, { 204 | type: 'select', 205 | table: { 206 | type: 'table', 207 | name: 'cart', 208 | single: false, 209 | fields: [ 210 | { type: 'column', name: 'user_id' }, 211 | { 212 | type: 'table', 213 | name: 'user', 214 | single: true, 215 | fields: [{ type: 'column', name: 'nickname' }], 216 | }, 217 | { type: 'column', name: 'product_id' }, 218 | { 219 | type: 'table', 220 | name: 'product', 221 | single: true, 222 | fields: [ 223 | { type: 'column', name: 'price' }, 224 | { 225 | type: 'table', 226 | name: 'shop', 227 | single: true, 228 | fields: [{ type: 'column', name: 'name' }], 229 | }, 230 | ], 231 | }, 232 | ], 233 | }, 234 | }) 235 | let sql = generateSQL(ast) 236 | expect(sql).to.equals(/* sql */ ` 237 | select 238 | cart.user_id 239 | , user.nickname 240 | , cart.product_id 241 | , product.price 242 | , shop.name 243 | from cart 244 | inner join user on user.id = cart.user_id 245 | inner join product on product.id = cart.product_id 246 | inner join shop on shop.id = product.shop_id 247 | `) 248 | }) 249 | }) 250 | 251 | context('select column with alias', () => { 252 | it('should parse multi-line column alias', () => { 253 | let query = ` 254 | select post [ 255 | id 256 | title as post_title 257 | author_id 258 | ] 259 | ` 260 | let ast = decode(query) 261 | expectAST(ast, { 262 | type: 'select', 263 | table: { 264 | type: 'table', 265 | name: 'post', 266 | single: false, 267 | fields: [ 268 | { type: 'column', name: 'id' }, 269 | { type: 'column', name: 'title', alias: 'post_title' }, 270 | { type: 'column', name: 'author_id' }, 271 | ], 272 | }, 273 | }) 274 | let sql = generateSQL(ast) 275 | expect(sql).to.equals(/* sql */ ` 276 | select 277 | post.id 278 | , post.title as post_title 279 | , post.author_id 280 | from post 281 | `) 282 | }) 283 | 284 | it('should parse inline column alias', () => { 285 | let query = `select post [ id, title as post_title, author_id ]` 286 | let ast = decode(query) 287 | expectAST(ast, { 288 | type: 'select', 289 | table: { 290 | type: 'table', 291 | name: 'post', 292 | single: false, 293 | fields: [ 294 | { type: 'column', name: 'id' }, 295 | { type: 'column', name: 'title', alias: 'post_title' }, 296 | { type: 'column', name: 'author_id' }, 297 | ], 298 | }, 299 | }) 300 | let sql = generateSQL(ast) 301 | expect(sql).to.equals(/* sql */ ` 302 | select 303 | post.id 304 | , post.title as post_title 305 | , post.author_id 306 | from post 307 | `) 308 | }) 309 | 310 | it('should parse column alias in nested select', () => { 311 | let query = ` 312 | select post [ 313 | id 314 | title as post_title 315 | author { 316 | nickname as author 317 | } 318 | ] 319 | ` 320 | let ast = decode(query) 321 | expectAST(ast, { 322 | type: 'select', 323 | table: { 324 | type: 'table', 325 | name: 'post', 326 | single: false, 327 | fields: [ 328 | { type: 'column', name: 'id' }, 329 | { type: 'column', name: 'title', alias: 'post_title' }, 330 | { 331 | type: 'table', 332 | name: 'author', 333 | single: true, 334 | fields: [{ type: 'column', name: 'nickname', alias: 'author' }], 335 | }, 336 | ], 337 | }, 338 | }) 339 | let sql = generateSQL(ast) 340 | expect(sql).to.equals(/* sql */ ` 341 | select 342 | post.id 343 | , post.title as post_title 344 | , author.nickname as author 345 | from post 346 | inner join author on author.id = post.author_id 347 | `) 348 | }) 349 | }) 350 | 351 | context('join table with alias', () => { 352 | it('should parse table name alias', () => { 353 | let query = `select thread as post [ id ]` 354 | let ast = decode(query) 355 | expectAST(ast, { 356 | type: 'select', 357 | table: { 358 | type: 'table', 359 | name: 'thread', 360 | single: false, 361 | alias: 'post', 362 | fields: [{ type: 'column', name: 'id' }], 363 | }, 364 | }) 365 | let sql = generateSQL(ast) 366 | expect(sql).to.equals(/* sql */ ` 367 | select 368 | post.id 369 | from thread as post 370 | `) 371 | }) 372 | 373 | it('should parse nested table name alias', () => { 374 | let query = ` 375 | select thread as post [ 376 | id 377 | user as author { 378 | username 379 | id as author_id 380 | is_admin 381 | } 382 | title 383 | ] 384 | ` 385 | let ast = decode(query) 386 | expectAST(ast, { 387 | type: 'select', 388 | table: { 389 | type: 'table', 390 | name: 'thread', 391 | single: false, 392 | alias: 'post', 393 | fields: [ 394 | { type: 'column', name: 'id' }, 395 | { 396 | type: 'table', 397 | name: 'user', 398 | single: true, 399 | alias: 'author', 400 | fields: [ 401 | { type: 'column', name: 'username' }, 402 | { type: 'column', name: 'id', alias: 'author_id' }, 403 | { type: 'column', name: 'is_admin' }, 404 | ], 405 | }, 406 | { type: 'column', name: 'title' }, 407 | ], 408 | }, 409 | }) 410 | let sql = generateSQL(ast) 411 | expect(sql).to.equals(/* sql */ ` 412 | select 413 | post.id 414 | , author.username 415 | , author.id as author_id 416 | , author.is_admin 417 | , post.title 418 | from thread as post 419 | inner join user as author on author.id = post.author_id 420 | `) 421 | }) 422 | }) 423 | 424 | context('where statement', () => { 425 | context('where condition on single column with literal value', () => { 426 | context('tailing where condition after table fields', () => { 427 | let ast: AST.Select = { 428 | type: 'select', 429 | table: { 430 | type: 'table', 431 | name: 'user', 432 | single: false, 433 | fields: [ 434 | { type: 'column', name: 'id' }, 435 | { type: 'column', name: 'username' }, 436 | ], 437 | where: { 438 | expr: { 439 | type: 'compare', 440 | left: 'is_admin', 441 | op: '=', 442 | right: '1', 443 | }, 444 | }, 445 | }, 446 | } 447 | 448 | it('should parse inline where condition', () => { 449 | let query = ` 450 | select user [ 451 | id 452 | username 453 | ] where is_admin = 1 454 | ` 455 | expect(decode(query)).to.deep.equals(ast) 456 | let sql = generateSQL(ast) 457 | expect(sql).to.equals(/* sql */ ` 458 | select 459 | user.id 460 | , user.username 461 | from user 462 | where user.is_admin = 1 463 | `) 464 | }) 465 | 466 | it('should parse multiline where condition', () => { 467 | let query = ` 468 | select user [ 469 | id 470 | username 471 | ] 472 | where is_admin = 1 473 | ` 474 | expect(decode(query)).to.deep.equals(ast) 475 | let sql = generateSQL(ast) 476 | expect(sql).to.equals(/* sql */ ` 477 | select 478 | user.id 479 | , user.username 480 | from user 481 | where user.is_admin = 1 482 | `) 483 | }) 484 | }) 485 | 486 | context('where condition in nested table fields', () => { 487 | let ast: AST.Select = { 488 | type: 'select', 489 | table: { 490 | type: 'table', 491 | name: 'post', 492 | single: false, 493 | fields: [ 494 | { type: 'column', name: 'id' }, 495 | { 496 | type: 'table', 497 | name: 'author', 498 | single: true, 499 | fields: [{ type: 'column', name: 'nickname' }], 500 | where: { 501 | expr: { 502 | type: 'compare', 503 | left: 'is_admin', 504 | op: '=', 505 | right: '1', 506 | }, 507 | }, 508 | }, 509 | { type: 'column', name: 'title' }, 510 | ], 511 | where: { 512 | expr: { 513 | type: 'compare', 514 | left: 'delete_time', 515 | op: 'is', 516 | right: 'null', 517 | }, 518 | }, 519 | }, 520 | } 521 | 522 | it('should parse nested inline where condition', () => { 523 | let query = ` 524 | select post [ 525 | id 526 | author { 527 | nickname 528 | } where is_admin = 1 529 | title 530 | ] where delete_time is null 531 | ` 532 | expect(decode(query)).to.deep.equals(ast) 533 | let sql = generateSQL(ast) 534 | expect(sql).to.equals(/* sql */ ` 535 | select 536 | post.id 537 | , author.nickname 538 | , post.title 539 | from post 540 | inner join author on author.id = post.author_id 541 | where author.is_admin = 1 542 | and post.delete_time is null 543 | `) 544 | }) 545 | }) 546 | }) 547 | 548 | context('where condition with "between" expression', () => { 549 | it('should parse "between" where condition', () => { 550 | let query = ` 551 | select post [ 552 | title 553 | ] where publish_time between '2022-01-01' and '2022-12-31' 554 | ` 555 | let ast = decode(query) 556 | expectAST(ast, { 557 | type: 'select', 558 | table: { 559 | type: 'table', 560 | name: 'post', 561 | single: false, 562 | fields: [{ type: 'column', name: 'title' }], 563 | where: { 564 | expr: { 565 | type: 'between', 566 | expr: 'publish_time', 567 | left: "'2022-01-01'", 568 | right: "'2022-12-31'", 569 | }, 570 | }, 571 | }, 572 | }) 573 | let sql = generateSQL(ast) 574 | expect(sql).to.equals(/* sql */ ` 575 | select 576 | post.title 577 | from post 578 | where post.publish_time between '2022-01-01' and '2022-12-31' 579 | `) 580 | }) 581 | 582 | it('should parse "not between" where condition', () => { 583 | let query = ` 584 | select post [ 585 | title 586 | ] where publish_time not between '2022-01-01' and '2022-12-31' 587 | ` 588 | let ast = decode(query) 589 | expectAST(ast, { 590 | type: 'select', 591 | table: { 592 | type: 'table', 593 | name: 'post', 594 | single: false, 595 | fields: [{ type: 'column', name: 'title' }], 596 | where: { 597 | expr: { 598 | type: 'between', 599 | not: 'not', 600 | expr: 'publish_time', 601 | left: "'2022-01-01'", 602 | right: "'2022-12-31'", 603 | }, 604 | }, 605 | }, 606 | }) 607 | let sql = generateSQL(ast) 608 | expect(sql).to.equals(/* sql */ ` 609 | select 610 | post.title 611 | from post 612 | where post.publish_time not between '2022-01-01' and '2022-12-31' 613 | `) 614 | }) 615 | }) 616 | 617 | context('where condition with variables', () => { 618 | function test(variable: string) { 619 | it(`should parse "${variable}"`, () => { 620 | let query = ` 621 | select post [ 622 | id 623 | title 624 | ] where user_id = ${variable} 625 | ` 626 | let ast = decode(query) 627 | expectAST(ast, { 628 | type: 'select', 629 | table: { 630 | type: 'table', 631 | name: 'post', 632 | single: false, 633 | fields: [ 634 | { type: 'column', name: 'id' }, 635 | { type: 'column', name: 'title' }, 636 | ], 637 | where: { 638 | expr: { 639 | type: 'compare', 640 | left: 'user_id', 641 | op: '=', 642 | right: variable, 643 | }, 644 | }, 645 | }, 646 | }) 647 | let sql = generateSQL(ast) 648 | expect(sql).to.equals(/* sql */ ` 649 | select 650 | post.id 651 | , post.title 652 | from post 653 | where post.user_id = ${variable} 654 | `) 655 | }) 656 | } 657 | test(':user_id') 658 | test('$user_id') 659 | test('@user_id') 660 | test('?') 661 | }) 662 | 663 | context('where condition on multiple column', () => { 664 | it('should parse multiple column where statement with "and" logic on single table', () => { 665 | let query = ` 666 | select post [ 667 | id 668 | title 669 | ] 670 | where delete_time is null 671 | and user_id = ? 672 | ` 673 | let ast = decode(query) 674 | expectAST(ast, { 675 | type: 'select', 676 | table: { 677 | type: 'table', 678 | name: 'post', 679 | single: false, 680 | fields: [ 681 | { type: 'column', name: 'id' }, 682 | { type: 'column', name: 'title' }, 683 | ], 684 | where: { 685 | expr: { 686 | type: 'compare', 687 | left: { 688 | type: 'compare', 689 | left: 'delete_time', 690 | op: 'is', 691 | right: 'null', 692 | }, 693 | op: 'and', 694 | right: { 695 | type: 'compare', 696 | left: 'user_id', 697 | op: '=', 698 | right: '?', 699 | }, 700 | }, 701 | }, 702 | }, 703 | }) 704 | let sql = generateSQL(ast) 705 | expect(sql).to.equals(/* sql */ ` 706 | select 707 | post.id 708 | , post.title 709 | from post 710 | where post.delete_time is null 711 | and post.user_id = ? 712 | `) 713 | }) 714 | 715 | it('should parse multiple column where statement with "and" logic on nested table', () => { 716 | let query = ` 717 | select post [ 718 | id 719 | title 720 | author { 721 | nickname 722 | } where is_admin = 1 723 | ] 724 | where delete_time is null 725 | and user_id = ? 726 | ` 727 | let ast = decode(query) 728 | expectAST(ast, { 729 | type: 'select', 730 | table: { 731 | type: 'table', 732 | name: 'post', 733 | single: false, 734 | fields: [ 735 | { type: 'column', name: 'id' }, 736 | { type: 'column', name: 'title' }, 737 | { 738 | type: 'table', 739 | name: 'author', 740 | single: true, 741 | fields: [{ type: 'column', name: 'nickname' }], 742 | where: { 743 | expr: { 744 | type: 'compare', 745 | left: 'is_admin', 746 | op: '=', 747 | right: '1', 748 | }, 749 | }, 750 | }, 751 | ], 752 | where: { 753 | expr: { 754 | type: 'compare', 755 | left: { 756 | type: 'compare', 757 | left: 'delete_time', 758 | op: 'is', 759 | right: 'null', 760 | }, 761 | op: 'and', 762 | right: { 763 | type: 'compare', 764 | left: 'user_id', 765 | op: '=', 766 | right: '?', 767 | }, 768 | }, 769 | }, 770 | }, 771 | }) 772 | let sql = generateSQL(ast) 773 | expect(sql).to.equals(/* sql */ ` 774 | select 775 | post.id 776 | , post.title 777 | , author.nickname 778 | from post 779 | inner join author on author.id = post.author_id 780 | where author.is_admin = 1 781 | and post.delete_time is null 782 | and post.user_id = ? 783 | `) 784 | }) 785 | }) 786 | 787 | context('where condition with "or" logic', () => { 788 | it('should parse "or" logic on single table', () => { 789 | let query = ` 790 | select post [ 791 | id 792 | title 793 | ] 794 | where type_id = 1 795 | or type_id = 2 796 | ` 797 | let ast = decode(query) 798 | expectAST(ast, { 799 | type: 'select', 800 | table: { 801 | type: 'table', 802 | name: 'post', 803 | single: false, 804 | fields: [ 805 | { type: 'column', name: 'id' }, 806 | { type: 'column', name: 'title' }, 807 | ], 808 | where: { 809 | expr: { 810 | type: 'compare', 811 | left: { 812 | type: 'compare', 813 | left: 'type_id', 814 | op: '=', 815 | right: '1', 816 | }, 817 | op: 'or', 818 | right: { 819 | type: 'compare', 820 | left: 'type_id', 821 | op: '=', 822 | right: '2', 823 | }, 824 | }, 825 | }, 826 | }, 827 | }) 828 | let sql = generateSQL(ast) 829 | expect(sql).to.equals(/* sql */ ` 830 | select 831 | post.id 832 | , post.title 833 | from post 834 | where post.type_id = 1 835 | or post.type_id = 2 836 | `) 837 | }) 838 | 839 | it('should parse "or" logic on nested table select', () => { 840 | let query = ` 841 | select post [ 842 | id 843 | author { 844 | nickname 845 | } 846 | where is_admin = 1 847 | or is_editor = 1 848 | title 849 | ] 850 | where type_id = 1 851 | or type_id = 2 852 | ` 853 | let ast = decode(query) 854 | expectAST(ast, { 855 | type: 'select', 856 | table: { 857 | type: 'table', 858 | name: 'post', 859 | single: false, 860 | fields: [ 861 | { type: 'column', name: 'id' }, 862 | { 863 | type: 'table', 864 | name: 'author', 865 | single: true, 866 | fields: [{ type: 'column', name: 'nickname' }], 867 | where: { 868 | expr: { 869 | type: 'compare', 870 | left: { 871 | type: 'compare', 872 | left: 'is_admin', 873 | op: '=', 874 | right: '1', 875 | }, 876 | op: 'or', 877 | right: { 878 | type: 'compare', 879 | left: 'is_editor', 880 | op: '=', 881 | right: '1', 882 | }, 883 | }, 884 | }, 885 | }, 886 | { type: 'column', name: 'title' }, 887 | ], 888 | where: { 889 | expr: { 890 | type: 'compare', 891 | left: { 892 | type: 'compare', 893 | left: 'type_id', 894 | op: '=', 895 | right: '1', 896 | }, 897 | op: 'or', 898 | right: { 899 | type: 'compare', 900 | left: 'type_id', 901 | op: '=', 902 | right: '2', 903 | }, 904 | }, 905 | }, 906 | }, 907 | }) 908 | let sql = generateSQL(ast) 909 | expect(sql).to.equals(/* sql */ ` 910 | select 911 | post.id 912 | , author.nickname 913 | , post.title 914 | from post 915 | inner join author on author.id = post.author_id 916 | where (author.is_admin = 1 917 | or author.is_editor = 1) 918 | and (post.type_id = 1 919 | or post.type_id = 2) 920 | `) 921 | }) 922 | }) 923 | 924 | context('where condition with "not" logic', () => { 925 | it('should parse where condition with "not" logic on single column', () => { 926 | let query = ` 927 | select post [ 928 | id 929 | title 930 | ] 931 | where not type_id = 1 932 | ` 933 | let ast = decode(query) 934 | expectAST(ast, { 935 | type: 'select', 936 | table: { 937 | type: 'table', 938 | name: 'post', 939 | single: false, 940 | fields: [ 941 | { type: 'column', name: 'id' }, 942 | { type: 'column', name: 'title' }, 943 | ], 944 | where: { 945 | expr: { 946 | type: 'not', 947 | expr: { 948 | type: 'compare', 949 | left: 'type_id', 950 | op: '=', 951 | right: '1', 952 | }, 953 | }, 954 | }, 955 | }, 956 | }) 957 | let sql = generateSQL(ast) 958 | expect(sql).to.equals(/* sql */ ` 959 | select 960 | post.id 961 | , post.title 962 | from post 963 | where not post.type_id = 1 964 | `) 965 | }) 966 | }) 967 | 968 | context('where condition with parenthesis', () => { 969 | it('should parse single parenthesis group', () => { 970 | let query = ` 971 | select post [ 972 | id 973 | title 974 | ] where (type_id = 1) 975 | ` 976 | let ast = decode(query) 977 | expectAST(ast, { 978 | type: 'select', 979 | table: { 980 | type: 'table', 981 | name: 'post', 982 | single: false, 983 | fields: [ 984 | { type: 'column', name: 'id' }, 985 | { type: 'column', name: 'title' }, 986 | ], 987 | where: { 988 | expr: { 989 | type: 'parenthesis', 990 | expr: { 991 | type: 'compare', 992 | left: 'type_id', 993 | op: '=', 994 | right: '1', 995 | }, 996 | }, 997 | }, 998 | }, 999 | }) 1000 | let sql = generateSQL(ast) 1001 | expect(sql).to.equals(/* sql */ ` 1002 | select 1003 | post.id 1004 | , post.title 1005 | from post 1006 | where (post.type_id = 1) 1007 | `) 1008 | }) 1009 | 1010 | context('parenthesis around "not" logic', () => { 1011 | it('should parse parenthesis before "not" logic', () => { 1012 | let query = ` 1013 | select post [ 1014 | id 1015 | title 1016 | ] where (not type_id = 1) 1017 | ` 1018 | let ast = decode(query) 1019 | expectAST(ast, { 1020 | type: 'select', 1021 | table: { 1022 | type: 'table', 1023 | name: 'post', 1024 | single: false, 1025 | fields: [ 1026 | { type: 'column', name: 'id' }, 1027 | { type: 'column', name: 'title' }, 1028 | ], 1029 | where: { 1030 | expr: { 1031 | type: 'parenthesis', 1032 | expr: { 1033 | type: 'not', 1034 | expr: { 1035 | type: 'compare', 1036 | left: 'type_id', 1037 | op: '=', 1038 | right: '1', 1039 | }, 1040 | }, 1041 | }, 1042 | }, 1043 | }, 1044 | }) 1045 | let sql = generateSQL(ast) 1046 | expect(sql).to.equals(/* sql */ ` 1047 | select 1048 | post.id 1049 | , post.title 1050 | from post 1051 | where (not post.type_id = 1) 1052 | `) 1053 | }) 1054 | 1055 | it('should parse parenthesis after "not" logic', () => { 1056 | let query = ` 1057 | select post [ 1058 | id 1059 | title 1060 | ] where not (type_id = 1) 1061 | ` 1062 | let ast = decode(query) 1063 | expectAST(ast, { 1064 | type: 'select', 1065 | table: { 1066 | type: 'table', 1067 | name: 'post', 1068 | single: false, 1069 | fields: [ 1070 | { type: 'column', name: 'id' }, 1071 | { type: 'column', name: 'title' }, 1072 | ], 1073 | where: { 1074 | expr: { 1075 | type: 'not', 1076 | expr: { 1077 | type: 'parenthesis', 1078 | expr: { 1079 | type: 'compare', 1080 | left: 'type_id', 1081 | op: '=', 1082 | right: '1', 1083 | }, 1084 | }, 1085 | }, 1086 | }, 1087 | }, 1088 | }) 1089 | let sql = generateSQL(ast) 1090 | expect(sql).to.equals(/* sql */ ` 1091 | select 1092 | post.id 1093 | , post.title 1094 | from post 1095 | where not (post.type_id = 1) 1096 | `) 1097 | }) 1098 | }) 1099 | 1100 | it('should parse multi parenthesis groups', () => { 1101 | let query = ` 1102 | select post [ 1103 | id 1104 | title 1105 | ] where (delete_time is null or recover_time is not null) 1106 | and (type_id = 1 or type_id = 2) 1107 | ` 1108 | let ast = decode(query) 1109 | expectAST(ast, { 1110 | type: 'select', 1111 | table: { 1112 | type: 'table', 1113 | name: 'post', 1114 | single: false, 1115 | fields: [ 1116 | { type: 'column', name: 'id' }, 1117 | { type: 'column', name: 'title' }, 1118 | ], 1119 | where: { 1120 | expr: { 1121 | type: 'compare', 1122 | left: { 1123 | type: 'parenthesis', 1124 | expr: { 1125 | type: 'compare', 1126 | left: { 1127 | type: 'compare', 1128 | left: 'delete_time', 1129 | op: 'is', 1130 | right: 'null', 1131 | }, 1132 | op: 'or', 1133 | right: { 1134 | type: 'compare', 1135 | left: 'recover_time', 1136 | op: 'is not', 1137 | right: 'null', 1138 | }, 1139 | }, 1140 | }, 1141 | op: 'and', 1142 | right: { 1143 | type: 'parenthesis', 1144 | expr: { 1145 | type: 'compare', 1146 | left: { 1147 | type: 'compare', 1148 | left: 'type_id', 1149 | op: '=', 1150 | right: '1', 1151 | }, 1152 | op: 'or', 1153 | right: { 1154 | type: 'compare', 1155 | left: 'type_id', 1156 | op: '=', 1157 | right: '2', 1158 | }, 1159 | }, 1160 | }, 1161 | }, 1162 | }, 1163 | }, 1164 | }) 1165 | let sql = generateSQL(ast) 1166 | expect(sql).to.equals(/* sql */ ` 1167 | select 1168 | post.id 1169 | , post.title 1170 | from post 1171 | where (post.delete_time is null 1172 | or post.recover_time is not null) 1173 | and (post.type_id = 1 1174 | or post.type_id = 2) 1175 | `) 1176 | }) 1177 | }) 1178 | 1179 | context('"like" and user-defined functions', () => { 1180 | function test(func: string) { 1181 | it(`should parse "${func}"`, () => { 1182 | let query = ` 1183 | select post [ 1184 | title 1185 | ] 1186 | where title ${func} :search 1187 | ` 1188 | let ast = decode(query) 1189 | expectAST(ast, { 1190 | type: 'select', 1191 | table: { 1192 | type: 'table', 1193 | name: 'post', 1194 | single: false, 1195 | fields: [{ type: 'column', name: 'title' }], 1196 | where: { 1197 | expr: { 1198 | type: 'compare', 1199 | left: 'title', 1200 | op: func, 1201 | right: ':search', 1202 | }, 1203 | }, 1204 | }, 1205 | }) 1206 | let sql = generateSQL(ast) 1207 | expect(sql).to.equals(/* sql */ ` 1208 | select 1209 | post.title 1210 | from post 1211 | where post.title ${func} :search 1212 | `) 1213 | }) 1214 | 1215 | it(`should parse "not ${func}"`, () => { 1216 | let query = ` 1217 | select post [ 1218 | title 1219 | ] 1220 | where title not ${func} :search 1221 | ` 1222 | let ast = decode(query) 1223 | expectAST(ast, { 1224 | type: 'select', 1225 | table: { 1226 | type: 'table', 1227 | name: 'post', 1228 | single: false, 1229 | fields: [{ type: 'column', name: 'title' }], 1230 | where: { 1231 | expr: { 1232 | type: 'compare', 1233 | left: 'title', 1234 | op: `not ${func}`, 1235 | right: ':search', 1236 | }, 1237 | }, 1238 | }, 1239 | }) 1240 | let sql = generateSQL(ast) 1241 | expect(sql).to.equals(/* sql */ ` 1242 | select 1243 | post.title 1244 | from post 1245 | where post.title not ${func} :search 1246 | `) 1247 | }) 1248 | } 1249 | test('like') 1250 | test('glob') 1251 | test('regexp') 1252 | test('match') 1253 | }) 1254 | }) 1255 | 1256 | it('should parse distinct select', () => { 1257 | let query = ` 1258 | select distinct post [ 1259 | title 1260 | version 1261 | ] 1262 | ` 1263 | let ast = decode(query) 1264 | expectAST(ast, { 1265 | type: 'select', 1266 | distinct: 'distinct', 1267 | table: { 1268 | type: 'table', 1269 | name: 'post', 1270 | single: false, 1271 | fields: [ 1272 | { type: 'column', name: 'title' }, 1273 | { type: 'column', name: 'version' }, 1274 | ], 1275 | }, 1276 | }) 1277 | let sql = generateSQL(ast) 1278 | expect(sql).to.equals(/* sql */ ` 1279 | select distinct 1280 | post.title 1281 | , post.version 1282 | from post 1283 | `) 1284 | }) 1285 | 1286 | context('group by statement', () => { 1287 | it('should parse single group by column on single table', () => { 1288 | let query = ` 1289 | select post [ 1290 | author_id 1291 | created_at 1292 | ] group by author_id 1293 | ` 1294 | let ast = decode(query) 1295 | expectAST(ast, { 1296 | type: 'select', 1297 | table: { 1298 | type: 'table', 1299 | name: 'post', 1300 | single: false, 1301 | fields: [ 1302 | { type: 'column', name: 'author_id' }, 1303 | { type: 'column', name: 'created_at' }, 1304 | ], 1305 | groupBy: { fields: ['author_id'] }, 1306 | }, 1307 | }) 1308 | let sql = generateSQL(ast) 1309 | expect(sql).to.equals(/* sql */ ` 1310 | select 1311 | post.author_id 1312 | , post.created_at 1313 | from post 1314 | group by 1315 | post.author_id 1316 | `) 1317 | }) 1318 | 1319 | it('should parse group by columns in nested tables', () => { 1320 | let query = ` 1321 | select post [ 1322 | author_id 1323 | created_at 1324 | author { 1325 | nickname 1326 | } group by cohort 1327 | ] group by type_id 1328 | ` 1329 | let ast = decode(query) 1330 | expectAST(ast, { 1331 | type: 'select', 1332 | table: { 1333 | type: 'table', 1334 | name: 'post', 1335 | single: false, 1336 | fields: [ 1337 | { type: 'column', name: 'author_id' }, 1338 | { type: 'column', name: 'created_at' }, 1339 | { 1340 | type: 'table', 1341 | name: 'author', 1342 | single: true, 1343 | fields: [{ type: 'column', name: 'nickname' }], 1344 | groupBy: { fields: ['cohort'] }, 1345 | }, 1346 | ], 1347 | groupBy: { fields: ['type_id'] }, 1348 | }, 1349 | }) 1350 | let sql = generateSQL(ast) 1351 | expect(sql).to.equals(/* sql */ ` 1352 | select 1353 | post.author_id 1354 | , post.created_at 1355 | , author.nickname 1356 | from post 1357 | inner join author on author.id = post.author_id 1358 | group by 1359 | author.cohort 1360 | , post.type_id 1361 | `) 1362 | }) 1363 | 1364 | it('should parse multi group by columns on single table', () => { 1365 | let query = ` 1366 | select post [ 1367 | author_id 1368 | created_at 1369 | ] group by author_id, version 1370 | ` 1371 | let ast = decode(query) 1372 | expectAST(ast, { 1373 | type: 'select', 1374 | table: { 1375 | type: 'table', 1376 | name: 'post', 1377 | single: false, 1378 | fields: [ 1379 | { type: 'column', name: 'author_id' }, 1380 | { type: 'column', name: 'created_at' }, 1381 | ], 1382 | groupBy: { fields: ['author_id', 'version'] }, 1383 | }, 1384 | }) 1385 | let sql = generateSQL(ast) 1386 | expect(sql).to.equals(/* sql */ ` 1387 | select 1388 | post.author_id 1389 | , post.created_at 1390 | from post 1391 | group by 1392 | post.author_id 1393 | , post.version 1394 | `) 1395 | }) 1396 | 1397 | it('should parse multi group by columns on nested tables', () => { 1398 | let query = ` 1399 | select post [ 1400 | author_id 1401 | author { 1402 | nickname 1403 | grade 1404 | cohort 1405 | } group by grade, cohort 1406 | version 1407 | ] group by author_id, version 1408 | ` 1409 | let ast = decode(query) 1410 | expectAST(ast, { 1411 | type: 'select', 1412 | table: { 1413 | type: 'table', 1414 | name: 'post', 1415 | single: false, 1416 | fields: [ 1417 | { type: 'column', name: 'author_id' }, 1418 | { 1419 | type: 'table', 1420 | name: 'author', 1421 | single: true, 1422 | fields: [ 1423 | { type: 'column', name: 'nickname' }, 1424 | { type: 'column', name: 'grade' }, 1425 | { type: 'column', name: 'cohort' }, 1426 | ], 1427 | groupBy: { fields: ['grade', 'cohort'] }, 1428 | }, 1429 | { type: 'column', name: 'version' }, 1430 | ], 1431 | groupBy: { fields: ['author_id', 'version'] }, 1432 | }, 1433 | }) 1434 | let sql = generateSQL(ast) 1435 | expect(sql).to.equals(/* sql */ ` 1436 | select 1437 | post.author_id 1438 | , author.nickname 1439 | , author.grade 1440 | , author.cohort 1441 | , post.version 1442 | from post 1443 | inner join author on author.id = post.author_id 1444 | group by 1445 | author.grade 1446 | , author.cohort 1447 | , post.author_id 1448 | , post.version 1449 | `) 1450 | }) 1451 | 1452 | it('should parse aggregate function', () => { 1453 | let query = ` 1454 | select post [ 1455 | author_id 1456 | created_at 1457 | count(*) as post_count 1458 | ] group by author_id 1459 | ` 1460 | let ast = decode(query) 1461 | expectAST(ast, { 1462 | type: 'select', 1463 | table: { 1464 | type: 'table', 1465 | name: 'post', 1466 | single: false, 1467 | fields: [ 1468 | { type: 'column', name: 'author_id' }, 1469 | { type: 'column', name: 'created_at' }, 1470 | { type: 'column', name: 'count(*)', alias: 'post_count' }, 1471 | ], 1472 | groupBy: { fields: ['author_id'] }, 1473 | }, 1474 | }) 1475 | let sql = generateSQL(ast) 1476 | expect(sql).to.equals(/* sql */ ` 1477 | select 1478 | post.author_id 1479 | , post.created_at 1480 | , count(*) as post_count 1481 | from post 1482 | group by 1483 | post.author_id 1484 | `) 1485 | }) 1486 | 1487 | it('should parse "having" statement', () => { 1488 | let query = ` 1489 | select post [ 1490 | author_id 1491 | created_at 1492 | count(*) as post_count 1493 | ] group by author_id 1494 | having count(*) > 10 1495 | ` 1496 | let ast = decode(query) 1497 | expectAST(ast, { 1498 | type: 'select', 1499 | table: { 1500 | type: 'table', 1501 | name: 'post', 1502 | single: false, 1503 | fields: [ 1504 | { type: 'column', name: 'author_id' }, 1505 | { type: 'column', name: 'created_at' }, 1506 | { type: 'column', name: 'count(*)', alias: 'post_count' }, 1507 | ], 1508 | groupBy: { fields: ['author_id'] }, 1509 | having: { 1510 | expr: { type: 'compare', left: 'count(*)', op: '>', right: '10' }, 1511 | }, 1512 | }, 1513 | }) 1514 | let sql = generateSQL(ast) 1515 | expect(sql).to.equals(/* sql */ ` 1516 | select 1517 | post.author_id 1518 | , post.created_at 1519 | , count(*) as post_count 1520 | from post 1521 | group by 1522 | post.author_id 1523 | having count(*) > 10 1524 | `) 1525 | }) 1526 | }) 1527 | 1528 | context('order by statement', () => { 1529 | it('should parse single order by column', () => { 1530 | let query = ` 1531 | select post [ 1532 | id 1533 | title 1534 | ] order by created_at 1535 | ` 1536 | let ast = decode(query) 1537 | expectAST(ast, { 1538 | type: 'select', 1539 | table: { 1540 | type: 'table', 1541 | name: 'post', 1542 | single: false, 1543 | fields: [ 1544 | { type: 'column', name: 'id' }, 1545 | { type: 'column', name: 'title' }, 1546 | ], 1547 | orderBy: { fields: [{ name: 'created_at' }] }, 1548 | }, 1549 | }) 1550 | let sql = generateSQL(ast) 1551 | expect(sql).to.equals(/* sql */ ` 1552 | select 1553 | post.id 1554 | , post.title 1555 | from post 1556 | order by 1557 | post.created_at 1558 | `) 1559 | }) 1560 | 1561 | it('should parse order by with explicit order', () => { 1562 | let query = ` 1563 | select post [ 1564 | id 1565 | title 1566 | ] order by created_at asc 1567 | ` 1568 | let ast = decode(query) 1569 | expectAST(ast, { 1570 | type: 'select', 1571 | table: { 1572 | type: 'table', 1573 | name: 'post', 1574 | single: false, 1575 | fields: [ 1576 | { type: 'column', name: 'id' }, 1577 | { type: 'column', name: 'title' }, 1578 | ], 1579 | orderBy: { fields: [{ name: 'created_at', order: 'asc' }] }, 1580 | }, 1581 | }) 1582 | let sql = generateSQL(ast) 1583 | expect(sql).to.equals(/* sql */ ` 1584 | select 1585 | post.id 1586 | , post.title 1587 | from post 1588 | order by 1589 | post.created_at asc 1590 | `) 1591 | }) 1592 | 1593 | it('should parse order by with null order', () => { 1594 | let query = ` 1595 | select post [ 1596 | id 1597 | title 1598 | ] order by created_at asc nulls last 1599 | ` 1600 | let ast = decode(query) 1601 | expectAST(ast, { 1602 | type: 'select', 1603 | table: { 1604 | type: 'table', 1605 | name: 'post', 1606 | single: false, 1607 | fields: [ 1608 | { type: 'column', name: 'id' }, 1609 | { type: 'column', name: 'title' }, 1610 | ], 1611 | orderBy: { 1612 | fields: [{ name: 'created_at', order: 'asc nulls last' }], 1613 | }, 1614 | }, 1615 | }) 1616 | let sql = generateSQL(ast) 1617 | expect(sql).to.equals(/* sql */ ` 1618 | select 1619 | post.id 1620 | , post.title 1621 | from post 1622 | order by 1623 | post.created_at asc nulls last 1624 | `) 1625 | }) 1626 | 1627 | it('should parse case-insensitive order by', () => { 1628 | let query = ` 1629 | select post [ 1630 | id 1631 | title 1632 | ] order by created_at collate nocase asc 1633 | ` 1634 | let ast = decode(query) 1635 | expectAST(ast, { 1636 | type: 'select', 1637 | table: { 1638 | type: 'table', 1639 | name: 'post', 1640 | single: false, 1641 | fields: [ 1642 | { type: 'column', name: 'id' }, 1643 | { type: 'column', name: 'title' }, 1644 | ], 1645 | orderBy: { 1646 | fields: [{ name: 'created_at', order: 'collate nocase asc' }], 1647 | }, 1648 | }, 1649 | }) 1650 | let sql = generateSQL(ast) 1651 | expect(sql).to.equals(/* sql */ ` 1652 | select 1653 | post.id 1654 | , post.title 1655 | from post 1656 | order by 1657 | post.created_at collate nocase asc 1658 | `) 1659 | }) 1660 | 1661 | it('should parse multi order by', () => { 1662 | let query = ` 1663 | select post [ 1664 | id 1665 | author { 1666 | nickname 1667 | } order by register_time desc nulls last 1668 | title 1669 | ] order by publish_time asc, type_id asc nulls first 1670 | ` 1671 | let ast = decode(query) 1672 | expectAST(ast, { 1673 | type: 'select', 1674 | table: { 1675 | type: 'table', 1676 | name: 'post', 1677 | single: false, 1678 | fields: [ 1679 | { type: 'column', name: 'id' }, 1680 | { 1681 | type: 'table', 1682 | name: 'author', 1683 | single: true, 1684 | fields: [{ type: 'column', name: 'nickname' }], 1685 | orderBy: { 1686 | fields: [{ name: 'register_time', order: 'desc nulls last' }], 1687 | }, 1688 | }, 1689 | 1690 | { type: 'column', name: 'title' }, 1691 | ], 1692 | orderBy: { 1693 | fields: [ 1694 | { name: 'publish_time', order: 'asc' }, 1695 | { name: 'type_id', order: 'asc nulls first' }, 1696 | ], 1697 | }, 1698 | }, 1699 | }) 1700 | let sql = generateSQL(ast) 1701 | expect(sql).to.equals(/* sql */ ` 1702 | select 1703 | post.id 1704 | , author.nickname 1705 | , post.title 1706 | from post 1707 | inner join author on author.id = post.author_id 1708 | order by 1709 | author.register_time desc nulls last 1710 | , post.publish_time asc 1711 | , post.type_id asc nulls first 1712 | `) 1713 | }) 1714 | }) 1715 | 1716 | context('"limit" and "offset"', () => { 1717 | it('should parse explicit "limit"', () => { 1718 | let query = ` 1719 | select post [ 1720 | id 1721 | ] 1722 | limit 10 1723 | ` 1724 | let ast = decode(query) 1725 | expectAST(ast, { 1726 | type: 'select', 1727 | table: { 1728 | type: 'table', 1729 | name: 'post', 1730 | single: false, 1731 | fields: [{ type: 'column', name: 'id' }], 1732 | limit: 'limit 10', 1733 | }, 1734 | }) 1735 | let sql = generateSQL(ast) 1736 | expect(sql).to.equals(/* sql */ ` 1737 | select 1738 | post.id 1739 | from post 1740 | limit 10 1741 | `) 1742 | }) 1743 | 1744 | it('should parse "offset"', () => { 1745 | let query = ` 1746 | select post [ 1747 | id 1748 | ] 1749 | limit 10 1750 | offset 20 1751 | ` 1752 | let ast = decode(query) 1753 | expectAST(ast, { 1754 | type: 'select', 1755 | table: { 1756 | type: 'table', 1757 | name: 'post', 1758 | single: false, 1759 | fields: [{ type: 'column', name: 'id' }], 1760 | limit: 'limit 10', 1761 | offset: 'offset 20', 1762 | }, 1763 | }) 1764 | let sql = generateSQL(ast) 1765 | expect(sql).to.equals(/* sql */ ` 1766 | select 1767 | post.id 1768 | from post 1769 | limit 10 1770 | offset 20 1771 | `) 1772 | }) 1773 | }) 1774 | 1775 | context('nested select with "in" expression', () => { 1776 | it('should parse nested select with "in" expression', () => { 1777 | let query = ` 1778 | select post [ 1779 | title 1780 | ] where author_id in ( 1781 | select user [ id ] 1782 | where is_admin = 1 1783 | ) 1784 | ` 1785 | let ast = decode(query) 1786 | expectAST(ast, { 1787 | type: 'select', 1788 | table: { 1789 | type: 'table', 1790 | single: false, 1791 | name: 'post', 1792 | fields: [{ type: 'column', name: 'title' }], 1793 | where: { 1794 | expr: { 1795 | type: 'in', 1796 | expr: 'author_id', 1797 | select: { 1798 | type: 'select', 1799 | table: { 1800 | type: 'table', 1801 | name: 'user', 1802 | single: false, 1803 | fields: [{ type: 'column', name: 'id' }], 1804 | where: { 1805 | expr: { 1806 | type: 'compare', 1807 | left: 'is_admin', 1808 | op: '=', 1809 | right: '1', 1810 | }, 1811 | }, 1812 | }, 1813 | }, 1814 | }, 1815 | }, 1816 | }, 1817 | }) 1818 | let sql = generateSQL(ast) 1819 | expect(sql).to.equals(/* sql */ ` 1820 | select 1821 | post.title 1822 | from post 1823 | where post.author_id in ( 1824 | select 1825 | user.id 1826 | from user 1827 | where user.is_admin = 1 1828 | ) 1829 | `) 1830 | }) 1831 | 1832 | it('should parse nested select with "not in" expression', () => { 1833 | let query = ` 1834 | select post [ 1835 | title 1836 | ] where author_id not in ( 1837 | select user [ id ] 1838 | where is_admin = 1 1839 | ) 1840 | ` 1841 | let ast = decode(query) 1842 | expectAST(ast, { 1843 | type: 'select', 1844 | table: { 1845 | type: 'table', 1846 | single: false, 1847 | name: 'post', 1848 | fields: [{ type: 'column', name: 'title' }], 1849 | where: { 1850 | expr: { 1851 | type: 'in', 1852 | expr: 'author_id', 1853 | not: 'not', 1854 | select: { 1855 | type: 'select', 1856 | table: { 1857 | type: 'table', 1858 | name: 'user', 1859 | single: false, 1860 | fields: [{ type: 'column', name: 'id' }], 1861 | where: { 1862 | expr: { 1863 | type: 'compare', 1864 | left: 'is_admin', 1865 | op: '=', 1866 | right: '1', 1867 | }, 1868 | }, 1869 | }, 1870 | }, 1871 | }, 1872 | }, 1873 | }, 1874 | }) 1875 | let sql = generateSQL(ast) 1876 | expect(sql).to.equals(/* sql */ ` 1877 | select 1878 | post.title 1879 | from post 1880 | where post.author_id not in ( 1881 | select 1882 | user.id 1883 | from user 1884 | where user.is_admin = 1 1885 | ) 1886 | `) 1887 | }) 1888 | 1889 | it('should parse multi-level nested select with "in" expression', () => { 1890 | let query = ` 1891 | select post [ 1892 | title 1893 | ] where author_id not in ( 1894 | select user [ id ] 1895 | where type_id in ( 1896 | select user_type [ id ] 1897 | ) 1898 | ) 1899 | ` 1900 | let ast = decode(query) 1901 | expectAST(ast, { 1902 | type: 'select', 1903 | table: { 1904 | type: 'table', 1905 | single: false, 1906 | name: 'post', 1907 | fields: [{ type: 'column', name: 'title' }], 1908 | where: { 1909 | expr: { 1910 | type: 'in', 1911 | expr: 'author_id', 1912 | not: 'not', 1913 | select: { 1914 | type: 'select', 1915 | table: { 1916 | type: 'table', 1917 | name: 'user', 1918 | single: false, 1919 | fields: [{ type: 'column', name: 'id' }], 1920 | where: { 1921 | expr: { 1922 | type: 'in', 1923 | expr: 'type_id', 1924 | select: { 1925 | type: 'select', 1926 | table: { 1927 | type: 'table', 1928 | name: 'user_type', 1929 | single: false, 1930 | fields: [{ type: 'column', name: 'id' }], 1931 | }, 1932 | }, 1933 | }, 1934 | }, 1935 | }, 1936 | }, 1937 | }, 1938 | }, 1939 | }, 1940 | }) 1941 | let sql = generateSQL(ast) 1942 | expect(sql).to.equals(/* sql */ ` 1943 | select 1944 | post.title 1945 | from post 1946 | where post.author_id not in ( 1947 | select 1948 | user.id 1949 | from user 1950 | where user.type_id in ( 1951 | select 1952 | user_type.id 1953 | from user_type 1954 | ) 1955 | ) 1956 | `) 1957 | }) 1958 | }) 1959 | 1960 | context('nested select sub-query', () => { 1961 | it('should parse inline select sub-query with column alias', () => { 1962 | let query = ` 1963 | select post [ 1964 | id 1965 | title 1966 | (select user { nickname } where id = post.author_id) as by 1967 | ] 1968 | ` 1969 | let ast = decode(query) 1970 | expectAST(ast, { 1971 | type: 'select', 1972 | table: { 1973 | type: 'table', 1974 | name: 'post', 1975 | single: false, 1976 | fields: [ 1977 | { type: 'column', name: 'id' }, 1978 | { type: 'column', name: 'title' }, 1979 | { 1980 | type: 'subQuery', 1981 | alias: 'by', 1982 | select: { 1983 | type: 'select', 1984 | table: { 1985 | type: 'table', 1986 | name: 'user', 1987 | single: true, 1988 | fields: [{ type: 'column', name: 'nickname' }], 1989 | where: { 1990 | expr: { 1991 | type: 'compare', 1992 | left: 'id', 1993 | op: '=', 1994 | right: 'post.author_id', 1995 | }, 1996 | }, 1997 | }, 1998 | }, 1999 | }, 2000 | ], 2001 | }, 2002 | }) 2003 | let sql = generateSQL(ast) 2004 | expect(sql).to.equals(/* sql */ ` 2005 | select 2006 | post.id 2007 | , post.title 2008 | , ( 2009 | select 2010 | user.nickname 2011 | from user 2012 | where user.id = post.author_id 2013 | limit 1 2014 | ) as by 2015 | from post 2016 | `) 2017 | }) 2018 | 2019 | it('should parse select sub-query in where condition', () => { 2020 | let query = ` 2021 | select post [ 2022 | title 2023 | ] where author_id = (select user {id} where username = :username) 2024 | ` 2025 | let ast = decode(query) 2026 | expectAST(ast, { 2027 | type: 'select', 2028 | table: { 2029 | type: 'table', 2030 | name: 'post', 2031 | single: false, 2032 | fields: [{ type: 'column', name: 'title' }], 2033 | where: { 2034 | expr: { 2035 | type: 'compare', 2036 | left: 'author_id', 2037 | op: '=', 2038 | right: { 2039 | type: 'parenthesis', 2040 | expr: { 2041 | type: 'select', 2042 | table: { 2043 | type: 'table', 2044 | name: 'user', 2045 | single: true, 2046 | fields: [{ type: 'column', name: 'id' }], 2047 | where: { 2048 | expr: { 2049 | type: 'compare', 2050 | left: 'username', 2051 | op: '=', 2052 | right: ':username', 2053 | }, 2054 | }, 2055 | }, 2056 | }, 2057 | }, 2058 | }, 2059 | }, 2060 | }, 2061 | }) 2062 | let sql = generateSQL(ast) 2063 | expect(sql).to.equals(/* sql */ ` 2064 | select 2065 | post.title 2066 | from post 2067 | where post.author_id = ( 2068 | select 2069 | user.id 2070 | from user 2071 | where user.username = :username 2072 | limit 1 2073 | ) 2074 | `) 2075 | }) 2076 | }) 2077 | 2078 | it('should parse "where", "group by", "order by", "limit", "offset" in any order', () => { 2079 | let query = ` 2080 | select post [ 2081 | id 2082 | ] 2083 | offset 20 2084 | limit 10 2085 | order by publish_time asc 2086 | group by type_id 2087 | where author_id = :author 2088 | ` 2089 | let ast = decode(query) 2090 | let sql = generateSQL(ast) 2091 | expect(sql).to.equals(/* sql */ ` 2092 | select 2093 | post.id 2094 | from post 2095 | where post.author_id = :author 2096 | group by 2097 | post.type_id 2098 | order by 2099 | post.publish_time asc 2100 | limit 10 2101 | offset 20 2102 | `) 2103 | }) 2104 | 2105 | it('should preserve original upper/lower case in the query', () => { 2106 | let query = ` 2107 | SELECT DISTINCT THREAD AS POST [ 2108 | VER AS VERSION 2109 | TITLE 2110 | ] 2111 | WHERE AUTHOR_ID = :Author_ID 2112 | AND PUBLISH_TIME NOT BETWEEN '2022-01-01' AND '2022-12-31' 2113 | OR NOT DELETE_TIME IS NULL 2114 | GROUP BY VERSION 2115 | ORDER BY VERSION COLLATE NOCASE DESC NULLS FIRST 2116 | LIMIT 10 2117 | OFFSET 20 2118 | ` 2119 | let ast = decode(query) 2120 | expectAST(ast, { 2121 | type: 'select', 2122 | selectStr: 'SELECT', 2123 | distinct: 'DISTINCT', 2124 | table: { 2125 | type: 'table', 2126 | name: 'THREAD', 2127 | alias: 'POST', 2128 | asStr: 'AS', 2129 | single: false, 2130 | fields: [ 2131 | { type: 'column', name: 'VER', alias: 'VERSION', asStr: 'AS' }, 2132 | { type: 'column', name: 'TITLE' }, 2133 | ], 2134 | where: { 2135 | whereStr: 'WHERE', 2136 | expr: { 2137 | type: 'compare', 2138 | left: { 2139 | type: 'compare', 2140 | left: 'AUTHOR_ID', 2141 | op: '=', 2142 | right: ':Author_ID', 2143 | }, 2144 | op: 'AND', 2145 | right: { 2146 | type: 'compare', 2147 | left: { 2148 | type: 'between', 2149 | betweenStr: 'BETWEEN', 2150 | not: 'NOT', 2151 | andStr: 'AND', 2152 | expr: 'PUBLISH_TIME', 2153 | left: "'2022-01-01'", 2154 | right: "'2022-12-31'", 2155 | }, 2156 | op: 'OR', 2157 | right: { 2158 | type: 'not', 2159 | notStr: 'NOT', 2160 | expr: { 2161 | type: 'compare', 2162 | left: 'DELETE_TIME', 2163 | op: 'IS', 2164 | right: 'NULL', 2165 | }, 2166 | }, 2167 | }, 2168 | }, 2169 | }, 2170 | groupBy: { groupByStr: 'GROUP BY', fields: ['VERSION'] }, 2171 | orderBy: { 2172 | orderByStr: 'ORDER BY', 2173 | fields: [ 2174 | { name: 'VERSION', order: 'COLLATE NOCASE DESC NULLS FIRST' }, 2175 | ], 2176 | }, 2177 | limit: 'LIMIT 10', 2178 | offset: 'OFFSET 20', 2179 | }, 2180 | }) 2181 | let sql = generateSQL(ast) 2182 | expect(sql).to.equals(/* sql */ ` 2183 | SELECT DISTINCT 2184 | POST.VER AS VERSION 2185 | , POST.TITLE 2186 | FROM THREAD AS POST 2187 | WHERE POST.AUTHOR_ID = :Author_ID 2188 | AND POST.PUBLISH_TIME NOT BETWEEN '2022-01-01' AND '2022-12-31' 2189 | OR NOT POST.DELETE_TIME IS NULL 2190 | GROUP BY 2191 | POST.VERSION 2192 | ORDER BY 2193 | POST.VERSION COLLATE NOCASE DESC NULLS FIRST 2194 | LIMIT 10 2195 | OFFSET 20 2196 | `) 2197 | }) 2198 | }) 2199 | 2200 | context('code-gen helpers', () => { 2201 | it('should insert table prefix on column name', () => { 2202 | let name = nameWithTablePrefix({ field: 'score', tableName: 'exam' }) 2203 | expect(name).to.equals('exam.score') 2204 | }) 2205 | 2206 | it('should insert table prefix in aggregate function', () => { 2207 | let name = nameWithTablePrefix({ field: 'sum(score)', tableName: 'exam' }) 2208 | expect(name).to.equals('sum(exam.score)') 2209 | }) 2210 | }) 2211 | }) 2212 | -------------------------------------------------------------------------------- /test/sample.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { generateSQL } from '../src/code-gen' 3 | import { decode } from '../src/parse' 4 | 5 | let text = /* sql */ ` 6 | select post [ 7 | id as post_id 8 | title 9 | author_id 10 | user as author { nickname, avatar } where delete_time is null 11 | type_id 12 | post_type { 13 | name as type 14 | is_hidden 15 | } where is_hidden = 0 or user.is_admin = 1 16 | ] where created_at >= :since 17 | and delete_time is null 18 | and title like :keyword 19 | order by created_at desc 20 | limit 25 21 | ` 22 | 23 | let ast = decode(text) 24 | let query = generateSQL(ast) 25 | let sql = /* sql */ ` 26 | select 27 | post.id as post_id 28 | , post.title 29 | , post.author_id 30 | , author.nickname 31 | , author.avatar 32 | , post.type_id 33 | , post_type.name as type 34 | , post_type.is_hidden 35 | from post 36 | inner join user as author on author.id = post.author_id 37 | inner join post_type on post_type.id = post.post_type_id 38 | where author.delete_time is null 39 | and (post_type.is_hidden = 0 40 | or user.is_admin = 1) 41 | and post.created_at >= :since 42 | and post.delete_time is null 43 | and post.title like :keyword 44 | order by 45 | post.created_at desc 46 | limit 25 47 | ` 48 | expect(query).to.equals(sql) 49 | -------------------------------------------------------------------------------- /test/typescript.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | 3 | describe('ts-mocha setup', () => { 4 | it('should be able to compile', () => { 5 | let a: number = 1 6 | let b: number = 2 7 | let c: number = a + b 8 | expect(c).to.equals(3) 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "target": "es2018", 5 | "module": "commonjs", 6 | "outDir": "dist/cjs" 7 | }, 8 | "include": [ 9 | "src/**/*.ts" 10 | ], 11 | "exclude": [ 12 | "node_modules", 13 | "src/**/*.macro.ts", 14 | "src/**/*.spec.ts", 15 | "src/**/*.test.ts" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "target": "es2018", 5 | "module": "es6", 6 | "outDir": "dist/esm" 7 | }, 8 | "include": [ 9 | "src/**/*.ts" 10 | ], 11 | "exclude": [ 12 | "node_modules", 13 | "src/**/*.macro.ts", 14 | "src/**/*.spec.ts", 15 | "src/**/*.test.ts" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "allowSyntheticDefaultImports": true, 5 | "allowJs": false, 6 | "sourceMap": false, 7 | "declaration": true, 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "strict": true, 11 | "skipLibCheck": true, 12 | "downlevelIteration": false, 13 | "importHelpers": false, 14 | "lib": [ 15 | "dom", 16 | "es2018" 17 | ], 18 | "module": "commonjs", 19 | "moduleResolution": "node", 20 | "target": "es2015", 21 | "outDir": "dist" 22 | }, 23 | "include": [ 24 | "src/**/*.ts", 25 | "test/**/*.ts" 26 | ], 27 | "exclude": [ 28 | "node_modules", 29 | "src/**/*.macro.ts", 30 | "src/**/*.spec.ts", 31 | "src/**/*.test.ts", 32 | "test/**/*.macro.ts", 33 | "test/**/*.spec.ts", 34 | "test/**/*.test.ts" 35 | ], 36 | "compileOnSave": false, 37 | "atom": { 38 | "rewriteTsconfig": false 39 | } 40 | } 41 | --------------------------------------------------------------------------------