From 281d48aa48ba9137e7e9ca4a666498be318e0dfb Mon Sep 17 00:00:00 2001 From: ondrejtucny <78753310+ondrejtucny@users.noreply.github.com> Date: Mon, 27 Oct 2025 19:25:22 +0100 Subject: [PATCH 1/2] Introduced IdentifierCasing option enabling upper / lower / pascal-casing of all identifiers in ScriptWriter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SqlScriptGeneratorOptions now supports a new setting IdentifierCasing with the following options: - PreserveOriginal — outputs identifiers as-is; this is the default behavior corresponding to the current state (no breaking change) - Lowercase — lower-case all identifiers when producing the output - Uppercase — upper-case all identifiers when producing the output - PascalCase — first character upper, remaining lower Added unit tests for the various options, including compatibility with KeywordCasing property and ensuring the default state. --- .../ScriptDom/SqlServer/IdentifierCasing.cs | 38 +++ .../SqlServer/ScriptGenerator/ScriptWriter.cs | 21 ++ .../Settings/SqlScriptGeneratorOptions.xml | 9 + Test/SqlDom/ScriptGeneratorTests.cs | 247 ++++++++++++++++++ 4 files changed, 315 insertions(+) create mode 100644 SqlScriptDom/ScriptDom/SqlServer/IdentifierCasing.cs diff --git a/SqlScriptDom/ScriptDom/SqlServer/IdentifierCasing.cs b/SqlScriptDom/ScriptDom/SqlServer/IdentifierCasing.cs new file mode 100644 index 0000000..02c59f2 --- /dev/null +++ b/SqlScriptDom/ScriptDom/SqlServer/IdentifierCasing.cs @@ -0,0 +1,38 @@ +//------------------------------------------------------------------------------ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +//------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.SqlServer.TransactSql.ScriptDom +{ + /// + /// Represents the possible ways of casing SQL identifiers + /// + public enum IdentifierCasing + { + /// + /// Preserve original casing + /// + PreserveOriginal, + + /// + /// All letters in lower case + /// + Lowercase, + + /// + /// All letters in upper case + /// + Uppercase, + + /// + /// First letter of each word capitalized, remaining letters lower case + /// + PascalCase + } +} diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptWriter.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptWriter.cs index 85d2fac..56c680d 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptWriter.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptWriter.cs @@ -170,8 +170,29 @@ private void AddIdentifier(String text, Boolean applyCasing) { if (applyCasing) { + // Apply keyword casing (for identifiers that are actually keywords) text = ScriptGeneratorSupporter.GetCasedString(text, _options.KeywordCasing); } + else + { + // Apply identifier casing (for actual identifiers) + switch (_options.IdentifierCasing) + { + case IdentifierCasing.Lowercase: + text = text.ToLowerInvariant(); + break; + case IdentifierCasing.Uppercase: + text = text.ToUpperInvariant(); + break; + case IdentifierCasing.PascalCase: + text = ScriptGeneratorSupporter.GetPascalCase(text); + break; + case IdentifierCasing.PreserveOriginal: + default: + // No transformation - preserve original casing + break; + } + } TSqlParserToken token = new TSqlParserToken(TSqlTokenType.Identifier, text); AddToken(token); diff --git a/SqlScriptDom/ScriptDom/SqlServer/Settings/SqlScriptGeneratorOptions.xml b/SqlScriptDom/ScriptDom/SqlServer/Settings/SqlScriptGeneratorOptions.xml index 551033a..aa56b18 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/Settings/SqlScriptGeneratorOptions.xml +++ b/SqlScriptDom/ScriptDom/SqlServer/Settings/SqlScriptGeneratorOptions.xml @@ -14,6 +14,15 @@ Gets or sets the keyword casing option to use during script generation + + IdentifierCasing_Title + IdentifierCasing_Description + + + + + Gets or sets the identifier casing option to use during script generation + Gets or sets the Sql version to generate script for diff --git a/Test/SqlDom/ScriptGeneratorTests.cs b/Test/SqlDom/ScriptGeneratorTests.cs index 9a92ce3..3ac9c7f 100644 --- a/Test/SqlDom/ScriptGeneratorTests.cs +++ b/Test/SqlDom/ScriptGeneratorTests.cs @@ -334,5 +334,252 @@ void ParseAndAssertEquality(string sqlText, SqlScriptGeneratorOptions generatorO Assert.AreEqual(sqlText, generatedSqlText); } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIdentifierCasingDefault() + { + Assert.AreEqual(IdentifierCasing.PreserveOriginal, new SqlScriptGeneratorOptions().IdentifierCasing); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIdentifierCasingPreserveOriginal() + { + var expectedSqlText = @"CREATE TABLE MyTableName ( + MyColumnName VARCHAR (50), + anotherColumn INT, + MixedCaseColumn DECIMAL (10, 2) +);"; + + ParseAndAssertEquality(expectedSqlText, new SqlScriptGeneratorOptions { + IdentifierCasing = IdentifierCasing.PreserveOriginal, + AlignColumnDefinitionFields = false + }); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIdentifierCasingLowercase() + { + var inputSqlText = @"CREATE TABLE MyTableName ( + MyColumnName VARCHAR (50), + AnotherColumn INT, + MixedCaseColumn DECIMAL (10, 2) +);"; + + var expectedSqlText = @"CREATE TABLE mytablename ( + mycolumnname VARCHAR (50), + anothercolumn INT, + mixedcasecolumn DECIMAL (10, 2) +);"; + + ParseTransformAndAssertEquality(inputSqlText, expectedSqlText, new SqlScriptGeneratorOptions { + IdentifierCasing = IdentifierCasing.Lowercase, + AlignColumnDefinitionFields = false + }); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIdentifierCasingUppercase() + { + var inputSqlText = @"CREATE TABLE MyTableName ( + MyColumnName VARCHAR (50), + anotherColumn INT, + MixedCaseColumn DECIMAL (10, 2) +);"; + + var expectedSqlText = @"CREATE TABLE MYTABLENAME ( + MYCOLUMNNAME VARCHAR (50), + ANOTHERCOLUMN INT, + MIXEDCASECOLUMN DECIMAL (10, 2) +);"; + + ParseTransformAndAssertEquality(inputSqlText, expectedSqlText, new SqlScriptGeneratorOptions { + IdentifierCasing = IdentifierCasing.Uppercase, + AlignColumnDefinitionFields = false + }); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIdentifierCasingPascalCase() + { + var inputSqlText = @"CREATE TABLE MyTableName ( + MyColumnName VARCHAR (50), + anotherColumn INT, + MIXEDCASECOLUMN DECIMAL (10, 2) +);"; + + var expectedSqlText = @"CREATE TABLE Mytablename ( + Mycolumnname VARCHAR (50), + Anothercolumn INT, + Mixedcasecolumn DECIMAL (10, 2) +);"; + + ParseTransformAndAssertEquality(inputSqlText, expectedSqlText, new SqlScriptGeneratorOptions { + IdentifierCasing = IdentifierCasing.PascalCase, + AlignColumnDefinitionFields = false + }); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIdentifierCasingWithAccentedCharactersLowercase() + { + var inputSqlText = @"CREATE TABLE [Příliš Žluťoučký Kůň] ( + [Úpěl Ďábelské] VARCHAR (50), + [Ódy] INT +);"; + + var expectedSqlText = @"CREATE TABLE [příliš žluťoučký kůň] ( + [úpěl ďábelské] VARCHAR (50), + [ódy] INT +);"; + + ParseTransformAndAssertEquality(inputSqlText, expectedSqlText, new SqlScriptGeneratorOptions { + IdentifierCasing = IdentifierCasing.Lowercase, + AlignColumnDefinitionFields = false + }); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIdentifierCasingWithAccentedCharactersUppercase() + { + var inputSqlText = @"CREATE TABLE [Příliš Žluťoučký Kůň] ( + [Úpěl Ďábelské] VARCHAR (50), + [Ódy] INT +);"; + + var expectedSqlText = @"CREATE TABLE [PŘÍLIŠ ŽLUŤOUČKÝ KŮŇ] ( + [ÚPĚL ĎÁBELSKÉ] VARCHAR (50), + [ÓDY] INT +);"; + + ParseTransformAndAssertEquality(inputSqlText, expectedSqlText, new SqlScriptGeneratorOptions { + IdentifierCasing = IdentifierCasing.Uppercase, + AlignColumnDefinitionFields = false + }); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIdentifierCasingWithAccentedCharactersPascalCase() + { + var inputSqlText = @"CREATE TABLE [PŘÍLIŠ ŽLUŤOUČKÝ KŮŇ] ( + [ÚPĚL ĎÁBELSKÉ ÓDY] VARCHAR (50), + [ódy] INT +);"; + + // Note: PascalCase with quoted identifiers containing spaces produces lowercase after first character + // because the GetPascalCase method only capitalizes the first character of the entire string + var expectedSqlText = @"CREATE TABLE [příliš žluťoučký kůň] ( + [úpěl ďábelské ódy] VARCHAR (50), + [ódy] INT +);"; + + ParseTransformAndAssertEquality(inputSqlText, expectedSqlText, new SqlScriptGeneratorOptions { + IdentifierCasing = IdentifierCasing.PascalCase, + AlignColumnDefinitionFields = false + }); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIdentifierCasingDoesNotAffectKeywords() + { + var inputSqlText = @"Create Table MyTable ( + MyColumn VarChar (50) not null, + MyId Int Primary Key +);"; + + var expectedSqlTextLowercaseKeywords = @"create table MYTABLE ( + MYCOLUMN varchar (50) not null, + MYID int primary key +);"; + + var expectedSqlTextUppercaseKeywords = @"CREATE TABLE mytable ( + mycolumn VARCHAR (50) NOT NULL, + myid INT PRIMARY KEY +);"; + + var parser = new TSql160Parser(true); + var fragment = parser.ParseStatementList(new StringReader(inputSqlText), out var errors); + + Assert.AreEqual(0, errors.Count); + + // The setting trigger both keyword and identifier casing, set opposite to each other, + // and hence it's clear which part is affected by which setting. + + // Test with lowercase keywords and uppercase identifiers + var generatorLowerKeywords = new Sql160ScriptGenerator(new SqlScriptGeneratorOptions { + KeywordCasing = KeywordCasing.Lowercase, + IdentifierCasing = IdentifierCasing.Uppercase, + AlignColumnDefinitionFields = false + }); + generatorLowerKeywords.GenerateScript(fragment, out var generatedSqlTextLower); + Assert.AreEqual(expectedSqlTextLowercaseKeywords, generatedSqlTextLower); + + // Test with uppercase keywords and lowercase identifiers + var generatorUpperKeywords = new Sql160ScriptGenerator(new SqlScriptGeneratorOptions { + KeywordCasing = KeywordCasing.Uppercase, + IdentifierCasing = IdentifierCasing.Lowercase, + AlignColumnDefinitionFields = false + }); + generatorUpperKeywords.GenerateScript(fragment, out var generatedSqlTextUpper); + Assert.AreEqual(expectedSqlTextUppercaseKeywords, generatedSqlTextUpper); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIdentifierCasingWithComplexStatement() + { + var inputSqlText = @"SELECT T1.MyColumn, + T2.AnotherColumn +FROM MySchema.MyTable AS T1 + INNER JOIN AnotherSchema.AnotherTable AS T2 ON T1.Id = T2.ForeignId +WHERE T1.StatusCode = 'ACTIVE';"; + + var expectedSqlText = @"SELECT t1.mycolumn, + t2.anothercolumn +FROM myschema.mytable AS t1 + INNER JOIN + anotherschema.anothertable AS t2 + ON t1.id = t2.foreignid +WHERE t1.statuscode = 'ACTIVE';"; + + ParseTransformAndAssertEquality(inputSqlText, expectedSqlText, new SqlScriptGeneratorOptions { + IdentifierCasing = IdentifierCasing.Lowercase, + AlignClauseBodies = true, + NewLineBeforeFromClause = true, + NewLineBeforeWhereClause = true, + NewLineBeforeJoinClause = true + }); + } + + void ParseTransformAndAssertEquality(string inputSqlText, string expectedSqlText, SqlScriptGeneratorOptions generatorOptions) + { + var parser = new TSql160Parser(true); + var fragment = parser.ParseStatementList(new StringReader(inputSqlText), out var errors); + + Assert.AreEqual(0, errors.Count); + + var generator = new Sql160ScriptGenerator(generatorOptions); + generator.GenerateScript(fragment, out var generatedSqlText); + + Assert.AreEqual(expectedSqlText, generatedSqlText); + } } } From 5b0f1e45507effc5a5454b62dba9fd94736ae8af Mon Sep 17 00:00:00 2001 From: ondrejtucny <78753310+ondrejtucny@users.noreply.github.com> Date: Mon, 27 Oct 2025 19:47:45 +0100 Subject: [PATCH 2/2] Refactored identifier casing into a separate method, in-line with keyword casing. --- .../ScriptGeneratorSupporter.cs | 31 +++++++++++++++++-- .../SqlServer/ScriptGenerator/ScriptWriter.cs | 17 +--------- 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptGeneratorSupporter.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptGeneratorSupporter.cs index 9e9abe0..189e318 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptGeneratorSupporter.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptGeneratorSupporter.cs @@ -40,10 +40,10 @@ public static Int32 TokenTypeCount } /// - /// Retrieves a version of the specified string, in the casing format specified + /// Retrieves a version of the specified string, in the keyword casing format specified /// /// The string to get a specially cased version of - /// The casing method to use + /// The keyword casing method to use /// A version of the string in the casing format specified in [SuppressMessage("Microsoft.Globalization", "CA1308:NormalizeStringsToUppercase")] public static string GetCasedString(string str, KeywordCasing casing) @@ -63,6 +63,33 @@ public static string GetCasedString(string str, KeywordCasing casing) return String.Empty; } + /// + /// Retrieves a version of the specified string, in the identifier casing format specified + /// + /// The string to get a specially cased version of + /// The identifier casing method to use + /// A version of the string in the casing format specified in + [SuppressMessage("Microsoft.Globalization", "CA1308:NormalizeStringsToUppercase")] + public static string GetCasedString(string str, IdentifierCasing casing) + { + switch (casing) + { + case IdentifierCasing.PreserveOriginal: + // No transformation - preserve original casing + return str; + case IdentifierCasing.Lowercase: + return str.ToLowerInvariant(); + case IdentifierCasing.Uppercase: + return str.ToUpperInvariant(); + case IdentifierCasing.PascalCase: + return GetPascalCase(str); + default: + Debug.Fail("Invalid IdentifierCasing value"); + break; + } + return String.Empty; + } + /// /// Retrieves a Pascal Cased version of the string /// diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptWriter.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptWriter.cs index 56c680d..bf2df1a 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptWriter.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptWriter.cs @@ -176,22 +176,7 @@ private void AddIdentifier(String text, Boolean applyCasing) else { // Apply identifier casing (for actual identifiers) - switch (_options.IdentifierCasing) - { - case IdentifierCasing.Lowercase: - text = text.ToLowerInvariant(); - break; - case IdentifierCasing.Uppercase: - text = text.ToUpperInvariant(); - break; - case IdentifierCasing.PascalCase: - text = ScriptGeneratorSupporter.GetPascalCase(text); - break; - case IdentifierCasing.PreserveOriginal: - default: - // No transformation - preserve original casing - break; - } + text = ScriptGeneratorSupporter.GetCasedString(text, _options.IdentifierCasing); } TSqlParserToken token = new TSqlParserToken(TSqlTokenType.Identifier, text);