diff --git a/docs/src/pages/changelog.mdx b/docs/src/pages/changelog.mdx index 2cffd2a64..9cafdd349 100644 --- a/docs/src/pages/changelog.mdx +++ b/docs/src/pages/changelog.mdx @@ -8,6 +8,12 @@ route: /changelog ## v0.21 +### v0.21.4 - (December 12, 2019) + +* **Enhancements** + * MySQL: removed getAffectedRowsCount call for mysql/xdevapi. Thanks to [@frankyjuang](https://github.com/frankyjuang). + * SAPHana: hana-client uses latest version. Thanks to [@ariel-bentu](https://github.com/ariel-bentu). + ### v0.21.3 - (November 06, 2019) * **Enhancements** diff --git a/package.json b/package.json index ad53a17d3..5459324ee 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "sqltools", "displayName": "SQLTools - Database tools", "description": "Database management done right. Connection explorer, query runner, intellisense, bookmarks, query history. Feel like a database hero!", - "version": "0.21.3", + "version": "0.21.4", "publisher": "mtxr", "license": "MIT", "main": "../dist/extension.js", @@ -26,7 +26,7 @@ "jest": "jest --config jest.config.js --passWithNoTests", "package": "cross-env NODE_ENV=production yarn run compile && (cd ../dist && cross-env NODE_ENV=production vsce package --yarn)", "postcompile": "rimraf -rf ../dist/ui/theme.js || exit 0", - "postinstall": "git submodule update --init --recursive && yarn workspace @sqltools/formatter install", + "postinstall": "yarn workspace @sqltools/formatter install", "precompile": "yarn test && yarn run clean", "pretest": "rimraf -rf ./coverage", "prewatch": "yarn run clean", diff --git a/packages/core/package.json b/packages/core/package.json index 2ae31d1f9..7c961fe88 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@sqltools/core", - "version": "0.21.3", + "version": "0.21.4", "description": "SQLTools Core Files", "main": "index.ts", "author": "Matheus Teixeira ", diff --git a/packages/extension/package.json b/packages/extension/package.json index 1185acc0e..573dfaaf4 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -2,7 +2,7 @@ "name": "@sqltools/extension", "displayName": "SQLTools - Database tools", "description": "Database management done right. Connection explorer, query runner, intellisense, bookmarks, query history. Feel like a database hero!", - "version": "0.21.3", + "version": "0.21.4", "publisher": "mtxr", "license": "MIT", "preview": false, diff --git a/packages/formatter/.editorconfig b/packages/formatter/.editorconfig new file mode 100644 index 000000000..b2dadf306 --- /dev/null +++ b/packages/formatter/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +indent_style = space +indent_size = 2 diff --git a/packages/formatter/.eslintignore b/packages/formatter/.eslintignore new file mode 100644 index 000000000..7f315a664 --- /dev/null +++ b/packages/formatter/.eslintignore @@ -0,0 +1,3 @@ +/lib +/dist +/coverage diff --git a/packages/formatter/.gitignore b/packages/formatter/.gitignore new file mode 100644 index 000000000..e3646df70 --- /dev/null +++ b/packages/formatter/.gitignore @@ -0,0 +1,5 @@ +dist +lib +node_modules +.DS_Store +coverage diff --git a/packages/formatter/LICENSE b/packages/formatter/LICENSE new file mode 100644 index 000000000..dcc3a73b2 --- /dev/null +++ b/packages/formatter/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2019-present Matheus Teixeira + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/formatter/README.md b/packages/formatter/README.md new file mode 100644 index 000000000..8810f6d37 --- /dev/null +++ b/packages/formatter/README.md @@ -0,0 +1,11 @@ +# SQLTools Formatter + +[![Build Status](https://github.com/mtxr/sqltools-formatter/workflows/Node%20CI/badge.svg)](https://github.com/mtxr/sqltools-formatter/actions) +[![codecov](https://img.shields.io/codecov/c/gh/mtxr/sqltools-formatter.svg)](https://codecov.io/gh/mtxr/sqltools-formatter) +[![NPM version](https://img.shields.io/npm/v/@sqltools/formatter.svg)](https://npmjs.com/package/@sqltools/formatter) +[![GitHub](https://img.shields.io/github/license/mtxr/sqltools-formatter)](https://github.com/mtxr/sqltools-formatter/blob/master/LICENSE) + + +> Forked from [zeroturnaround/sql-formatter](https://zeroturnaround.github.io/sql-formatter/) + +This package is part of [vscode-sqltools](https://github.com/mtxr/vscode-sqltools) extension. diff --git a/packages/formatter/jest.config.js b/packages/formatter/jest.config.js new file mode 100644 index 000000000..8770ed70c --- /dev/null +++ b/packages/formatter/jest.config.js @@ -0,0 +1,18 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + collectCoverage: true, + collectCoverageFrom: ['src/**/*.ts'], + coverageDirectory: '/coverage', + coveragePathIgnorePatterns: ['/node_modules/', '/coverage/'], + coverageThreshold: { + global: { + statements: 100, + branches: 100, + functions: 100, + lines: 100, + }, + }, + roots: ['test'], + testMatch: ['**/*.test.(ts)'], +}; diff --git a/packages/formatter/package.json b/packages/formatter/package.json new file mode 100644 index 000000000..a41ab1444 --- /dev/null +++ b/packages/formatter/package.json @@ -0,0 +1,61 @@ +{ + "name": "@sqltools/formatter", + "version": "1.1.1", + "description": "Formats SQL queries. Part of SQLTools", + "license": "MIT", + "main": "lib/sqlFormatter.js", + "private": false, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "keywords": [ + "sql", + "formatter", + "format", + "n1ql", + "whitespaces", + "sqltools" + ], + "authors": [ + "Matheus Teixeira ", + "Rene Saarsoo", + "Uku Pattak" + ], + "files": [ + "lib" + ], + "scripts": { + "clean": "rimraf lib dist", + "test": "jest --config jest.config.js", + "test:watch": "yarn run test -- --watch", + "check": "yarn run test", + "precompile": "yarn run check && yarn run clean", + "compile": "./node_modules/.bin/tsc -p tsconfig.json", + "build": "yarn run compile" + }, + "repository": { + "type": "git", + "url": "https://github.com/mtxr/sqltools-formatter.git" + }, + "bugs": { + "url": "https://github.com/mtxr/sqltools-formatter/issues" + }, + "dependencies": { + "lodash": "^4.17.11" + }, + "devDependencies": { + "@types/jest": "^24.0.11", + "husky": "^3.0.3", + "jest": "^24.7.0", + "jest-cli": "^24.7.0", + "rimraf": "^3.0.0", + "ts-jest": "^24.0.1", + "typescript": "^3.4.1" + }, + "husky": { + "hooks": { + "pre-commit": "yarn test" + } + } +} diff --git a/packages/formatter/src/core/Formatter.ts b/packages/formatter/src/core/Formatter.ts new file mode 100644 index 000000000..421a3be39 --- /dev/null +++ b/packages/formatter/src/core/Formatter.ts @@ -0,0 +1,218 @@ +import trimEnd from 'lodash/trimEnd'; +import { TokenTypes, Config, Token } from './types'; +import Indentation from './Indentation'; +import InlineBlock from './InlineBlock'; +import Params from './Params'; +import Tokenizer from './Tokenizer'; + +export default class Formatter { + private tokens: Token[] = []; + private previousReservedWord: Token = { type: null, value: null }; + private indentation: Indentation; + private inlineBlock: InlineBlock; + private params: Params; + private index = 0; + /** + * @param {Config} cfg + * @param {string} cfg.indent + * @param {Object} cfg.params + * @param {Tokenizer} tokenizer + */ + constructor(public cfg: Config, public tokenizer: Tokenizer) { + this.indentation = new Indentation(this.cfg.indent); + this.inlineBlock = new InlineBlock(); + this.params = new Params(this.cfg.params); + } + + /** + * Formats whitespaces in a SQL string to make it easier to read. + * + * @param {String} query The SQL query string + * @return {String} formatted query + */ + format(query) { + this.tokens = this.tokenizer.tokenize(query); + const formattedQuery = this.getFormattedQueryFromTokens(); + + return formattedQuery.trim(); + } + + reservedWord(word) { + if (this.cfg.reservedWordCase === 'upper') return word.toUpperCase(); + if (this.cfg.reservedWordCase === 'lower') return word.toLowerCase(); + return word; + } + + getFormattedQueryFromTokens() { + let formattedQuery = ''; + + this.tokens.forEach((token, index) => { + this.index = index; + + if (token.type === TokenTypes.WHITESPACE) { + // ignore (we do our own whitespace formatting) + } else if (token.type === TokenTypes.LINE_COMMENT) { + formattedQuery = this.formatLineComment(token, formattedQuery); + } else if (token.type === TokenTypes.BLOCK_COMMENT) { + formattedQuery = this.formatBlockComment(token, formattedQuery); + } else if (token.type === TokenTypes.TABLENAME_PREFIX) { + formattedQuery = this.formatTablePrefix(token, formattedQuery); + } else if (token.type === TokenTypes.RESERVED_TOPLEVEL) { + formattedQuery = this.formatToplevelReservedWord(token, formattedQuery); + this.previousReservedWord = token; + } else if (token.type === TokenTypes.RESERVED_NEWLINE) { + formattedQuery = this.formatNewlineReservedWord(token, formattedQuery); + this.previousReservedWord = token; + } else if (token.type === TokenTypes.RESERVED) { + formattedQuery = this.formatWithSpaces(token, formattedQuery); + this.previousReservedWord = token; + } else if (token.type === TokenTypes.OPEN_PAREN) { + formattedQuery = this.formatOpeningParentheses(token, formattedQuery); + } else if (token.type === TokenTypes.CLOSE_PAREN) { + formattedQuery = this.formatClosingParentheses(token, formattedQuery); + } else if (token.type === TokenTypes.PLACEHOLDER) { + formattedQuery = this.formatPlaceholderOrVariable(token, formattedQuery); + } else if (token.type === TokenTypes.SERVERVARIABLE) { + formattedQuery = this.formatPlaceholderOrVariable(token, formattedQuery); + } else if (token.value === ',') { + formattedQuery = this.formatComma(token, formattedQuery); + } else if (token.value === '.') { + formattedQuery = this.formatWithoutSpaces(token, formattedQuery); + } else if (token.value === ';' || token.type === TokenTypes.QUERY_SEPARATOR ) { + formattedQuery = this.formatQuerySeparator(token, formattedQuery); + } else { + formattedQuery = this.formatWithSpaces(token, formattedQuery); + } + }); + return formattedQuery; + } + + formatLineComment(token, query) { + return this.addNewline(query + token.value); + } + + formatBlockComment(token, query) { + return this.addNewline(this.addNewline(query) + this.indentComment(token.value)); + } + + indentComment(comment) { + return comment.replace(/\r\n|\n/g, '\n' + this.indentation.getIndent()); + } + + formatToplevelReservedWord(token, query) { + this.indentation.decreaseTopLevel(); + + query = this.addNewline(query); + + this.indentation.increaseToplevel(); + + query += this.equalizeWhitespace(this.reservedWord(token.value)); + return this.addNewline(query); + } + + formatNewlineReservedWord(token, query) { + return this.addNewline(query) + this.equalizeWhitespace(this.reservedWord(token.value)) + ' '; + } + + // Replace any sequence of whitespace characters with single space + equalizeWhitespace(string) { + return string.replace(/\s+/g, ' '); + } + + // Opening parentheses increase the block indent level and start a new line + formatOpeningParentheses(token, query) { + // Take out the preceding space unless there was whitespace there in the original query + // or another opening parens or line comment + const preserveWhitespaceFor = [TokenTypes.WHITESPACE, TokenTypes.OPEN_PAREN, TokenTypes.LINE_COMMENT]; + if (!preserveWhitespaceFor.includes(this.previousToken().type)) { + query = trimEnd(query); + } + query += token.value; + + this.inlineBlock.beginIfPossible(this.tokens, this.index); + + if (!this.inlineBlock.isActive()) { + this.indentation.increaseBlockLevel(); + query = this.addNewline(query); + } + return query; + } + + // Closing parentheses decrease the block indent level + formatClosingParentheses(token, query) { + if (this.inlineBlock.isActive()) { + this.inlineBlock.end(); + return this.formatWithSpaceAfter(token, query); + } else { + this.indentation.decreaseBlockLevel(); + return this.formatWithSpaces(token, this.addNewline(query)); + } + } + + formatPlaceholderOrVariable(token, query) { + return query + this.params.get(token) + ' '; + } + + // Commas start a new line (unless within inline parentheses or SQL "LIMIT" clause) + formatComma(token, query) { + query = this.trimTrailingWhitespace(query) + token.value + ' '; + + if (this.inlineBlock.isActive()) { + return query; + } else if (/^LIMIT$/i.test(this.previousReservedWord.value)) { + return query; + } else { + return this.addNewline(query); + } + } + + formatWithSpaceAfter(token, query) { + return this.trimTrailingWhitespace(query) + token.value + ' '; + } + + formatWithoutSpaces(token, query) { + return this.trimTrailingWhitespace(query) + token.value; + } + + formatWithSpaces(token, query) { + return query + token.value + ' '; + } + + formatTablePrefix(token, query) { + this.indentation.decreaseTopLevel(); + + query = this.addNewline(query); + + this.indentation.increaseToplevel(); + + return query + this.equalizeWhitespace(this.reservedWord(token.value)) + ' '; + } + + formatQuerySeparator(token, query) { + return this.trimTrailingWhitespace(query) + trimEnd(token.value) + '\n'; + } + + addNewline(query) { + return trimEnd(query) + '\n' + this.indentation.getIndent(); + } + + trimTrailingWhitespace(query) { + if (this.previousNonWhitespaceToken().type === TokenTypes.LINE_COMMENT) { + return trimEnd(query) + '\n'; + } else { + return trimEnd(query); + } + } + + previousNonWhitespaceToken() { + let n = 1; + while (this.previousToken(n).type === TokenTypes.WHITESPACE) { + n++; + } + return this.previousToken(n); + } + + previousToken(offset = 1): Token { + return this.tokens[this.index - offset] || { type: null, value: null }; + } +} diff --git a/packages/formatter/src/core/Indentation.ts b/packages/formatter/src/core/Indentation.ts new file mode 100644 index 000000000..ff14009c0 --- /dev/null +++ b/packages/formatter/src/core/Indentation.ts @@ -0,0 +1,69 @@ +import repeat from 'lodash/repeat'; +import last from 'lodash/last'; + +const INDENT_TYPE_TOP_LEVEL = 'top-level'; +const INDENT_TYPE_BLOCK_LEVEL = 'block-level'; + +/** + * Manages indentation levels. + * + * There are two types of indentation levels: + * + * - BLOCK_LEVEL : increased by open-parenthesis + * - TOP_LEVEL : increased by RESERVED_TOPLEVEL words + */ +export default class Indentation { + public indentTypes = []; + /** + * @param {String} indent Indent value, default is " " (2 spaces) + */ + constructor(public indent?: string) { + this.indent = indent || ' '; + } + + /** + * Returns current indentation string. + * @return {String} + */ + getIndent() { + return repeat(this.indent, this.indentTypes.length); + } + + /** + * Increases indentation by one top-level indent. + */ + increaseToplevel() { + this.indentTypes.push(INDENT_TYPE_TOP_LEVEL); + } + + /** + * Increases indentation by one block-level indent. + */ + increaseBlockLevel() { + this.indentTypes.push(INDENT_TYPE_BLOCK_LEVEL); + } + + /** + * Decreases indentation by one top-level indent. + * Does nothing when the previous indent is not top-level. + */ + decreaseTopLevel() { + if (last(this.indentTypes) === INDENT_TYPE_TOP_LEVEL) { + this.indentTypes.pop(); + } + } + + /** + * Decreases indentation by one block-level indent. + * If there are top-level indents within the block-level indent, + * throws away these as well. + */ + decreaseBlockLevel() { + while (this.indentTypes.length > 0) { + const type = this.indentTypes.pop(); + if (type !== INDENT_TYPE_TOP_LEVEL) { + break; + } + } + } +} diff --git a/packages/formatter/src/core/InlineBlock.ts b/packages/formatter/src/core/InlineBlock.ts new file mode 100644 index 000000000..96ee7668f --- /dev/null +++ b/packages/formatter/src/core/InlineBlock.ts @@ -0,0 +1,89 @@ +import { TokenTypes } from './types'; + +const INLINE_MAX_LENGTH = 50; + +/** + * Bookkeeper for inline blocks. + * + * Inline blocks are parenthized expressions that are shorter than INLINE_MAX_LENGTH. + * These blocks are formatted on a single line, unlike longer parenthized + * expressions where open-parenthesis causes newline and increase of indentation. + */ +export default class InlineBlock { + private level = 0; + + /** + * Begins inline block when lookahead through upcoming tokens determines + * that the block would be smaller than INLINE_MAX_LENGTH. + * @param {Object[]} tokens Array of all tokens + * @param {Number} index Current token position + */ + beginIfPossible(tokens, index) { + if (this.level === 0 && this.isInlineBlock(tokens, index)) { + this.level = 1; + } else if (this.level > 0) { + this.level++; + } else { + this.level = 0; + } + } + + /** + * Finishes current inline block. + * There might be several nested ones. + */ + end() { + this.level--; + } + + /** + * True when inside an inline block + * @return {Boolean} + */ + isActive() { + return this.level > 0; + } + + // Check if this should be an inline parentheses block + // Examples are "NOW()", "COUNT(*)", "int(10)", key(`somecolumn`), DECIMAL(7,2) + isInlineBlock(tokens, index) { + let length = 0; + let level = 0; + + for (let i = index; i < tokens.length; i++) { + const token = tokens[i]; + length += token.value.length; + + // Overran max length + if (length > INLINE_MAX_LENGTH) { + return false; + } + + if (token.type === TokenTypes.OPEN_PAREN) { + level++; + } else if (token.type === TokenTypes.CLOSE_PAREN) { + level--; + if (level === 0) { + return true; + } + } + + if (this.isForbiddenToken(token)) { + return false; + } + } + return false; + } + + // Reserved words that cause newlines, comments and semicolons + // are not allowed inside inline parentheses block + isForbiddenToken({ type, value }) { + return ( + type === TokenTypes.RESERVED_TOPLEVEL || + type === TokenTypes.RESERVED_NEWLINE || + type === TokenTypes.LINE_COMMENT || + type === TokenTypes.BLOCK_COMMENT || + value === ';' + ); + } +} diff --git a/packages/formatter/src/core/Params.ts b/packages/formatter/src/core/Params.ts new file mode 100644 index 000000000..7b9525ec3 --- /dev/null +++ b/packages/formatter/src/core/Params.ts @@ -0,0 +1,29 @@ +/** + * Handles placeholder replacement with given params. + */ +export default class Params { + public index = 0; + /** + * @param {Object} params + */ + constructor(public params: Object) { + this.params = params; + } + + /** + * Returns param value that matches given placeholder with param key. + * @param {Object} token + * @param {String} token.key Placeholder key + * @param {String} token.value Placeholder value + * @return {String} param or token.value when params are missing + */ + get({ key, value }) { + if (!this.params) { + return value; + } + if (key) { + return this.params[key]; + } + return this.params[this.index++]; + } +} diff --git a/packages/formatter/src/core/Tokenizer.ts b/packages/formatter/src/core/Tokenizer.ts new file mode 100644 index 000000000..76c848716 --- /dev/null +++ b/packages/formatter/src/core/Tokenizer.ts @@ -0,0 +1,366 @@ +import escapeRegExp from 'lodash/escapeRegExp'; +import { TokenTypes, Token, TokenizerConfig } from './types'; +const wordUTF8 = '\\w$\\u0041-\\u005A\\u0061-\\u007A\\u00AA\\u00B5\\u00BA\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02C1\\u02C6-\\u02D1\\u02E0-\\u02E4\\u02EC\\u02EE\\u0370-\\u0374\\u0376\\u0377\\u037A-\\u037D\\u0386\\u0388-\\u038A\\u038C\\u038E-\\u03A1\\u03A3-\\u03F5\\u03F7-\\u0481\\u048A-\\u0527\\u0531-\\u0556\\u0559\\u0561-\\u0587\\u05D0-\\u05EA\\u05F0-\\u05F2\\u0620-\\u064A\\u066E\\u066F\\u0671-\\u06D3\\u06D5\\u06E5\\u06E6\\u06EE\\u06EF\\u06FA-\\u06FC\\u06FF\\u0710\\u0712-\\u072F\\u074D-\\u07A5\\u07B1\\u07CA-\\u07EA\\u07F4\\u07F5\\u07FA\\u0800-\\u0815\\u081A\\u0824\\u0828\\u0840-\\u0858\\u08A0\\u08A2-\\u08AC\\u0904-\\u0939\\u093D\\u0950\\u0958-\\u0961\\u0971-\\u0977\\u0979-\\u097F\\u0985-\\u098C\\u098F\\u0990\\u0993-\\u09A8\\u09AA-\\u09B0\\u09B2\\u09B6-\\u09B9\\u09BD\\u09CE\\u09DC\\u09DD\\u09DF-\\u09E1\\u09F0\\u09F1\\u0A05-\\u0A0A\\u0A0F\\u0A10\\u0A13-\\u0A28\\u0A2A-\\u0A30\\u0A32\\u0A33\\u0A35\\u0A36\\u0A38\\u0A39\\u0A59-\\u0A5C\\u0A5E\\u0A72-\\u0A74\\u0A85-\\u0A8D\\u0A8F-\\u0A91\\u0A93-\\u0AA8\\u0AAA-\\u0AB0\\u0AB2\\u0AB3\\u0AB5-\\u0AB9\\u0ABD\\u0AD0\\u0AE0\\u0AE1\\u0B05-\\u0B0C\\u0B0F\\u0B10\\u0B13-\\u0B28\\u0B2A-\\u0B30\\u0B32\\u0B33\\u0B35-\\u0B39\\u0B3D\\u0B5C\\u0B5D\\u0B5F-\\u0B61\\u0B71\\u0B83\\u0B85-\\u0B8A\\u0B8E-\\u0B90\\u0B92-\\u0B95\\u0B99\\u0B9A\\u0B9C\\u0B9E\\u0B9F\\u0BA3\\u0BA4\\u0BA8-\\u0BAA\\u0BAE-\\u0BB9\\u0BD0\\u0C05-\\u0C0C\\u0C0E-\\u0C10\\u0C12-\\u0C28\\u0C2A-\\u0C33\\u0C35-\\u0C39\\u0C3D\\u0C58\\u0C59\\u0C60\\u0C61\\u0C85-\\u0C8C\\u0C8E-\\u0C90\\u0C92-\\u0CA8\\u0CAA-\\u0CB3\\u0CB5-\\u0CB9\\u0CBD\\u0CDE\\u0CE0\\u0CE1\\u0CF1\\u0CF2\\u0D05-\\u0D0C\\u0D0E-\\u0D10\\u0D12-\\u0D3A\\u0D3D\\u0D4E\\u0D60\\u0D61\\u0D7A-\\u0D7F\\u0D85-\\u0D96\\u0D9A-\\u0DB1\\u0DB3-\\u0DBB\\u0DBD\\u0DC0-\\u0DC6\\u0E01-\\u0E30\\u0E32\\u0E33\\u0E40-\\u0E46\\u0E81\\u0E82\\u0E84\\u0E87\\u0E88\\u0E8A\\u0E8D\\u0E94-\\u0E97\\u0E99-\\u0E9F\\u0EA1-\\u0EA3\\u0EA5\\u0EA7\\u0EAA\\u0EAB\\u0EAD-\\u0EB0\\u0EB2\\u0EB3\\u0EBD\\u0EC0-\\u0EC4\\u0EC6\\u0EDC-\\u0EDF\\u0F00\\u0F40-\\u0F47\\u0F49-\\u0F6C\\u0F88-\\u0F8C\\u1000-\\u102A\\u103F\\u1050-\\u1055\\u105A-\\u105D\\u1061\\u1065\\u1066\\u106E-\\u1070\\u1075-\\u1081\\u108E\\u10A0-\\u10C5\\u10C7\\u10CD\\u10D0-\\u10FA\\u10FC-\\u1248\\u124A-\\u124D\\u1250-\\u1256\\u1258\\u125A-\\u125D\\u1260-\\u1288\\u128A-\\u128D\\u1290-\\u12B0\\u12B2-\\u12B5\\u12B8-\\u12BE\\u12C0\\u12C2-\\u12C5\\u12C8-\\u12D6\\u12D8-\\u1310\\u1312-\\u1315\\u1318-\\u135A\\u1380-\\u138F\\u13A0-\\u13F4\\u1401-\\u166C\\u166F-\\u167F\\u1681-\\u169A\\u16A0-\\u16EA\\u1700-\\u170C\\u170E-\\u1711\\u1720-\\u1731\\u1740-\\u1751\\u1760-\\u176C\\u176E-\\u1770\\u1780-\\u17B3\\u17D7\\u17DC\\u1820-\\u1877\\u1880-\\u18A8\\u18AA\\u18B0-\\u18F5\\u1900-\\u191C\\u1950-\\u196D\\u1970-\\u1974\\u1980-\\u19AB\\u19C1-\\u19C7\\u1A00-\\u1A16\\u1A20-\\u1A54\\u1AA7\\u1B05-\\u1B33\\u1B45-\\u1B4B\\u1B83-\\u1BA0\\u1BAE\\u1BAF\\u1BBA-\\u1BE5\\u1C00-\\u1C23\\u1C4D-\\u1C4F\\u1C5A-\\u1C7D\\u1CE9-\\u1CEC\\u1CEE-\\u1CF1\\u1CF5\\u1CF6\\u1D00-\\u1DBF\\u1E00-\\u1F15\\u1F18-\\u1F1D\\u1F20-\\u1F45\\u1F48-\\u1F4D\\u1F50-\\u1F57\\u1F59\\u1F5B\\u1F5D\\u1F5F-\\u1F7D\\u1F80-\\u1FB4\\u1FB6-\\u1FBC\\u1FBE\\u1FC2-\\u1FC4\\u1FC6-\\u1FCC\\u1FD0-\\u1FD3\\u1FD6-\\u1FDB\\u1FE0-\\u1FEC\\u1FF2-\\u1FF4\\u1FF6-\\u1FFC\\u2071\\u207F\\u2090-\\u209C\\u2102\\u2107\\u210A-\\u2113\\u2115\\u2119-\\u211D\\u2124\\u2126\\u2128\\u212A-\\u212D\\u212F-\\u2139\\u213C-\\u213F\\u2145-\\u2149\\u214E\\u2183\\u2184\\u2C00-\\u2C2E\\u2C30-\\u2C5E\\u2C60-\\u2CE4\\u2CEB-\\u2CEE\\u2CF2\\u2CF3\\u2D00-\\u2D25\\u2D27\\u2D2D\\u2D30-\\u2D67\\u2D6F\\u2D80-\\u2D96\\u2DA0-\\u2DA6\\u2DA8-\\u2DAE\\u2DB0-\\u2DB6\\u2DB8-\\u2DBE\\u2DC0-\\u2DC6\\u2DC8-\\u2DCE\\u2DD0-\\u2DD6\\u2DD8-\\u2DDE\\u2E2F\\u3005\\u3006\\u3031-\\u3035\\u303B\\u303C\\u3041-\\u3096\\u309D-\\u309F\\u30A1-\\u30FA\\u30FC-\\u30FF\\u3105-\\u312D\\u3131-\\u318E\\u31A0-\\u31BA\\u31F0-\\u31FF\\u3400-\\u4DB5\\u4E00-\\u9FCC\\uA000-\\uA48C\\uA4D0-\\uA4FD\\uA500-\\uA60C\\uA610-\\uA61F\\uA62A\\uA62B\\uA640-\\uA66E\\uA67F-\\uA697\\uA6A0-\\uA6E5\\uA717-\\uA71F\\uA722-\\uA788\\uA78B-\\uA78E\\uA790-\\uA793\\uA7A0-\\uA7AA\\uA7F8-\\uA801\\uA803-\\uA805\\uA807-\\uA80A\\uA80C-\\uA822\\uA840-\\uA873\\uA882-\\uA8B3\\uA8F2-\\uA8F7\\uA8FB\\uA90A-\\uA925\\uA930-\\uA946\\uA960-\\uA97C\\uA984-\\uA9B2\\uA9CF\\uAA00-\\uAA28\\uAA40-\\uAA42\\uAA44-\\uAA4B\\uAA60-\\uAA76\\uAA7A\\uAA80-\\uAAAF\\uAAB1\\uAAB5\\uAAB6\\uAAB9-\\uAABD\\uAAC0\\uAAC2\\uAADB-\\uAADD\\uAAE0-\\uAAEA\\uAAF2-\\uAAF4\\uAB01-\\uAB06\\uAB09-\\uAB0E\\uAB11-\\uAB16\\uAB20-\\uAB26\\uAB28-\\uAB2E\\uABC0-\\uABE2\\uAC00-\\uD7A3\\uD7B0-\\uD7C6\\uD7CB-\\uD7FB\\uF900-\\uFA6D\\uFA70-\\uFAD9\\uFB00-\\uFB06\\uFB13-\\uFB17\\uFB1D\\uFB1F-\\uFB28\\uFB2A-\\uFB36\\uFB38-\\uFB3C\\uFB3E\\uFB40\\uFB41\\uFB43\\uFB44\\uFB46-\\uFBB1\\uFBD3-\\uFD3D\\uFD50-\\uFD8F\\uFD92-\\uFDC7\\uFDF0-\\uFDFB\\uFE70-\\uFE74\\uFE76-\\uFEFC\\uFF21-\\uFF3A\\uFF41-\\uFF5A\\uFF66-\\uFFBE\\uFFC2-\\uFFC7\\uFFCA-\\uFFCF\\uFFD2-\\uFFD7\\uFFDA-\\uFFDC'; + +export default class Tokenizer { + public WHITESPACE_REGEX: RegExp; + public NUMBER_REGEX: RegExp; + public OPERATOR_REGEX: RegExp; + public QUERY_SEPARATOR_REGEX: RegExp; + public BLOCK_COMMENT_REGEX: RegExp; + public LINE_COMMENT_REGEX: RegExp; + public RESERVED_TOPLEVEL_REGEX: RegExp; + public RESERVED_NEWLINE_REGEX: RegExp; + public RESERVED_PLAIN_REGEX: RegExp; + public WORD_REGEX: RegExp; + public TABLE_NAME_REGEX: RegExp; + public TABLE_NAME_PREFIX_REGEX: RegExp; + public STRING_REGEX: RegExp; + public OPEN_PAREN_REGEX: RegExp; + public CLOSE_PAREN_REGEX: RegExp; + public INDEXED_PLACEHOLDER_REGEX: RegExp; + public IDENT_NAMED_PLACEHOLDER_REGEX: RegExp; + public STRING_NAMED_PLACEHOLDER_REGEX: RegExp; + + /** + * @param {TokenizerConfig} cfg + * @param {string[]} cfg.reservedWords Reserved words in SQL + * @param {string[]} cfg.reservedToplevelWords Words that are set to new line separately + * @param {string[]} cfg.reservedNewlineWords Words that are set to newline + * @param {string[]} cfg.stringTypes String types to enable: "", '', ``, [], N'' + * @param {string[]} cfg.openParens Opening parentheses to enable, like (, [ + * @param {string[]} cfg.closeParens Closing parentheses to enable, like ), ] + * @param {string[]} cfg.indexedPlaceholderTypes Prefixes for indexed placeholders, like ? + * @param {string[]} cfg.namedPlaceholderTypes Prefixes for named placeholders, like @ and : + * @param {string[]} cfg.lineCommentTypes Line comments to enable, like # and -- + */ + constructor(cfg: TokenizerConfig) { + this.WHITESPACE_REGEX = /^(\s+)/; + this.NUMBER_REGEX = /^((-\s*)?[0-9]+(\.[0-9]+)?|0x[0-9a-fA-F]+|0b[01]+)\b/; + this.OPERATOR_REGEX = /^(!=|<>|==|<=|>=|!<|!>|\|\||::|->>|->|~~\*|~~|!~~\*|!~~|~\*|!~\*|!~|.)/; + this.QUERY_SEPARATOR_REGEX = /^(;[\n\r\s]*)/; + + this.BLOCK_COMMENT_REGEX = /^(\/\*[^]*?(?:\*\/|$))/; + this.LINE_COMMENT_REGEX = this.createLineCommentRegex(cfg.lineCommentTypes); + + this.RESERVED_TOPLEVEL_REGEX = this.createReservedWordRegex(cfg.reservedToplevelWords); + this.RESERVED_NEWLINE_REGEX = this.createReservedWordRegex(cfg.reservedNewlineWords); + this.RESERVED_PLAIN_REGEX = this.createReservedWordRegex(cfg.reservedWords); + this.TABLE_NAME_PREFIX_REGEX = this.createReservedWordRegex(cfg.tableNamePrefixWords); + + this.WORD_REGEX = new RegExp(`^([${wordUTF8}]+)`); + this.TABLE_NAME_REGEX = new RegExp(`^([${wordUTF8}][${wordUTF8}\\.]*|[\\[\`][${wordUTF8}][${wordUTF8}\\. \\-]*[\\]\`])`, 'i'); + this.STRING_REGEX = this.createStringRegex(cfg.stringTypes); + + this.OPEN_PAREN_REGEX = this.createParenRegex(cfg.openParens); + this.CLOSE_PAREN_REGEX = this.createParenRegex(cfg.closeParens); + + this.INDEXED_PLACEHOLDER_REGEX = this.createPlaceholderRegex(cfg.indexedPlaceholderTypes, '[0-9]*'); + this.IDENT_NAMED_PLACEHOLDER_REGEX = this.createPlaceholderRegex(cfg.namedPlaceholderTypes, '[a-zA-Z0-9._$]+'); + this.STRING_NAMED_PLACEHOLDER_REGEX = this.createPlaceholderRegex( + cfg.namedPlaceholderTypes, + this.createStringPattern(cfg.stringTypes) + ); + } + + createLineCommentRegex(lineCommentTypes) { + return new RegExp(`^((?:${lineCommentTypes.map(c => escapeRegExp(c)).join('|')}).*?(?:\r\n|\n|$))`); + } + + createReservedWordRegex(reservedWords) { + const reservedWordsPattern = reservedWords.join('|').replace(/ /g, '\\s+'); + return new RegExp(`^(${reservedWordsPattern})\\b`, 'i'); + } + + createStringRegex(stringTypes) { + return new RegExp('^(' + this.createStringPattern(stringTypes) + ')'); + } + + // This enables the following string patterns: + // 1. backtick quoted string using `` to escape + // 2. square bracket quoted string (SQL Server) using ]] to escape + // 3. double quoted string using "" or \" to escape + // 4. single quoted string using '' or \' to escape + // 5. national character quoted string using N'' or N\' to escape + createStringPattern(stringTypes) { + const patterns = { + '``': '((`[^`]*($|`))+)', + '[]': '((\\[[^\\]]*($|\\]))(\\][^\\]]*($|\\]))*)', + '""': '(("[^"\\\\]*(?:\\\\.[^"\\\\]*)*("|$))+)', + "''": "(('[^'\\\\]*(?:\\\\.[^'\\\\]*)*('|$))+)", + "N''": "((N'[^N'\\\\]*(?:\\\\.[^N'\\\\]*)*('|$))+)", + }; + + return stringTypes.map(t => patterns[t]).join('|'); + } + + createParenRegex(parens) { + return new RegExp('^(' + parens.map(p => this.escapeParen(p)).join('|') + ')', 'i'); + } + + escapeParen(paren) { + if (paren.length === 1) { + // A single punctuation character + return escapeRegExp(paren); + } else { + // longer word + return '\\b' + paren + '\\b'; + } + } + + createPlaceholderRegex(types, pattern) { + const typesRegex = types.map(escapeRegExp).join('|'); + + return new RegExp(`^((?:${typesRegex})(?:${pattern}))`); + } + + /** + * Takes a SQL string and breaks it into tokens. + * Each token is an object with type and value. + * + * @param {string} input The SQL string + * @return {Object[]} tokens An array of tokens. + * @return {string} token.type + * @return {string} token.value + */ + tokenize(input: string): Token[] { + const tokens = []; + let token: Token; + + // Keep processing the string until it is empty + while (input.length) { + // Get the next token and the token type + token = this.getNextToken(input, this.getPreviousToken(tokens), this.getPreviousToken(tokens, 1)); + // Advance the string + input = input.substring(token.value.length); + + tokens.push(token); + } + return tokens; + } + + getNextToken(input: string, tokenMinus1?: Token, tokenMinus2?: Token): Token { + return ( + this.getWhitespaceToken(input) || + this.getCommentToken(input) || + this.getStringToken(input) || + this.getOpenParenToken(input) || + this.getCloseParenToken(input) || + this.getServerVariableToken(input) || + this.getPlaceholderToken(input) || + this.getNumberToken(input) || + this.getReservedWordToken(input, tokenMinus1) || + this.getTableNameToken(input, tokenMinus1, tokenMinus2) || + this.getWordToken(input) || + this.getQuerySeparatorToken(input) || + this.getOperatorToken(input) + ); + } + + getWhitespaceToken(input: string): Token { + return this.getTokenOnFirstMatch({ + input, + type: TokenTypes.WHITESPACE, + regex: this.WHITESPACE_REGEX, + }); + } + + getCommentToken(input: string): Token { + return this.getLineCommentToken(input) || this.getBlockCommentToken(input); + } + + getLineCommentToken(input: string): Token { + return this.getTokenOnFirstMatch({ + input, + type: TokenTypes.LINE_COMMENT, + regex: this.LINE_COMMENT_REGEX, + }); + } + + getBlockCommentToken(input: string): Token { + return this.getTokenOnFirstMatch({ + input, + type: TokenTypes.BLOCK_COMMENT, + regex: this.BLOCK_COMMENT_REGEX, + }); + } + + getStringToken(input: string): Token { + return this.getTokenOnFirstMatch({ + input, + type: TokenTypes.STRING, + regex: this.STRING_REGEX, + }); + } + + getOpenParenToken(input: string): Token { + return this.getTokenOnFirstMatch({ + input, + type: TokenTypes.OPEN_PAREN, + regex: this.OPEN_PAREN_REGEX, + }); + } + + getCloseParenToken(input: string): Token { + return this.getTokenOnFirstMatch({ + input, + type: TokenTypes.CLOSE_PAREN, + regex: this.CLOSE_PAREN_REGEX, + }); + } + + getPlaceholderToken(input: string): Token { + return ( + this.getIdentNamedPlaceholderToken(input) || + this.getStringNamedPlaceholderToken(input) || + this.getIndexedPlaceholderToken(input) + ); + } + + getServerVariableToken(input: string): Token { + return this.getTokenOnFirstMatch({ + input, + type: TokenTypes.SERVERVARIABLE, + regex: /(^@@\w+)/i, + }); + } + + getIdentNamedPlaceholderToken(input: string): Token { + return this.getPlaceholderTokenWithKey({ + input, + regex: this.IDENT_NAMED_PLACEHOLDER_REGEX, + parseKey: v => v.slice(1), + }); + } + + getStringNamedPlaceholderToken(input: string): Token { + return this.getPlaceholderTokenWithKey({ + input, + regex: this.STRING_NAMED_PLACEHOLDER_REGEX, + parseKey: v => this.getEscapedPlaceholderKey({ key: v.slice(2, -1), quoteChar: v.slice(-1) }), + }); + } + + getIndexedPlaceholderToken(input: string): Token { + return this.getPlaceholderTokenWithKey({ + input, + regex: this.INDEXED_PLACEHOLDER_REGEX, + parseKey: v => v.slice(1), + }); + } + + getPlaceholderTokenWithKey({ input, regex, parseKey }) { + const token = this.getTokenOnFirstMatch({ input, regex, type: TokenTypes.PLACEHOLDER }); + if (token) { + token.key = parseKey(token.value); + } + return token; + } + + getEscapedPlaceholderKey({ key, quoteChar }) { + return key.replace(new RegExp(escapeRegExp('\\') + quoteChar, 'g'), quoteChar); + } + + // Decimal, binary, or hex numbers + getNumberToken(input: string): Token { + return this.getTokenOnFirstMatch({ + input, + type: TokenTypes.NUMBER, + regex: this.NUMBER_REGEX, + }); + } + + // Punctuation and symbols + getOperatorToken(input: string): Token { + return this.getTokenOnFirstMatch({ + input, + type: TokenTypes.OPERATOR, + regex: this.OPERATOR_REGEX, + }); + } + getQuerySeparatorToken(input: string): Token { + return this.getTokenOnFirstMatch({ + input, + type: TokenTypes.QUERY_SEPARATOR, + regex: this.QUERY_SEPARATOR_REGEX, + }); + } + + getReservedWordToken(input, previousToken) { + // A reserved word cannot be preceded by a "." + // this makes it so in "mytable.from", "from" is not considered a reserved word + if (previousToken && previousToken.value && previousToken.value === '.') { + return; + } + return ( + this.getToplevelTablePrefixReservedToken(input) || this.getToplevelReservedToken(input) || this.getNewlineReservedToken(input) || this.getPlainReservedToken(input) + ); + } + + getTableNameToken(input, tokenMinus1, tokenMinus2) { + if (tokenMinus1 && tokenMinus1.value && tokenMinus1.value.trim() !== '') { + return; + } + if (tokenMinus2 && tokenMinus2.value && tokenMinus2.type !== TokenTypes.TABLENAME_PREFIX) { + return; + } + + return this.getTokenOnFirstMatch({ + input, + type: TokenTypes.TABLENAME, + regex: this.TABLE_NAME_REGEX, + }); + } + + getToplevelTablePrefixReservedToken(input: string): Token { + return this.getTokenOnFirstMatch({ + input, + type: TokenTypes.TABLENAME_PREFIX, + regex: this.TABLE_NAME_PREFIX_REGEX, + }); + } + + getToplevelReservedToken(input: string): Token { + return this.getTokenOnFirstMatch({ + input, + type: TokenTypes.RESERVED_TOPLEVEL, + regex: this.RESERVED_TOPLEVEL_REGEX, + }); + } + + getNewlineReservedToken(input: string): Token { + return this.getTokenOnFirstMatch({ + input, + type: TokenTypes.RESERVED_NEWLINE, + regex: this.RESERVED_NEWLINE_REGEX, + }); + } + + getPlainReservedToken(input: string): Token { + return this.getTokenOnFirstMatch({ + input, + type: TokenTypes.RESERVED, + regex: this.RESERVED_PLAIN_REGEX, + }); + } + + getWordToken(input: string): Token { + return this.getTokenOnFirstMatch({ + input, + type: TokenTypes.WORD, + regex: this.WORD_REGEX, + }); + } + + getTokenOnFirstMatch({ input, type, regex }: { input: string; type: TokenTypes; regex: RegExp }): Token { + const matches = input.match(regex); + + if (matches) { + return { type, value: matches[1] }; + } + } + + getPreviousToken(tokens: Token[], offset = 0) { + return tokens[tokens.length - offset - 1] || { value: null, type: null }; + } +} diff --git a/packages/formatter/src/core/types.ts b/packages/formatter/src/core/types.ts new file mode 100644 index 000000000..8fbc0d05e --- /dev/null +++ b/packages/formatter/src/core/types.ts @@ -0,0 +1,45 @@ +/** + * Constants for token types + */ +export enum TokenTypes { + WHITESPACE = 'whitespace', + WORD = 'word', + STRING = 'string', + RESERVED = 'reserved', + RESERVED_TOPLEVEL = 'reserved-toplevel', + RESERVED_NEWLINE = 'reserved-newline', + OPERATOR = 'operator', + QUERY_SEPARATOR = 'query-separator', + OPEN_PAREN = 'open-paren', + CLOSE_PAREN = 'close-paren', + LINE_COMMENT = 'line-comment', + BLOCK_COMMENT = 'block-comment', + NUMBER = 'number', + PLACEHOLDER = 'placeholder', + SERVERVARIABLE = 'servervariable', + TABLENAME_PREFIX = 'tablename-prefix', + TABLENAME = 'tablename', +} +export interface Config { + indent?: string; + reservedWordCase?: string; + params?: Object; +} +export interface TokenizerConfig { + reservedWords: string[]; + reservedToplevelWords: string[]; + reservedNewlineWords: string[]; + tableNamePrefixWords: string[]; + stringTypes: string[]; + openParens: string[]; + closeParens: string[]; + indexedPlaceholderTypes?: string[]; + namedPlaceholderTypes: string[]; + lineCommentTypes: string[]; +} + +export interface Token { + type: TokenTypes; + value: string; + key?: string; +} diff --git a/packages/formatter/src/languages/StandardSqlFormatter.ts b/packages/formatter/src/languages/StandardSqlFormatter.ts new file mode 100644 index 000000000..d73882055 --- /dev/null +++ b/packages/formatter/src/languages/StandardSqlFormatter.ts @@ -0,0 +1,384 @@ +import Formatter from '../core/Formatter'; +import Tokenizer from '../core/Tokenizer'; +import { Config, Token } from '../core/types'; + +let tokenizer: Tokenizer; + +export default class StandardSqlFormatter { + /** + * @param {Config} cfg Different set of configurations + */ + constructor(public cfg: Config) {} + + /** + * Format the whitespace in a Standard SQL string to make it easier to read + * + * @param {String} query The Standard SQL string + * @return {String} formatted string + */ + format(query) { + return new Formatter(this.cfg, getTokenizer()).format(query); + } + + tokenize(query): Token[] { + return getTokenizer().tokenize(query); + } +} + +function getTokenizer(): Tokenizer { + if (!tokenizer) { + tokenizer = new Tokenizer({ + reservedWords, + reservedToplevelWords, + reservedNewlineWords, + stringTypes: [`""`, "N''", "''", '``', '[]'], + openParens: ['(', 'CASE'], + closeParens: [')', 'END'], + indexedPlaceholderTypes: ['?'], + namedPlaceholderTypes: ['@', ':', '%'], + lineCommentTypes: ['#', '--'], + tableNamePrefixWords, + }); + } + return tokenizer; +} + +const tableNamePrefixWords = [ + 'UPDATE', + 'DELETE FROM', + 'FROM', + 'CROSS JOIN', + 'INNER JOIN', + 'LEFT JOIN', + 'LEFT OUTER JOIN', + 'OUTER JOIN', + 'RIGHT JOIN', + 'RIGHT OUTER JOIN', + 'JOIN', + 'INSERT INTO', + 'INSERT', + 'ALTER TABLE', +] + +const reservedWords = [ + 'ACCESSIBLE', + 'ACTION', + 'AGAINST', + 'AGGREGATE', + 'ALGORITHM', + 'ALL', + 'ALTER', + 'ANALYSE', + 'ANALYZE', + 'AS', + 'ASC', + 'AUTOCOMMIT', + 'AUTO_INCREMENT', + 'BACKUP', + 'BEGIN', + 'BETWEEN', + 'BINLOG', + 'BOTH', + 'CASCADE', + 'CASE', + 'CHANGE', + 'CHANGED', + 'CHARACTER SET', + 'CHARSET', + 'CHECK', + 'CHECKSUM', + 'COLLATE', + 'COLLATION', + 'COLUMN', + 'COLUMNS', + 'COMMENT', + 'COMMIT', + 'COMMITTED', + 'COMPRESSED', + 'CONCURRENT', + 'CONSTRAINT', + 'CONTAINS', + 'CONVERT', + 'CREATE', + 'CROSS', + 'CURRENT_TIMESTAMP', + 'DATABASE', + 'DATABASES', + 'DAY', + 'DAY_HOUR', + 'DAY_MINUTE', + 'DAY_SECOND', + 'DEFAULT', + 'DEFINER', + 'DELAYED', + 'DELETE', + 'DESC', + 'DESCRIBE', + 'DETERMINISTIC', + 'DISTINCT', + 'DISTINCTROW', + 'DIV', + 'DO', + 'DROP', + 'DUMPFILE', + 'DUPLICATE', + 'DYNAMIC', + 'ELSE', + 'ENCLOSED', + 'END', + 'ENGINE', + 'ENGINES', + 'ENGINE_TYPE', + 'ESCAPE', + 'ESCAPED', + 'EVENTS', + 'EXEC', + 'EXECUTE', + 'EXISTS', + 'EXPLAIN', + 'EXTENDED', + 'FAST', + 'FETCH', + 'FIELDS', + 'FILE', + 'FIRST', + 'FIXED', + 'FLUSH', + 'FOR', + 'FORCE', + 'FOREIGN', + 'FULL', + 'FULLTEXT', + 'FUNCTION', + 'GLOBAL', + 'GRANT', + 'GRANTS', + 'GROUP_CONCAT', + 'HEAP', + 'HIGH_PRIORITY', + 'HOSTS', + 'HOUR', + 'HOUR_MINUTE', + 'HOUR_SECOND', + 'IDENTIFIED', + 'IF', + 'IFNULL', + 'IGNORE', + 'IN', + 'INDEX', + 'INDEXES', + 'INFILE', + 'INSERT', + 'INSERT_ID', + 'INSERT_METHOD', + 'INTERVAL', + 'INTO', + 'INVOKER', + 'IS', + 'ISOLATION', + 'KEY', + 'KEYS', + 'KILL', + 'LAST_INSERT_ID', + 'LEADING', + 'LEVEL', + 'LIKE', + 'LINEAR', + 'LINES', + 'LOAD', + 'LOCAL', + 'LOCK', + 'LOCKS', + 'LOGS', + 'LOW_PRIORITY', + 'MARIA', + 'MASTER', + 'MASTER_CONNECT_RETRY', + 'MASTER_HOST', + 'MASTER_LOG_FILE', + 'MATCH', + 'MAX_CONNECTIONS_PER_HOUR', + 'MAX_QUERIES_PER_HOUR', + 'MAX_ROWS', + 'MAX_UPDATES_PER_HOUR', + 'MAX_USER_CONNECTIONS', + 'MEDIUM', + 'MERGE', + 'MINUTE', + 'MINUTE_SECOND', + 'MIN_ROWS', + 'MODE', + 'MODIFY', + 'MONTH', + 'MRG_MYISAM', + 'MYISAM', + 'NAMES', + 'NATURAL', + 'NOT', + 'NOW()', + 'NULL', + 'OFFSET', + 'ON DELETE', + 'ON UPDATE', + 'ON', + 'ONLY', + 'OPEN', + 'OPTIMIZE', + 'OPTION', + 'OPTIONALLY', + 'OUTFILE', + 'PACK_KEYS', + 'PAGE', + 'PARTIAL', + 'PARTITION', + 'PARTITIONS', + 'PASSWORD', + 'PRIMARY', + 'PRIVILEGES', + 'PROCEDURE', + 'PROCESS', + 'PROCESSLIST', + 'PURGE', + 'QUICK', + 'RAID0', + 'RAID_CHUNKS', + 'RAID_CHUNKSIZE', + 'RAID_TYPE', + 'RANGE', + 'READ', + 'READ_ONLY', + 'READ_WRITE', + 'REFERENCES', + 'REGEXP', + 'RELOAD', + 'RENAME', + 'REPAIR', + 'REPEATABLE', + 'REPLACE', + 'REPLICATION', + 'RESET', + 'RESTORE', + 'RESTRICT', + 'RETURN', + 'RETURNS', + 'REVOKE', + 'RLIKE', + 'ROLLBACK', + 'ROW', + 'ROWS', + 'ROW_FORMAT', + 'SECOND', + 'SECURITY', + 'SEPARATOR', + 'SERIALIZABLE', + 'SESSION', + 'SHARE', + 'SHOW', + 'SHUTDOWN', + 'SLAVE', + 'SONAME', + 'SOUNDS', + 'SQL', + 'SQL_AUTO_IS_NULL', + 'SQL_BIG_RESULT', + 'SQL_BIG_SELECTS', + 'SQL_BIG_TABLES', + 'SQL_BUFFER_RESULT', + 'SQL_CACHE', + 'SQL_CALC_FOUND_ROWS', + 'SQL_LOG_BIN', + 'SQL_LOG_OFF', + 'SQL_LOG_UPDATE', + 'SQL_LOW_PRIORITY_UPDATES', + 'SQL_MAX_JOIN_SIZE', + 'SQL_NO_CACHE', + 'SQL_QUOTE_SHOW_CREATE', + 'SQL_SAFE_UPDATES', + 'SQL_SELECT_LIMIT', + 'SQL_SLAVE_SKIP_COUNTER', + 'SQL_SMALL_RESULT', + 'SQL_WARNINGS', + 'START', + 'STARTING', + 'STATUS', + 'STOP', + 'STORAGE', + 'STRAIGHT_JOIN', + 'STRING', + 'STRIPED', + 'SUPER', + 'TABLE', + 'TABLES', + 'TEMPORARY', + 'TERMINATED', + 'THEN', + 'TO', + 'TRAILING', + 'TRANSACTIONAL', + 'TRUE', + 'TRUNCATE', + 'TYPE', + 'TYPES', + 'UNCOMMITTED', + 'UNIQUE', + 'UNLOCK', + 'UNSIGNED', + 'USAGE', + 'USE', + 'USING', + 'VARIABLES', + 'VIEW', + 'WHEN', + 'WITH', + 'WORK', + 'WRITE', + 'YEAR_MONTH', +]; + +const reservedToplevelWords = [ + 'ADD', + 'AFTER', + 'ALTER COLUMN', + 'ALTER TABLE', + 'DELETE FROM', + 'EXCEPT', + 'FETCH FIRST', + 'FROM', + 'GROUP BY', + 'GO', + 'HAVING', + 'INSERT INTO', + 'INSERT', + 'INTERSECT', + 'LIMIT', + 'MODIFY', + 'ORDER BY', + 'SELECT', + 'SET CURRENT SCHEMA', + 'SET SCHEMA', + 'SET', + 'UNION ALL', + 'UNION', + 'UPDATE', + 'VALUES', + 'WHERE', +]; + +const reservedNewlineWords = [ + 'AND', + 'CROSS APPLY', + 'CROSS JOIN', + 'ELSE', + 'INNER JOIN', + 'JOIN', + 'LEFT JOIN', + 'LEFT OUTER JOIN', + 'OR', + 'OUTER APPLY', + 'OUTER JOIN', + 'RENAME', + 'RIGHT JOIN', + 'RIGHT OUTER JOIN', + 'WHEN', + 'XOR', +]; diff --git a/packages/formatter/src/sqlFormatter.ts b/packages/formatter/src/sqlFormatter.ts new file mode 100644 index 000000000..a7670d917 --- /dev/null +++ b/packages/formatter/src/sqlFormatter.ts @@ -0,0 +1,30 @@ +import StandardSqlFormatter from './languages/StandardSqlFormatter'; +import { Config, Token } from './core/types'; + +export default { + /** + * Format whitespaces in a query to make it easier to read. + * + * @param {string} query + * @param {Config} cfg + * @param {string} cfg.language Query language, default is Standard SQL + * @param {string} cfg.indent Characters used for indentation, default is " " (2 spaces) + * @param {string} cfg.reservedWordCase Reserverd case change. Allowed upper, lower, null. Default null (no changes). + * @param {any} cfg.params Collection of params for placeholder replacement + * @return {string} + */ + format: (query: string, cfg: Config = {}): string => { + return new StandardSqlFormatter(cfg).format(query); + }, + + /** + * Tokenize query. + * + * @param {string} query + * @param {Config} cfg + * @return {Token[]} + */ + tokenize: (query: string, cfg: Config = {}): Token[] => { + return new StandardSqlFormatter(cfg).tokenize(query); + }, +}; diff --git a/packages/formatter/test/StandardSqlFormatter.test.ts b/packages/formatter/test/StandardSqlFormatter.test.ts new file mode 100644 index 000000000..a813f5d10 --- /dev/null +++ b/packages/formatter/test/StandardSqlFormatter.test.ts @@ -0,0 +1,519 @@ +import sqlFormatter from "../src/sqlFormatter"; +import behavesLikeSqlFormatter from "./behavesLikeSqlFormatter"; + +describe("StandardSqlFormatter", function() { + behavesLikeSqlFormatter(); + + it("formats short CREATE TABLE", function() { + expect(sqlFormatter.format( + "CREATE TABLE items (a INT PRIMARY KEY, b TEXT);" + )).toBe( + "CREATE TABLE items (a INT PRIMARY KEY, b TEXT);" + ); + }); + + it("formats long CREATE TABLE", function() { + expect(sqlFormatter.format( + "CREATE TABLE items (a INT PRIMARY KEY, b TEXT, c INT NOT NULL, d INT NOT NULL);" + )).toBe( + "CREATE TABLE items (\n" + + " a INT PRIMARY KEY,\n" + + " b TEXT,\n" + + " c INT NOT NULL,\n" + + " d INT NOT NULL\n" + + ");" + ); + }); + + it("formats INSERT without INTO", function() { + const result = sqlFormatter.format( + "INSERT Customers (ID, MoneyBalance, Address, City) VALUES (12,-123.4, 'Skagen 2111','Stv');" + ); + expect(result).toBe( + "INSERT Customers (ID, MoneyBalance, Address, City)\n" + + "VALUES\n" + + " (12, -123.4, 'Skagen 2111', 'Stv');" + ); + }); + + it("formats ALTER TABLE ... MODIFY query", function() { + const result = sqlFormatter.format( + "ALTER TABLE supplier MODIFY supplier_name char(100) NOT NULL;" + ); + expect(result).toBe( + "ALTER TABLE supplier\n" + + "MODIFY\n" + + " supplier_name char(100) NOT NULL;" + ); + }); + + it("formats ALTER TABLE ... ALTER COLUMN query", function() { + const result = sqlFormatter.format( + "ALTER TABLE supplier ALTER COLUMN supplier_name VARCHAR(100) NOT NULL;" + ); + expect(result).toBe( + "ALTER TABLE supplier\n" + + "ALTER COLUMN\n" + + " supplier_name VARCHAR(100) NOT NULL;" + ); + }); + + it("recognizes [] strings", function() { + expect(sqlFormatter.format("[foo JOIN bar]")).toBe("[foo JOIN bar]"); + expect(sqlFormatter.format("[foo ]] JOIN bar]")).toBe("[foo ]] JOIN bar]"); + }); + + it("recognizes @variables", function() { + const result = sqlFormatter.format( + "SELECT @variable, @a1_2.3$, @'var name', @\"var name\", @`var name`, @[var name];" + ); + expect(result).toBe( + "SELECT\n" + + " @variable,\n" + + " @a1_2.3$,\n" + + " @'var name',\n" + + " @\"var name\",\n" + + " @`var name`,\n" + + " @[var name];" + ); + }); + + it("recognizes mssql server @variables", function() { + const result = sqlFormatter.format( + "SELECT @@SERVERNAME AS servername, @@SERVER_NAME AS server_name" + ); + expect(result).toBe( + "SELECT\n" + + " @@SERVERNAME AS servername,\n" + + " @@SERVER_NAME AS server_name" + ); + }); + + it("replaces @variables with param values", function() { + const result = sqlFormatter.format( + "SELECT @variable, @a1_2.3$, @'var name', @\"var name\", @`var name`, @[var name], @'var\\name';", + { + params: { + "variable": "\"variable value\"", + "a1_2.3$": "'weird value'", + "var name": "'var value'", + "var\\name": "'var\\ value'" + } + } + ); + expect(result).toBe( + "SELECT\n" + + " \"variable value\",\n" + + " 'weird value',\n" + + " 'var value',\n" + + " 'var value',\n" + + " 'var value',\n" + + " 'var value',\n" + + " 'var\\ value';" + ); + }); + + it("recognizes :variables", function() { + const result = sqlFormatter.format( + "SELECT :variable, :a1_2.3$, :'var name', :\"var name\", :`var name`, :[var name];" + ); + expect(result).toBe( + "SELECT\n" + + " :variable,\n" + + " :a1_2.3$,\n" + + " :'var name',\n" + + " :\"var name\",\n" + + " :`var name`,\n" + + " :[var name];" + ); + }); + + it("replaces :variables with param values", function() { + const result = sqlFormatter.format( + "SELECT :variable, :a1_2.3$, :'var name', :\"var name\", :`var name`," + + " :[var name], :'escaped \\'var\\'', :\"^*& weird \\\" var \";", + { + params: { + "variable": "\"variable value\"", + "a1_2.3$": "'weird value'", + "var name": "'var value'", + "escaped 'var'": "'weirder value'", + "^*& weird \" var ": "'super weird value'" + } + } + ); + expect(result).toBe( + "SELECT\n" + + " \"variable value\",\n" + + " 'weird value',\n" + + " 'var value',\n" + + " 'var value',\n" + + " 'var value',\n" + + " 'var value',\n" + + " 'weirder value',\n" + + " 'super weird value';" + ); + }); + + it("recognizes ?[0-9]* placeholders", function() { + const result = sqlFormatter.format("SELECT ?1, ?25, ?;"); + expect(result).toBe( + "SELECT\n" + + " ?1,\n" + + " ?25,\n" + + " ?;" + ); + }); + + it("replaces ? numbered placeholders with param values", function() { + const result = sqlFormatter.format("SELECT ?1, ?2, ?0;", { + params: { + 0: "first", + 1: "second", + 2: "third" + } + }); + expect(result).toBe( + "SELECT\n" + + " second,\n" + + " third,\n" + + " first;" + ); + }); + + it("replaces ? indexed placeholders with param values", function() { + const result = sqlFormatter.format("SELECT ?, ?, ?;", { + params: ["first", "second", "third"] + }); + expect(result).toBe( + "SELECT\n" + + " first,\n" + + " second,\n" + + " third;" + ); + }); + + it("recognizes %s placeholders", function() { + const result = sqlFormatter.format( + "SELECT %s, %s, %s, %s, %d, %f FROM table WHERE id = %d;" + ); + expect(result).toBe( + "SELECT\n" + + " %s,\n" + + " %s,\n" + + " %s,\n" + + " %s,\n" + + " %d,\n" + + " %f\n" + + "FROM table\n" + + "WHERE\n" + + " id = %d;" + ); + }); + + + it("formats query with GO batch separator", function() { + const result = sqlFormatter.format("SELECT 1 GO SELECT 2", { + params: ["first", "second", "third"] + }); + expect(result).toBe( + "SELECT\n" + + " 1\n" + + "GO\n" + + "SELECT\n" + + " 2" + ); + }); + + it("formats SELECT query with CROSS JOIN", function() { + const result = sqlFormatter.format("SELECT a, b FROM t CROSS JOIN t2 on t.id = t2.id_t"); + expect(result).toBe( + "SELECT\n" + + " a,\n" + + " b\n" + + "FROM t\n" + + "CROSS JOIN t2 on t.id = t2.id_t" + ); + }); + + it("formats SELECT query with CROSS APPLY", function() { + const result = sqlFormatter.format("SELECT a, b FROM t CROSS APPLY fn(t.id)"); + expect(result).toBe( + "SELECT\n" + + " a,\n" + + " b\n" + + "FROM t\n" + + " CROSS APPLY fn(t.id)" + ); + }); + + it("formats simple SELECT", function() { + const result = sqlFormatter.format("SELECT N, M FROM t"); + expect(result).toBe( + "SELECT\n" + + " N,\n" + + " M\n" + + "FROM t" + ); + }); + + it("formats simple SELECT with national characters (MSSQL)", function() { + const result = sqlFormatter.format("SELECT N'value'"); + expect(result).toBe( + "SELECT\n" + + " N'value'" + ); + }); + + it("formats SELECT query with OUTER APPLY", function() { + const result = sqlFormatter.format("SELECT a, b FROM t OUTER APPLY fn(t.id)"); + expect(result).toBe( + "SELECT\n" + + " a,\n" + + " b\n" + + "FROM t\n" + + " OUTER APPLY fn(t.id)" + ); + }); + + it("formats FETCH FIRST like LIMIT", function() { + const result = sqlFormatter.format( + "SELECT * FETCH FIRST 2 ROWS ONLY;" + ); + expect(result).toBe( + "SELECT\n" + + " *\n" + + "FETCH FIRST\n" + + " 2 ROWS ONLY;" + ); + }); + + it("formats CASE ... WHEN with a blank expression", function() { + const result = sqlFormatter.format( + "CASE WHEN option = 'foo' THEN 1 WHEN option = 'bar' THEN 2 WHEN option = 'baz' THEN 3 ELSE 4 END;" + ); + + expect(result).toBe( + "CASE\n" + + " WHEN option = 'foo' THEN 1\n" + + " WHEN option = 'bar' THEN 2\n" + + " WHEN option = 'baz' THEN 3\n" + + " ELSE 4\n" + + "END;" + ); + }); + + it("formats CASE ... WHEN inside SELECT", function() { + const result = sqlFormatter.format( + "SELECT foo, bar, CASE baz WHEN 'one' THEN 1 WHEN 'two' THEN 2 ELSE 3 END FROM table" + ); + + expect(result).toBe( + "SELECT\n" + + " foo,\n" + + " bar,\n" + + " CASE\n" + + " baz\n" + + " WHEN 'one' THEN 1\n" + + " WHEN 'two' THEN 2\n" + + " ELSE 3\n" + + " END\n" + + "FROM table" + ); + }); + + it("formats CASE ... WHEN with an expression", function() { + const result = sqlFormatter.format( + "CASE toString(getNumber()) WHEN 'one' THEN 1 WHEN 'two' THEN 2 WHEN 'three' THEN 3 ELSE 4 END;" + ); + + expect(result).toBe( + "CASE\n" + + " toString(getNumber())\n" + + " WHEN 'one' THEN 1\n" + + " WHEN 'two' THEN 2\n" + + " WHEN 'three' THEN 3\n" + + " ELSE 4\n" + + "END;" + ); + }); + + it("recognizes lowercase CASE ... END", function() { + const result = sqlFormatter.format( + "case when option = 'foo' then 1 else 2 end;" + ); + + expect(result).toBe( + "case\n" + + " when option = 'foo' then 1\n" + + " else 2\n" + + "end;" + ); + }); + + // Regression test for issue #43 + it("ignores words CASE and END inside other strings", function() { + const result = sqlFormatter.format( + "SELECT CASEDATE, ENDDATE FROM table1;" + ); + + expect(result).toBe( + "SELECT\n" + + " CASEDATE,\n" + + " ENDDATE\n" + + "FROM table1;" + ); + }); + + it("formats tricky line comments", function() { + expect(sqlFormatter.format("SELECT a#comment, here\nFROM b--comment")).toBe( + "SELECT\n" + + " a #comment, here\n" + + "FROM b --comment" + ); + }); + + it("formats line comments followed by semicolon", function() { + expect(sqlFormatter.format("SELECT a FROM b\n--comment\n;")).toBe( + "SELECT\n" + + " a\n" + + "FROM b --comment\n" + + ";" + ); + }); + + it("formats line comments followed by comma", function() { + expect(sqlFormatter.format("SELECT a --comment\n, b")).toBe( + "SELECT\n" + + " a --comment\n" + + ",\n" + + " b" + ); + }); + + it("formats line comments followed by close-paren", function() { + expect(sqlFormatter.format("SELECT ( a --comment\n )")).toBe( +`SELECT + ( + a --comment + )` + ); + }); + + it("formats line comments followed by open-paren", function() { + expect(sqlFormatter.format("SELECT a --comment\n()")).toBe( + "SELECT\n" + + " a --comment\n" + + " ()" + ); + }); + + it("formats lonely semicolon", function() { + expect(sqlFormatter.format(";")).toBe(";"); + }); + + it('Format query with cyrilic chars', () => { + expect(sqlFormatter.format(`select t.column1 Кириллица_cyrilic_alias + , t.column2 Latin_alias + from db_table t + where a >= some_date1 -- from + and a < some_date2 -- to + and b >= some_date3 -- and + and b < some_date4 -- where, select etc. + and 1 = 1`)).toEqual( + `select + t.column1 Кириллица_cyrilic_alias, + t.column2 Latin_alias +from db_table t +where + a >= some_date1 -- from + and a < some_date2 -- to + and b >= some_date3 -- and + and b < some_date4 -- where, select etc. + and 1 = 1`); + }); + + it('Format query with japanese chars', () => { + expect(sqlFormatter.format(`select * from 注文 inner join 注文明細 on 注文.注文id = 注文明細.注文id;`)).toEqual( +`select + * +from 注文 +inner join 注文明細 on 注文.注文id = 注文明細.注文id;`); + }); + + it('Format query with dollar quoting', () => { + expect(sqlFormatter.format(`create function foo() returns void AS $$ + begin + select true; + end; + $$ language PLPGSQL;`)).toEqual( +`create function foo() returns void AS $$ begin +select + true; +end; +$$ language PLPGSQL;`); + }); + + it('Format query with dollar parameters', () => { + expect(sqlFormatter.format(`select * from a where id = $1`)).toEqual( +`select + * +from a +where + id = $1`); + }); +}); + +// @TODO improve this tests +describe('StandardSqlFormatter tokenizer', function() { + it('tokenizes tricky line comments', function() { + expect(sqlFormatter.tokenize('SELECT a#comment, here\nFROM h.b--comment', {})).toEqual([ + { type: 'reserved-toplevel', value: 'SELECT' }, + { type: 'whitespace', value: ' ' }, + { type: 'word', value: 'a' }, + { type: 'line-comment', value: '#comment, here\n' }, + { type: 'tablename-prefix', value: 'FROM' }, + { type: 'whitespace', value: ' ' }, + { type: 'tablename', value: 'h.b' }, + { type: 'line-comment', value: '--comment' }, + ]); + }); + + it('tokenizes tricky line comments using sql as language', function() { + expect(sqlFormatter.tokenize('SELECT a#comment, here\nFROM h.b--comment')).toEqual([ + { type: 'reserved-toplevel', value: 'SELECT' }, + { type: 'whitespace', value: ' ' }, + { type: 'word', value: 'a' }, + { type: 'line-comment', value: '#comment, here\n' }, + { type: 'tablename-prefix', value: 'FROM' }, + { type: 'whitespace', value: ' ' }, + { type: 'tablename', value: 'h.b' }, + { type: 'line-comment', value: '--comment' }, + ]); + }); + + it('tokenize SELECT query with OUTER APPLY', function() { + const result = sqlFormatter.tokenize('SELECT a, b FROM t OUTER APPLY fn(t.id)'); + expect(result).toEqual([ + { type: 'reserved-toplevel', value: 'SELECT' }, + { type: 'whitespace', value: ' ' }, + { type: 'word', value: 'a' }, + { type: 'operator', value: ',' }, + { type: 'whitespace', value: ' ' }, + { type: 'word', value: 'b' }, + { type: 'whitespace', value: ' ' }, + { type: 'tablename-prefix', value: 'FROM' }, + { type: 'whitespace', value: ' ' }, + { type: 'tablename', value: 't' }, + { type: 'whitespace', value: ' ' }, + { type: 'reserved-newline', value: 'OUTER APPLY' }, + { type: 'whitespace', value: ' ' }, + { type: 'word', value: 'fn' }, + { type: 'open-paren', value: '(' }, + { type: 'word', value: 't' }, + { type: 'operator', value: '.' }, + { type: 'word', value: 'id' }, + { type: 'close-paren', value: ')' }, + ]); + }); +}); diff --git a/packages/formatter/test/behavesLikeSqlFormatter.ts b/packages/formatter/test/behavesLikeSqlFormatter.ts new file mode 100644 index 000000000..3ca652cd4 --- /dev/null +++ b/packages/formatter/test/behavesLikeSqlFormatter.ts @@ -0,0 +1,517 @@ +import sqlFormatter from "../src/sqlFormatter"; + +/** + * Core tests for all SQL formatters + * @param {String} language + */ +export default function behavesLikeSqlFormatter() { + it("uses given indent config for indention", function() { + const result = sqlFormatter.format( + "SELECT count(*),Column1 FROM Table1;", + {indent: " "} + ); + + expect(result).toBe( + "SELECT\n" + + " count(*),\n" + + " Column1\n" + + "FROM Table1;" + ); + }); + + function format(query) { + return sqlFormatter.format(query); + } + + it("formats simple SET SCHEMA queries", function() { + const result = format("SET SCHEMA tetrisdb; SET CURRENT SCHEMA bingodb;"); + expect(result).toBe( + "SET SCHEMA\n" + + " tetrisdb;\n" + + "SET CURRENT SCHEMA\n" + + " bingodb;" + ); + }); + + it("formats simple SELECT query", function() { + const result = format("SELECT count(*),Column1 FROM Table1;"); + expect(result).toBe( + "SELECT\n" + + " count(*),\n" + + " Column1\n" + + "FROM Table1;" + ); + }); + + it("formats complex SELECT", function() { + const result = format( + "SELECT DISTINCT name, ROUND(age/7) field1, 18 + 20 AS field2, 'some string' FROM foo;" + ); + expect(result).toBe( + "SELECT\n" + + " DISTINCT name,\n" + + " ROUND(age / 7) field1,\n" + + " 18 + 20 AS field2,\n" + + " 'some string'\n" + + "FROM foo;" + ); + }); + + it("formats SELECT with complex WHERE", function() { + const result = sqlFormatter.format( + "SELECT * FROM foo WHERE Column1 = 'testing'" + + "AND ( (Column2 = Column3 OR Column4 >= NOW()) );" + ); + expect(result).toBe( + "SELECT\n" + + " *\n" + + "FROM foo\n" + + "WHERE\n" + + " Column1 = 'testing'\n" + + " AND (\n" + + " (\n" + + " Column2 = Column3\n" + + " OR Column4 >= NOW()\n" + + " )\n" + + " );" + ); + }); + + it("formats SELECT with toplevel reserved words", function() { + const result = format( + "SELECT * FROM foo WHERE name = 'John' GROUP BY some_column " + + "HAVING column > 10 ORDER BY other_column LIMIT 5;" + ); + expect(result).toBe( + "SELECT\n" + + " *\n" + + "FROM foo\n" + + "WHERE\n" + + " name = 'John'\n" + + "GROUP BY\n" + + " some_column\n" + + "HAVING\n" + + " column > 10\n" + + "ORDER BY\n" + + " other_column\n" + + "LIMIT\n" + + " 5;" + ); + }); + + it("formats LIMIT with two comma-separated values on single line", function() { + const result = format( + "LIMIT 5, 10;" + ); + expect(result).toBe( + "LIMIT\n" + + " 5, 10;" + ); + }); + + it("formats LIMIT of single value followed by another SELECT using commas", function() { + const result = format( + "LIMIT 5; SELECT foo, bar;" + ); + expect(result).toBe( + "LIMIT\n" + + " 5;\n" + + "SELECT\n" + + " foo,\n" + + " bar;" + ); + }); + + it("formats LIMIT of single value and OFFSET", function() { + const result = format( + "LIMIT 5 OFFSET 8;" + ); + expect(result).toBe( + "LIMIT\n" + + " 5 OFFSET 8;" + ); + }); + + it("recognizes LIMIT in lowercase", function() { + const result = format( + "limit 5, 10;" + ); + expect(result).toBe( + "limit\n" + + " 5, 10;" + ); + }); + + it("preserves case of keywords", function() { + const result = format( + "select distinct * frOM foo left join bar WHERe a > 1 and b = 3" + ); + expect(result).toBe( + "select\n" + + " distinct *\n" + + "frOM foo\n" + + "left join bar\n" + + "WHERe\n" + + " a > 1\n" + + " and b = 3" + ); + }); + + it("formats SELECT query with SELECT query inside it", function() { + const result = format( + "SELECT *, SUM(*) AS sum FROM (SELECT * FROM Posts LIMIT 30) WHERE a > b" + ); + expect(result).toBe( + "SELECT\n" + + " *,\n" + + " SUM(*) AS sum\n" + + "FROM (\n" + + " SELECT\n" + + " *\n" + + " FROM Posts\n" + + " LIMIT\n" + + " 30\n" + + " )\n" + + "WHERE\n" + + " a > b" + ); + }); + + it("formats SELECT query with INNER JOIN", function() { + const result = format( + "SELECT customer_id.from, COUNT(order_id) AS total FROM customers " + + "INNER JOIN orders ON customers.customer_id = orders.customer_id;" + ); + expect(result).toBe( + "SELECT\n" + + " customer_id.from,\n" + + " COUNT(order_id) AS total\n" + + "FROM customers\n" + + "INNER JOIN orders ON customers.customer_id = orders.customer_id;" + ); + }); + + it("formats SELECT query with different comments", function() { + const result = format( + "SELECT\n" + + "/*\n" + + " * This is a block comment\n" + + " */\n" + + "* FROM\n" + + "-- This is another comment\n" + + "MyTable # One final comment\n" + + "WHERE 1 = 2;" + ); + expect(result).toBe( + "SELECT\n" + + " /*\n" + + " * This is a block comment\n" + + " */\n" + + " *\n" + + "FROM -- This is another comment\n" + + " MyTable # One final comment\n" + + "WHERE\n" + + " 1 = 2;" + ); + }); + + it("formats SELECT query with different comments with \\r\\n as line ending. Issue #3", function() { + const result = format( + "SELECT\r\n" + + "/*\r\n" + + " * This is a block comment\r\n" + + " */\r\n" + + "* FROM\r\n" + + "-- This is another comment\r\n" + + "MyTable # One final comment\r\n" + + "WHERE 1 = 2;" + ); + expect(result).toBe( + "SELECT\n" + + " /*\n" + + " * This is a block comment\n" + + " */\n" + + " *\n" + + "FROM -- This is another comment\n" + + " MyTable # One final comment\n" + + "WHERE\n" + + " 1 = 2;" + ); + }); + + it("formats simple INSERT query", function() { + const result = format( + "INSERT INTO Customers (ID, MoneyBalance, Address, City) VALUES (12,-123.4, 'Skagen 2111','Stv');" + ); + expect(result).toBe( + "INSERT INTO Customers (ID, MoneyBalance, Address, City)\n" + + "VALUES\n" + + " (12, -123.4, 'Skagen 2111', 'Stv');" + ); + }); + + it("keeps short parenthized list with nested parenthesis on single line", function() { + const result = format( + "SELECT (a + b * (c - NOW()));" + ); + expect(result).toBe( + "SELECT\n" + + " (a + b * (c - NOW()));" + ); + }); + + it("breaks long parenthized lists to multiple lines", function() { + const result = format( + "INSERT INTO some_table (id_product, id_shop, id_currency, id_country, id_registration) (" + + "SELECT IF(dq.id_discounter_shopping = 2, dq.value, dq.value / 100)," + + "IF (dq.id_discounter_shopping = 2, 'amount', 'percentage') FROM foo);" + ); + expect(result).toBe( + "INSERT INTO some_table (\n" + + " id_product,\n" + + " id_shop,\n" + + " id_currency,\n" + + " id_country,\n" + + " id_registration\n" + + " ) (\n" + + " SELECT\n" + + " IF(\n" + + " dq.id_discounter_shopping = 2,\n" + + " dq.value,\n" + + " dq.value / 100\n" + + " ),\n" + + " IF (\n" + + " dq.id_discounter_shopping = 2,\n" + + " 'amount',\n" + + " 'percentage'\n" + + " )\n" + + " FROM foo\n" + + " );" + ); + }); + + it("formats simple UPDATE query", function() { + const result = format( + "UPDATE Customers SET ContactName='Alfred Schmidt', City='Hamburg' WHERE CustomerName='Alfreds Futterkiste';" + ); + expect(result).toBe( + "UPDATE Customers\n" + + "SET\n" + + " ContactName = 'Alfred Schmidt',\n" + + " City = 'Hamburg'\n" + + "WHERE\n" + + " CustomerName = 'Alfreds Futterkiste';" + ); + }); + + it("formats simple DELETE query", function() { + const result = format( + "DELETE FROM Customers WHERE CustomerName='Alfred' AND Phone=5002132;" + ); + expect(result).toBe( + "DELETE FROM Customers\n" + + "WHERE\n" + + " CustomerName = 'Alfred'\n" + + " AND Phone = 5002132;" + ); + }); + + it("formats simple DROP query", function() { + const result = format( + "DROP TABLE IF EXISTS admin_role;" + ); + expect(result).toBe( + "DROP TABLE IF EXISTS admin_role;" + ); + }); + + it("formats uncomplete query", function() { + const result = format("SELECT count("); + expect(result).toBe( + "SELECT\n" + + " count(" + ); + }); + + it("formats query that ends with open comment", function() { + const result = format("SELECT count(*)\n/*Comment"); + expect(result).toBe( + "SELECT\n" + + " count(*)\n" + + " /*Comment" + ); + }); + + it("formats UPDATE query with AS part", function() { + const result = format( + "UPDATE customers SET totalorders = ordersummary.total FROM ( SELECT * FROM bank) AS ordersummary" + ); + expect(result).toBe( + "UPDATE customers\n" + + "SET\n" + + " totalorders = ordersummary.total\n" + + "FROM (\n" + + " SELECT\n" + + " *\n" + + " FROM bank\n" + + " ) AS ordersummary" + ); + }); + + it("formats top-level and newline multi-word reserved words with inconsistent spacing", function() { + const result = format("SELECT * FROM foo LEFT \t OUTER \n JOIN bar ORDER \n BY blah"); + expect(result).toBe( + "SELECT\n" + + " *\n" + + "FROM foo\n" + + "LEFT OUTER JOIN bar\n" + + "ORDER BY\n" + + " blah" + ); + }); + + it("formats long double parenthized queries to multiple lines", function() { + const result = format("((foo = '0123456789-0123456789-0123456789-0123456789'))"); + expect(result).toBe( + "(\n" + + " (\n" + + " foo = '0123456789-0123456789-0123456789-0123456789'\n" + + " )\n" + + ")" + ); + }); + + it("formats short double parenthized queries to one line", function() { + const result = format("((foo = 'bar'))"); + expect(result).toBe("((foo = 'bar'))"); + }); + + it("formats single-char operators", function() { + expect(format("foo = bar")).toBe("foo = bar"); + expect(format("foo < bar")).toBe("foo < bar"); + expect(format("foo > bar")).toBe("foo > bar"); + expect(format("foo + bar")).toBe("foo + bar"); + expect(format("foo - bar")).toBe("foo - bar"); + expect(format("foo * bar")).toBe("foo * bar"); + expect(format("foo / bar")).toBe("foo / bar"); + expect(format("foo % bar")).toBe("foo % bar"); + }); + + it("formats multi-char operators", function() { + expect(format("foo != bar")).toBe("foo != bar"); + expect(format("foo <> bar")).toBe("foo <> bar"); + expect(format("foo == bar")).toBe("foo == bar"); // N1QL + expect(format("foo || bar")).toBe("foo || bar"); // Oracle, Postgres, N1QL string concat + + expect(format("foo <= bar")).toBe("foo <= bar"); + expect(format("foo >= bar")).toBe("foo >= bar"); + + expect(format("foo !< bar")).toBe("foo !< bar"); + expect(format("foo !> bar")).toBe("foo !> bar"); + }); + + it("formats logical operators", function() { + expect(format("foo ALL bar")).toBe("foo ALL bar"); + expect(format("foo = ANY (1, 2, 3)")).toBe("foo = ANY (1, 2, 3)"); + expect(format("EXISTS bar")).toBe("EXISTS bar"); + expect(format("foo IN (1, 2, 3)")).toBe("foo IN (1, 2, 3)"); + expect(format("foo LIKE 'hello%'")).toBe("foo LIKE 'hello%'"); + expect(format("foo IS NULL")).toBe("foo IS NULL"); + expect(format("UNIQUE foo")).toBe("UNIQUE foo"); + }); + + it("formats AND/OR operators", function() { + expect(format("foo BETWEEN bar AND baz")).toBe("foo BETWEEN bar\nAND baz"); + expect(format("foo AND bar")).toBe("foo\nAND bar"); + expect(format("foo OR bar")).toBe("foo\nOR bar"); + }); + + it("recognizes strings", function() { + expect(format("\"foo JOIN bar\"")).toBe("\"foo JOIN bar\""); + expect(format("'foo JOIN bar'")).toBe("'foo JOIN bar'"); + expect(format("`foo JOIN bar`")).toBe("`foo JOIN bar`"); + }); + + it("recognizes escaped strings", function() { + expect(format("\"foo \\\" JOIN bar\"")).toBe("\"foo \\\" JOIN bar\""); + expect(format("'foo \\' JOIN bar'")).toBe("'foo \\' JOIN bar'"); + expect(format("`foo `` JOIN bar`")).toBe("`foo `` JOIN bar`"); + }); + + it("formats postgres specific operators", function() { + expect(format("column::int")).toBe("column :: int"); + expect(format("v->2")).toBe("v -> 2"); + expect(format("v->>2")).toBe( "v ->> 2"); + expect(format("foo ~~ 'hello'")).toBe("foo ~~ 'hello'"); + expect(format("foo !~ 'hello'")).toBe("foo !~ 'hello'"); + expect(format("foo ~* 'hello'")).toBe("foo ~* 'hello'"); + expect(format("foo ~~* 'hello'")).toBe("foo ~~* 'hello'"); + expect(format("foo !~~ 'hello'")).toBe("foo !~~ 'hello'"); + expect(format("foo !~* 'hello'")).toBe("foo !~* 'hello'"); + expect(format("foo !~~* 'hello'")).toBe("foo !~~* 'hello'"); + }); + + it("keeps separation between multiple statements", function() { + expect(format("foo;bar;")).toBe("foo;\nbar;"); + expect(format("foo\n;bar;")).toBe("foo;\nbar;"); + expect(format("foo\n\n\n;bar;\n\n")).toBe("foo;\nbar;"); + + const result = format("SELECT count(*),Column1 FROM Table1;\nSELECT count(*),Column1 FROM Table2;"); + expect(result).toBe( + "SELECT\n" + + " count(*),\n" + + " Column1\n" + + "FROM Table1;\n" + + "SELECT\n" + + " count(*),\n" + + " Column1\n" + + "FROM Table2;" + ); + }); + + it("keeps reserved words case", function() { + expect(format("foo;bar;")).toBe("foo;\nbar;"); + expect(format("foo\n;bar;")).toBe("foo;\nbar;"); + expect(format("foo\n\n\n;bar;\n\n")).toBe("foo;\nbar;"); + + const result = sqlFormatter.format("select count(*),Column1 FROM Table1;\nSELECT count(*),Column1 from Table2;"); + expect(result).toBe( + "select\n" + + " count(*),\n" + + " Column1\n" + + "FROM Table1;\n" + + "SELECT\n" + + " count(*),\n" + + " Column1\n" + + "from Table2;" + ); + }); + + it("change reserved words case to upper", function() { + const result = sqlFormatter.format("select count(*),column1 from table1;\nselect count(*),column1 from table2;", { reservedWordCase: 'upper' }); + expect(result).toBe( + "SELECT\n" + + " count(*),\n" + + " column1\n" + + "FROM table1;\n" + + "SELECT\n" + + " count(*),\n" + + " column1\n" + + "FROM table2;" + ); + }); + + it("change reserved words case to lower", function() { + const result = sqlFormatter.format("SELECT count(*),column1 FROM table1;\nSELECT count(*),column1 FROM table2;", { reservedWordCase: 'lower' }); + expect(result).toBe( + "select\n" + + " count(*),\n" + + " column1\n" + + "from table1;\n" + + "select\n" + + " count(*),\n" + + " column1\n" + + "from table2;" + ); + }); +} diff --git a/packages/formatter/tsconfig.json b/packages/formatter/tsconfig.json new file mode 100644 index 000000000..9ad3c3a85 --- /dev/null +++ b/packages/formatter/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "experimentalDecorators": true, + "lib": ["es7"], + "module": "commonjs", + "removeComments": true, + "resolveJsonModule": true, + "outDir": "./lib", + "sourceMap": true, + "target": "ES3", + "rootDir": "src", + }, + "exclude": [ + "node_modules", + "**/test/*" + ] + } diff --git a/packages/language-server/package.json b/packages/language-server/package.json index 77c7c064e..a5af78718 100644 --- a/packages/language-server/package.json +++ b/packages/language-server/package.json @@ -1,6 +1,6 @@ { "name": "@sqltools/language-server", - "version": "0.21.3", + "version": "0.21.4", "description": "SQLTools Language Server", "main": "index.ts", "author": "Matheus Teixeira ", diff --git a/packages/plugins/package.json b/packages/plugins/package.json index eab9f88f0..6738ff115 100644 --- a/packages/plugins/package.json +++ b/packages/plugins/package.json @@ -1,6 +1,6 @@ { "name": "@sqltools/plugins", - "version": "0.21.3", + "version": "0.21.4", "description": "SQLTools Plugins", "author": "Matheus Teixeira ", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index b9e0d9e0c..23417308b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@sqltools/ui", - "version": "0.21.3", + "version": "0.21.4", "description": "UI components for SQLTools", "main": "noop.ts", "author": "Matheus Teixeira ", diff --git a/yarn.lock b/yarn.lock index 1ae827a8a..64e44d3b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3950,9 +3950,9 @@ https-proxy-agent@^2.2.1: debug "^3.1.0" husky@^3.0.3: - version "3.0.9" - resolved "https://registry.yarnpkg.com/husky/-/husky-3.0.9.tgz#a2c3e9829bfd6b4957509a9500d2eef5dbfc8044" - integrity sha512-Yolhupm7le2/MqC1VYLk/cNmYxsSsqKkTyBhzQHhPK1jFnC89mmmNVuGtLNabjDI6Aj8UNIr0KpRNuBkiC4+sg== + version "3.1.0" + resolved "https://registry.yarnpkg.com/husky/-/husky-3.1.0.tgz#5faad520ab860582ed94f0c1a77f0f04c90b57c0" + integrity sha512-FJkPoHHB+6s4a+jwPqBudBDvYZsoQW5/HBuMSehC8qDiCe50kpcxeqFoDSlow+9I6wg47YxBoT3WxaURlrDIIQ== dependencies: chalk "^2.4.2" ci-info "^2.0.0" @@ -8001,7 +8001,23 @@ trim-newlines@^1.0.0: dependencies: glob "^7.1.2" -ts-jest@^24.0.1, ts-jest@^24.0.2: +ts-jest@^24.0.1: + version "24.2.0" + resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-24.2.0.tgz#7abca28c2b4b0a1fdd715cd667d65d047ea4e768" + integrity sha512-Yc+HLyldlIC9iIK8xEN7tV960Or56N49MDP7hubCZUeI7EbIOTsas6rXCMB4kQjLACJ7eDOF4xWEO5qumpKsag== + dependencies: + bs-logger "0.x" + buffer-from "1.x" + fast-json-stable-stringify "2.x" + json5 "2.x" + lodash.memoize "4.x" + make-error "1.x" + mkdirp "0.x" + resolve "1.x" + semver "^5.5" + yargs-parser "10.x" + +ts-jest@^24.0.2: version "24.1.0" resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-24.1.0.tgz#2eaa813271a2987b7e6c3fefbda196301c131734" integrity sha512-HEGfrIEAZKfu1pkaxB9au17b1d9b56YZSqz5eCVE8mX68+5reOvlM93xGOzzCREIov9mdH7JBG+s0UyNAqr0tQ==