diff --git a/docs/identifierCase.md b/docs/identifierCase.md new file mode 100644 index 0000000000..8ebfbf2928 --- /dev/null +++ b/docs/identifierCase.md @@ -0,0 +1,58 @@ +# identifierCase + +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. +- `"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..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); @@ -286,7 +288,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 +508,19 @@ export default class ExpressionFormatter { return node.text.toLowerCase(); } } + + private showIdentifier(node: IdentifierNode): string { + if (node.tokenType === TokenType.IDENTIFIER || node.tokenType === TokenType.ARRAY_IDENTIFIER) { + switch (this.cfg.identifierCase) { + case 'preserve': + return node.text; + case 'upper': + return node.text.toUpperCase(); + case 'lower': + return node.text.toLowerCase(); + } + } else { + return node.text; + } + } } 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, 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/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/features/arrayAndMapAccessors.ts b/test/features/arrayAndMapAccessors.ts index 3f903becb8..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` @@ -46,4 +58,36 @@ export default function supportsArrayAndMapAccessors(format: FormatFn) { foo./* comment */ arr[1]; `); }); + + 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` + 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]; + `); + }); } diff --git a/test/options/identifierCase.ts b/test/options/identifierCase.ts new file mode 100644 index 0000000000..1fbd00aed8 --- /dev/null +++ b/test/options/identifierCase.ts @@ -0,0 +1,81 @@ +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( + dedent` + select Abc, 'mytext' as MyText from tBl1 left join Tbl2 where colA > 1 and colB = 3` + ); + expect(result).toBe(dedent` + select + Abc, + 'mytext' as MyText + from + tBl1 + left join Tbl2 + where + colA > 1 + and colB = 3 + `); + }); + + it('converts identifiers to uppercase', () => { + 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, + 'mytext' as MYTEXT + from + TBL1 + left join TBL2 + where + COLA > 1 + and COLB = 3 + `); + }); + + it('converts identifiers to lowercase', () => { + 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, + 'mytext' as mytext + from + tbl1 + left join tbl2 + where + cola > 1 + and colb = 3 + `); + }); + + it('does not uppercase quoted identifiers', () => { + const result = format(`select "abc" as foo`, { + identifierCase: 'upper', + }); + expect(result).toBe(dedent` + select + "abc" as FOO + `); + }); + + it('converts multi-part identifiers to uppercase', () => { + const result = format('select Abc from Part1.Part2.Part3', { identifierCase: 'upper' }); + expect(result).toBe(dedent` + select + ABC + from + PART1.PART2.PART3 + `); + }); +} 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", }, ],