├── .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 | [](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 |
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 |
77 |
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 |
105 |
106 |
107 |
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 | Load sample query
26 |
27 |
28 |
29 |
30 | =>
31 |
32 | generated sql query
33 |
34 | Copy SQL query
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 |
--------------------------------------------------------------------------------