From f3f935a58b77ba274d30431dcdf835801ba284a1 Mon Sep 17 00:00:00 2001 From: Christian Jorgensen Date: Sat, 11 Nov 2023 23:11:32 +0100 Subject: [PATCH 1/9] Add option for specifying case for identifiers --- docs/identifierCase.md | 54 +++++++++++++++++++++ src/FormatOptions.ts | 3 ++ src/formatter/ExpressionFormatter.ts | 17 ++++++- src/sqlFormatter.ts | 1 + test/behavesLikeSqlFormatter.ts | 2 + test/options/identifierCase.ts | 71 ++++++++++++++++++++++++++++ 6 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 docs/identifierCase.md create mode 100644 test/options/identifierCase.ts diff --git a/docs/identifierCase.md b/docs/identifierCase.md new file mode 100644 index 0000000000..7dfa50d96d --- /dev/null +++ b/docs/identifierCase.md @@ -0,0 +1,54 @@ +# identifierCase + +Converts identifiers to upper or lowercase. + +## Options + +- `"preserve"` (default) preserves the original case. +- `"upper"` converts to uppercase. +- `"lower"` converts to lowercase. + +### preserve + +``` +select + count(a.Column1), + max(a.Column2 + a.Column3), + a.Column4 AS myCol +from + Table1 as a +where + Column6 + and Column7 +group by Column4 +``` + +### upper + +``` +select + count(a.COLUMN1), + max(a.COLUMN2 + a.COLUMN3), + a.COLUMN4 AS MYCOL +from + TABLE1 as a +where + COLUMN6 + and COLUMN7 +group by COLUMN4 +``` + +### lower + +``` +select + count(a.column1), + max(a.column2 + a.column3), + a.column4 AS mycol +from + table1 as a +where + column6 + and column7 +group by column4 +``` diff --git a/src/FormatOptions.ts b/src/FormatOptions.ts index c785b009fb..c5e4007464 100644 --- a/src/FormatOptions.ts +++ b/src/FormatOptions.ts @@ -6,6 +6,8 @@ export type IndentStyle = 'standard' | 'tabularLeft' | 'tabularRight'; export type KeywordCase = 'preserve' | 'upper' | 'lower'; +export type IdentifierCase = 'preserve' | 'upper' | 'lower'; + export type CommaPosition = 'before' | 'after' | 'tabular'; export type LogicalOperatorNewline = 'before' | 'after'; @@ -14,6 +16,7 @@ export interface FormatOptions { tabWidth: number; useTabs: boolean; keywordCase: KeywordCase; + identifierCase: IdentifierCase; indentStyle: IndentStyle; logicalOperatorNewline: LogicalOperatorNewline; tabulateAlias: boolean; diff --git a/src/formatter/ExpressionFormatter.ts b/src/formatter/ExpressionFormatter.ts index f17ff35954..f84a2d5223 100644 --- a/src/formatter/ExpressionFormatter.ts +++ b/src/formatter/ExpressionFormatter.ts @@ -286,7 +286,7 @@ export default class ExpressionFormatter { } private formatIdentifier(node: IdentifierNode) { - this.layout.add(node.text, WS.SPACE); + this.layout.add(this.showIdentifier(node), WS.SPACE); } private formatParameter(node: ParameterNode) { @@ -506,4 +506,19 @@ export default class ExpressionFormatter { return node.text.toLowerCase(); } } + + private showIdentifier(node: IdentifierNode): string { + if (/['"\\`]/.test(node.text[0]) || node.text.startsWith(`U&`)) { + return node.text; + } else { + switch (this.cfg.identifierCase) { + case 'preserve': + return node.text; + case 'upper': + return node.text.toUpperCase(); + case 'lower': + return node.text.toLowerCase(); + } + } + } } diff --git a/src/sqlFormatter.ts b/src/sqlFormatter.ts index dc5e2c97f6..5af80b04a2 100644 --- a/src/sqlFormatter.ts +++ b/src/sqlFormatter.ts @@ -41,6 +41,7 @@ const defaultOptions: FormatOptions = { tabWidth: 2, useTabs: false, keywordCase: 'preserve', + identifierCase: 'preserve', indentStyle: 'standard', logicalOperatorNewline: 'before', tabulateAlias: false, diff --git a/test/behavesLikeSqlFormatter.ts b/test/behavesLikeSqlFormatter.ts index 1638921140..463f91a665 100644 --- a/test/behavesLikeSqlFormatter.ts +++ b/test/behavesLikeSqlFormatter.ts @@ -9,6 +9,7 @@ import supportsTabWidth from './options/tabWidth.js'; import supportsUseTabs from './options/useTabs.js'; import supportsExpressionWidth from './options/expressionWidth.js'; import supportsKeywordCase from './options/keywordCase.js'; +import supportsIdentifierCase from './options/identifierCase.js'; import supportsIndentStyle from './options/indentStyle.js'; import supportsCommaPosition from './options/commaPosition.js'; import supportsLinesBetweenQueries from './options/linesBetweenQueries.js'; @@ -30,6 +31,7 @@ export default function behavesLikeSqlFormatter(format: FormatFn) { supportsTabWidth(format); supportsUseTabs(format); supportsKeywordCase(format); + supportsIdentifierCase(format); supportsIndentStyle(format); supportsLinesBetweenQueries(format); supportsExpressionWidth(format); diff --git a/test/options/identifierCase.ts b/test/options/identifierCase.ts new file mode 100644 index 0000000000..002bf2e12c --- /dev/null +++ b/test/options/identifierCase.ts @@ -0,0 +1,71 @@ +import dedent from 'dedent-js'; + +import { FormatFn } from '../../src/sqlFormatter.js'; + +export default function supportsIdentifierCase(format: FormatFn) { + it('preserves identifier case by default', () => { + const result = format('select Abc from tBl1 left join Tbl2 where colA > 1 and colB = 3'); + expect(result).toBe(dedent` + select + Abc + from + tBl1 + left join Tbl2 + where + colA > 1 + and colB = 3 + `); + }); + + it('converts identifiers to uppercase', () => { + const result = format('select Abc from tBl1 left join Tbl2 where colA > 1 and colB = 3', { + identifierCase: 'upper', + }); + expect(result).toBe(dedent` + select + ABC + from + TBL1 + left join TBL2 + where + COLA > 1 + and COLB = 3 + `); + }); + + it('converts identifiers to lowercase', () => { + const result = format('select Abc from tBl1 left join Tbl2 where colA > 1 and colB = 3', { + identifierCase: 'lower', + }); + expect(result).toBe(dedent` + select + abc + from + tbl1 + left join tbl2 + where + cola > 1 + and colb = 3 + `); + }); + + it('does not uppercase identifiers inside strings', () => { + const result = format(`select 'abc' as foo`, { + identifierCase: 'upper', + }); + expect(result).toBe(dedent` + select + 'abc' as FOO + `); + }); + + it('does not uppercase identifiers inside quotes', () => { + const result = format(`select "abc" as foo`, { + identifierCase: 'upper', + }); + expect(result).toBe(dedent` + select + "abc" as FOO + `); + }); +} From afe5b48b281957e5904b9d6e271d67c1f272e595 Mon Sep 17 00:00:00 2001 From: Christian Jorgensen Date: Sun, 12 Nov 2023 16:01:05 +0100 Subject: [PATCH 2/9] Describe identifiers and what's subject to conversion --- docs/identifierCase.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/identifierCase.md b/docs/identifierCase.md index 7dfa50d96d..60b1eb7a28 100644 --- a/docs/identifierCase.md +++ b/docs/identifierCase.md @@ -2,6 +2,10 @@ Converts identifiers to upper or lowercase. +Note: An identifier is a name of a SQL object. +There are two types of SQL identifiers: ordinary identifiers and quoted identifiers. +Only ordinary identifiers are subject to be converted. + ## Options - `"preserve"` (default) preserves the original case. From 0c74c02f5abc81842c5e054d260640c87c4e0ef0 Mon Sep 17 00:00:00 2001 From: Christian Jorgensen Date: Sun, 12 Nov 2023 16:02:12 +0100 Subject: [PATCH 3/9] Fix example of identifier uppercase conversion --- docs/identifierCase.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/identifierCase.md b/docs/identifierCase.md index 60b1eb7a28..8ebfbf2928 100644 --- a/docs/identifierCase.md +++ b/docs/identifierCase.md @@ -31,11 +31,11 @@ group by Column4 ``` select - count(a.COLUMN1), - max(a.COLUMN2 + a.COLUMN3), - a.COLUMN4 AS MYCOL + count(A.COLUMN1), + max(A.COLUMN2 + A.COLUMN3), + A.COLUMN4 AS MYCOL from - TABLE1 as a + TABLE1 as A where COLUMN6 and COLUMN7 From d6664d16ef83ea89a7e3be97af2be770b7174ca5 Mon Sep 17 00:00:00 2001 From: Christian Jorgensen Date: Sun, 12 Nov 2023 16:31:39 +0100 Subject: [PATCH 4/9] Export type 'IdentifierCase' --- src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/index.ts b/src/index.ts index 28a1fa161a..c3866d669e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ export type { export type { IndentStyle, KeywordCase, + IdentifierCase, CommaPosition, LogicalOperatorNewline, FormatOptions, From 5354887b842fc4ce2cccad3753e45228f0cc795c Mon Sep 17 00:00:00 2001 From: Christian Jorgensen Date: Sun, 12 Nov 2023 16:34:32 +0100 Subject: [PATCH 5/9] Store identifier type in node --- src/parser/ast.ts | 1 + src/parser/grammar.ne | 12 +++++++++--- test/unit/__snapshots__/Parser.test.ts.snap | 20 ++++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/parser/ast.ts b/src/parser/ast.ts index 2d468ed806..a293a4882d 100644 --- a/src/parser/ast.ts +++ b/src/parser/ast.ts @@ -125,6 +125,7 @@ export interface PropertyAccessNode extends BaseNode { export interface IdentifierNode extends BaseNode { type: NodeType.identifier; + tokenType: TokenType; text: string; } diff --git a/src/parser/grammar.ne b/src/parser/grammar.ne index a13a97772d..a336e5077e 100644 --- a/src/parser/grammar.ne +++ b/src/parser/grammar.ne @@ -1,7 +1,7 @@ @preprocessor typescript @{% import LexerAdapter from './LexerAdapter.js'; -import { NodeType, AstNode, CommentNode, KeywordNode } from './ast.js'; +import { NodeType, AstNode, CommentNode, KeywordNode, IdentifierNode } from './ast.js'; import { Token, TokenType } from '../lexer/token.js'; // The lexer here is only to provide the has() method, @@ -16,6 +16,12 @@ const lexer = new LexerAdapter(chunk => []); // which otherwise produce single element nested inside two arrays const unwrap = ([[el]]: T[][]): T => el; +const toIdentifierNode = (token: Token): IdentifierNode => ({ + type: NodeType.identifier, + tokenType: token.type, + text: token.text, +}); + const toKeywordNode = (token: Token): KeywordNode => ({ type: NodeType.keyword, tokenType: token.type, @@ -202,7 +208,7 @@ atomic_expression -> array_subscript -> %ARRAY_IDENTIFIER _ square_brackets {% ([arrayToken, _, brackets]) => ({ type: NodeType.array_subscript, - array: addComments({ type: NodeType.identifier, text: arrayToken.text}, { trailing: _ }), + array: addComments({ type: NodeType.identifier, tokenType: TokenType.ARRAY_IDENTIFIER, text: arrayToken.text}, { trailing: _ }), parenthesis: brackets, }) %} @@ -309,7 +315,7 @@ operator -> ( %OPERATOR ) {% ([[token]]) => ({ type: NodeType.operator, text: to identifier -> ( %IDENTIFIER | %QUOTED_IDENTIFIER - | %VARIABLE ) {% ([[token]]) => ({ type: NodeType.identifier, text: token.text }) %} + | %VARIABLE ) {% ([[token]]) => toIdentifierNode(token) %} parameter -> ( %NAMED_PARAMETER diff --git a/test/unit/__snapshots__/Parser.test.ts.snap b/test/unit/__snapshots__/Parser.test.ts.snap index e0ca10cabd..b54e0fd2f4 100644 --- a/test/unit/__snapshots__/Parser.test.ts.snap +++ b/test/unit/__snapshots__/Parser.test.ts.snap @@ -8,6 +8,7 @@ Array [ "children": Array [ Object { "text": "age", + "tokenType": "IDENTIFIER", "type": "identifier", }, Object { @@ -127,6 +128,7 @@ Array [ "expr": Array [ Object { "text": "foo", + "tokenType": "IDENTIFIER", "type": "identifier", }, ], @@ -288,6 +290,7 @@ Array [ Object { "object": Object { "text": "ident", + "tokenType": "IDENTIFIER", "type": "identifier", }, "property": Object { @@ -320,6 +323,7 @@ Array [ Object { "array": Object { "text": "my_array", + "tokenType": "ARRAY_IDENTIFIER", "type": "identifier", }, "parenthesis": Object { @@ -360,6 +364,7 @@ Array [ Object { "array": Object { "text": "my_array", + "tokenType": "ARRAY_IDENTIFIER", "trailingComments": Array [ Object { "precedingWhitespace": " ", @@ -408,6 +413,7 @@ Array [ "children": Array [ Object { "text": "foo", + "tokenType": "IDENTIFIER", "type": "identifier", }, Object { @@ -416,6 +422,7 @@ Array [ }, Object { "text": "bar", + "tokenType": "IDENTIFIER", "type": "identifier", }, ], @@ -495,6 +502,7 @@ Array [ }, Object { "text": "a", + "tokenType": "IDENTIFIER", "type": "identifier", }, Object { @@ -517,6 +525,7 @@ Array [ }, Object { "text": "b", + "tokenType": "IDENTIFIER", "type": "identifier", }, ], @@ -541,6 +550,7 @@ Array [ "children": Array [ Object { "text": "foo", + "tokenType": "IDENTIFIER", "type": "identifier", }, ], @@ -551,6 +561,7 @@ Array [ "children": Array [ Object { "text": "bar", + "tokenType": "IDENTIFIER", "type": "identifier", }, ], @@ -570,6 +581,7 @@ Array [ "children": Array [ Object { "text": "birth_year", + "tokenType": "IDENTIFIER", "type": "identifier", }, Object { @@ -580,6 +592,7 @@ Array [ "children": Array [ Object { "text": "CURRENT_DATE", + "tokenType": "IDENTIFIER", "type": "identifier", }, Object { @@ -626,16 +639,19 @@ Array [ "object": Object { "object": Object { "text": "foo", + "tokenType": "IDENTIFIER", "type": "identifier", }, "property": Object { "text": "bar", + "tokenType": "IDENTIFIER", "type": "identifier", }, "type": "property_access", }, "property": Object { "text": "baz", + "tokenType": "IDENTIFIER", "type": "identifier", }, "type": "property_access", @@ -664,6 +680,7 @@ Array [ "children": Array [ Object { "text": "foo", + "tokenType": "IDENTIFIER", "type": "identifier", }, ], @@ -679,6 +696,7 @@ Array [ "children": Array [ Object { "text": "bar", + "tokenType": "IDENTIFIER", "type": "identifier", }, ], @@ -704,6 +722,7 @@ Array [ "children": Array [ Object { "text": "foo", + "tokenType": "IDENTIFIER", "type": "identifier", }, ], @@ -719,6 +738,7 @@ Array [ "children": Array [ Object { "text": "baz", + "tokenType": "IDENTIFIER", "type": "identifier", }, ], From bb2d3ccdfdd1763ea3ce66d7268033a5146fe08e Mon Sep 17 00:00:00 2001 From: Christian Jorgensen Date: Sun, 12 Nov 2023 16:42:38 +0100 Subject: [PATCH 6/9] Only change casing of ordinary identifiers --- src/formatter/ExpressionFormatter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/formatter/ExpressionFormatter.ts b/src/formatter/ExpressionFormatter.ts index f84a2d5223..8f05790e23 100644 --- a/src/formatter/ExpressionFormatter.ts +++ b/src/formatter/ExpressionFormatter.ts @@ -508,7 +508,7 @@ export default class ExpressionFormatter { } private showIdentifier(node: IdentifierNode): string { - if (/['"\\`]/.test(node.text[0]) || node.text.startsWith(`U&`)) { + if (!(node.tokenType === TokenType.IDENTIFIER)) { return node.text; } else { switch (this.cfg.identifierCase) { From 558efa36bfa434fdb672408a2fed5ccaaa4d0359 Mon Sep 17 00:00:00 2001 From: Christian Jorgensen Date: Sun, 12 Nov 2023 17:07:19 +0100 Subject: [PATCH 7/9] Enhance tests for identifier case conversions --- test/options/identifierCase.ts | 46 +++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/test/options/identifierCase.ts b/test/options/identifierCase.ts index 002bf2e12c..1fbd00aed8 100644 --- a/test/options/identifierCase.ts +++ b/test/options/identifierCase.ts @@ -4,10 +4,14 @@ import { FormatFn } from '../../src/sqlFormatter.js'; export default function supportsIdentifierCase(format: FormatFn) { it('preserves identifier case by default', () => { - const result = format('select Abc from tBl1 left join Tbl2 where colA > 1 and colB = 3'); + const result = format( + dedent` + select Abc, 'mytext' as MyText from tBl1 left join Tbl2 where colA > 1 and colB = 3` + ); expect(result).toBe(dedent` select - Abc + Abc, + 'mytext' as MyText from tBl1 left join Tbl2 @@ -18,12 +22,15 @@ export default function supportsIdentifierCase(format: FormatFn) { }); it('converts identifiers to uppercase', () => { - const result = format('select Abc from tBl1 left join Tbl2 where colA > 1 and colB = 3', { - identifierCase: 'upper', - }); + const result = format( + dedent` + select Abc, 'mytext' as MyText from tBl1 left join Tbl2 where colA > 1 and colB = 3`, + { identifierCase: 'upper' } + ); expect(result).toBe(dedent` select - ABC + ABC, + 'mytext' as MYTEXT from TBL1 left join TBL2 @@ -34,12 +41,15 @@ export default function supportsIdentifierCase(format: FormatFn) { }); it('converts identifiers to lowercase', () => { - const result = format('select Abc from tBl1 left join Tbl2 where colA > 1 and colB = 3', { - identifierCase: 'lower', - }); + const result = format( + dedent` + select Abc, 'mytext' as MyText from tBl1 left join Tbl2 where colA > 1 and colB = 3`, + { identifierCase: 'lower' } + ); expect(result).toBe(dedent` select - abc + abc, + 'mytext' as mytext from tbl1 left join tbl2 @@ -49,23 +59,23 @@ export default function supportsIdentifierCase(format: FormatFn) { `); }); - it('does not uppercase identifiers inside strings', () => { - const result = format(`select 'abc' as foo`, { + it('does not uppercase quoted identifiers', () => { + const result = format(`select "abc" as foo`, { identifierCase: 'upper', }); expect(result).toBe(dedent` select - 'abc' as FOO + "abc" as FOO `); }); - it('does not uppercase identifiers inside quotes', () => { - const result = format(`select "abc" as foo`, { - identifierCase: 'upper', - }); + it('converts multi-part identifiers to uppercase', () => { + const result = format('select Abc from Part1.Part2.Part3', { identifierCase: 'upper' }); expect(result).toBe(dedent` select - "abc" as FOO + ABC + from + PART1.PART2.PART3 `); }); } From 0bb7d2137c5073a217e30f82c3fb120ce9df678b Mon Sep 17 00:00:00 2001 From: Christian Jorgensen Date: Mon, 13 Nov 2023 17:00:02 +0100 Subject: [PATCH 8/9] Add conversion of array identifiers --- src/formatter/ExpressionFormatter.ts | 10 ++++++---- test/features/arrayAndMapAccessors.ts | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/formatter/ExpressionFormatter.ts b/src/formatter/ExpressionFormatter.ts index 8f05790e23..fb8c09b33a 100644 --- a/src/formatter/ExpressionFormatter.ts +++ b/src/formatter/ExpressionFormatter.ts @@ -145,7 +145,9 @@ export default class ExpressionFormatter { private formatArraySubscript(node: ArraySubscriptNode) { this.withComments(node.array, () => { this.layout.add( - node.array.type === NodeType.keyword ? this.showKw(node.array) : node.array.text + node.array.type === NodeType.keyword + ? this.showKw(node.array) + : this.showIdentifier(node.array) ); }); this.formatNode(node.parenthesis); @@ -508,9 +510,7 @@ export default class ExpressionFormatter { } private showIdentifier(node: IdentifierNode): string { - if (!(node.tokenType === TokenType.IDENTIFIER)) { - return node.text; - } else { + if (node.tokenType === TokenType.IDENTIFIER || node.tokenType === TokenType.ARRAY_IDENTIFIER) { switch (this.cfg.identifierCase) { case 'preserve': return node.text; @@ -519,6 +519,8 @@ export default class ExpressionFormatter { case 'lower': return node.text.toLowerCase(); } + } else { + return node.text; } } } diff --git a/test/features/arrayAndMapAccessors.ts b/test/features/arrayAndMapAccessors.ts index 3f903becb8..dc6c80bd67 100644 --- a/test/features/arrayAndMapAccessors.ts +++ b/test/features/arrayAndMapAccessors.ts @@ -46,4 +46,20 @@ export default function supportsArrayAndMapAccessors(format: FormatFn) { foo./* comment */ arr[1]; `); }); + + it('formats namespaced array accessor with comment in-between in uppercase', () => { + const result = format(`SELECT foo./* comment */arr[1];`, { identifierCase: 'upper' }); + expect(result).toBe(dedent` + SELECT + FOO./* comment */ ARR[1]; + `); + }); + + it('formats namespaced array accessor with comment in-between in lowercase', () => { + const result = format(`SELECT Foo./* comment */Arr[1];`, { identifierCase: 'lower' }); + expect(result).toBe(dedent` + SELECT + foo./* comment */ arr[1]; + `); + }); } From ca3f3d9163bd340ee07cf039df331cf2fa089351 Mon Sep 17 00:00:00 2001 From: Christian Jorgensen Date: Mon, 13 Nov 2023 17:09:42 +0100 Subject: [PATCH 9/9] Add additional tests for array identifier conversions --- test/features/arrayAndMapAccessors.ts | 28 +++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test/features/arrayAndMapAccessors.ts b/test/features/arrayAndMapAccessors.ts index dc6c80bd67..756d0fb4c8 100644 --- a/test/features/arrayAndMapAccessors.ts +++ b/test/features/arrayAndMapAccessors.ts @@ -23,6 +23,18 @@ export default function supportsArrayAndMapAccessors(format: FormatFn) { `); }); + it('supports square brackets for map lookup - uppercase', () => { + const result = format(`SELECT Alpha['a'], Beta['gamma'].zeTa, yotA['foo.bar-baz'];`, { + identifierCase: 'upper', + }); + expect(result).toBe(dedent` + SELECT + ALPHA['a'], + BETA['gamma'].ZETA, + YOTA['foo.bar-baz']; + `); + }); + it('supports namespaced array identifiers', () => { const result = format(`SELECT foo.coalesce['blah'];`); expect(result).toBe(dedent` @@ -47,6 +59,22 @@ export default function supportsArrayAndMapAccessors(format: FormatFn) { `); }); + it('supports namespaced array identifiers in uppercase', () => { + const result = format(`SELECT Foo.Coalesce['Blah'];`, { identifierCase: 'upper' }); + expect(result).toBe(dedent` + SELECT + FOO.COALESCE['Blah']; + `); + }); + + it('supports namespaced array identifiers in lowercase', () => { + const result = format(`SELECT Foo.Coalesce['Blah'];`, { identifierCase: 'lower' }); + expect(result).toBe(dedent` + SELECT + foo.coalesce['Blah']; + `); + }); + it('formats namespaced array accessor with comment in-between in uppercase', () => { const result = format(`SELECT foo./* comment */arr[1];`, { identifierCase: 'upper' }); expect(result).toBe(dedent`