From 4a1ee673cb5e69c8f4c4767b2ec579f529fddd56 Mon Sep 17 00:00:00 2001 From: Alasdair Campbell Date: Wed, 30 Sep 2026 14:03:15 +0100 Subject: [PATCH] Add MultilineCaseExpression script generator option A CASE expression is always generated on one line, so a multi-branch CASE becomes a single long line, and a WHEN predicate joined by AND breaks under MultilineWherePredicatesList with the next WHEN appended after its continuation, producing a staircase. MultilineCaseExpression (default false) writes each WHEN clause and the ELSE on its own line, indented one level from CASE, and END on its own line aligned with CASE. It applies to simple and searched CASE. The alignment point is pushed only when the option is on, so the default output is unchanged. A trailing single-line comment on a branch now stays on that branch's line, because the deferred comment is flushed at the next line break. --- ...qlScriptGeneratorVisitor.CaseExpression.cs | 76 +++-- .../Settings/SqlScriptGeneratorOptions.xml | 5 + .../CaseExpressionFormattingTests.cs | 265 ++++++++++++++++++ 3 files changed, 326 insertions(+), 20 deletions(-) create mode 100644 Test/SqlDom/ScriptGenerator/CaseExpressionFormattingTests.cs diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CaseExpression.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CaseExpression.cs index b8739b17..fdfa4c94 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CaseExpression.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CaseExpression.cs @@ -3,6 +3,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // //------------------------------------------------------------------------------ +using System.Collections.Generic; using Microsoft.SqlServer.TransactSql.ScriptDom; namespace Microsoft.SqlServer.TransactSql.ScriptDom.ScriptGenerator @@ -11,45 +12,80 @@ partial class SqlScriptGeneratorVisitor { public override void ExplicitVisit(SimpleCaseExpression node) { - GenerateKeyword(TSqlTokenType.Case); + bool multiline = BeginCaseExpression(); GenerateSpaceAndFragmentIfNotNull(node.InputExpression); - foreach (SimpleWhenClause when in node.WhenClauses) - { - GenerateSpaceAndFragmentIfNotNull(when); - } + GenerateCaseBody(node.WhenClauses, node.ElseExpression, multiline); - if (node.ElseExpression != null) - { - GenerateSpaceAndKeyword(TSqlTokenType.Else); - GenerateSpaceAndFragmentIfNotNull(node.ElseExpression); - } + GenerateSpaceAndCollation(node.Collation); + } - GenerateSpaceAndKeyword(TSqlTokenType.End); + public override void ExplicitVisit(SearchedCaseExpression node) + { + bool multiline = BeginCaseExpression(); + + GenerateCaseBody(node.WhenClauses, node.ElseExpression, multiline); GenerateSpaceAndCollation(node.Collation); } - public override void ExplicitVisit(SearchedCaseExpression node) + // Writes CASE. With MultilineCaseExpression it first pushes an alignment point at the CASE + // keyword, which GenerateCaseBody pops after END, so that every line of the body starts + // from the CASE column. Without it no point is pushed, which keeps the single-line output + // unchanged. + private bool BeginCaseExpression() { - GenerateKeyword(TSqlTokenType.Case); + bool multiline = _options.MultilineCaseExpression; - foreach (SearchedWhenClause when in node.WhenClauses) + if (multiline) { - GenerateSpaceAndFragmentIfNotNull(when); + MarkAndPushAlignmentPoint(new AlignmentPoint()); } - if (node.ElseExpression != null) + GenerateKeyword(TSqlTokenType.Case); + + return multiline; + } + + private void GenerateCaseBody(IList whenClauses, ScalarExpression elseExpression, bool multiline) + where TWhenClause : WhenClause + { + foreach (TWhenClause when in whenClauses) { - GenerateSpaceAndKeyword(TSqlTokenType.Else); - GenerateSpaceAndFragmentIfNotNull(node.ElseExpression); + GenerateCaseBodyLineStart(multiline); + GenerateFragmentIfNotNull(when); } - GenerateSpaceAndKeyword(TSqlTokenType.End); + if (elseExpression != null) + { + GenerateCaseBodyLineStart(multiline); + GenerateKeyword(TSqlTokenType.Else); + GenerateSpaceAndFragmentIfNotNull(elseExpression); + } - GenerateSpaceAndCollation(node.Collation); + if (multiline) + { + NewLine(); + GenerateKeyword(TSqlTokenType.End); + PopAlignmentPoint(); + } + else + { + GenerateSpaceAndKeyword(TSqlTokenType.End); + } } + private void GenerateCaseBodyLineStart(bool multiline) + { + if (multiline) + { + NewLineAndIndent(); + } + else + { + GenerateSpace(); + } + } } } diff --git a/SqlScriptDom/ScriptDom/SqlServer/Settings/SqlScriptGeneratorOptions.xml b/SqlScriptDom/ScriptDom/SqlServer/Settings/SqlScriptGeneratorOptions.xml index a7c86591..2c6b17aa 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/Settings/SqlScriptGeneratorOptions.xml +++ b/SqlScriptDom/ScriptDom/SqlServer/Settings/SqlScriptGeneratorOptions.xml @@ -215,6 +215,11 @@ Gets or sets a boolean indicating if parameters in a nested function call tree should be written on separate lines and indented one level per call. Isolated function calls remain on one line. When false (the default), all function calls retain the existing compact layout. + + + Gets or sets a boolean indicating if each WHEN clause and the ELSE of a CASE expression should be written on its own line, indented one level from the CASE keyword, with END on its own line aligned with CASE. This applies to simple and searched CASE expressions wherever they appear. When false (the default), a CASE expression is written on a single line. + + Gets or sets a boolean indicating if file paths can be used for external library content diff --git a/Test/SqlDom/ScriptGenerator/CaseExpressionFormattingTests.cs b/Test/SqlDom/ScriptGenerator/CaseExpressionFormattingTests.cs new file mode 100644 index 00000000..ba06566e --- /dev/null +++ b/Test/SqlDom/ScriptGenerator/CaseExpressionFormattingTests.cs @@ -0,0 +1,265 @@ +//------------------------------------------------------------------------------ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +//------------------------------------------------------------------------------ + +using Microsoft.SqlServer.TransactSql.ScriptDom; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SqlStudio.Tests.AssemblyTools.TestCategory; +using static SqlStudio.Tests.UTSqlScriptDom.ScriptGeneratorTestHelper; + +namespace SqlStudio.Tests.UTSqlScriptDom +{ + // Tests for the MultilineCaseExpression script-generation option, which controls whether the + // WHEN clauses and ELSE of a CASE expression are written on a single line (default) or each on + // its own line, with END aligned with CASE. Kept in a dedicated file to avoid churn in + // ScriptGeneratorTests.cs. + [TestClass] + public class CaseExpressionFormattingTests + { + // Builds options that isolate the CASE layout: clause bodies are not aligned and clauses are + // not broken onto their own lines, so the surrounding statement stays on one line and the + // expectations focus on the CASE expression itself. + private static SqlScriptGeneratorOptions MakeOptions(bool multilineCaseExpression) + { + return new SqlScriptGeneratorOptions + { + MultilineCaseExpression = multilineCaseExpression, + AlignClauseBodies = false, + NewLineBeforeFromClause = false, + NewLineBeforeWhereClause = false, + MultilineSelectElementsList = false, + MultilineWherePredicatesList = false, + }; + } + + // ----------------------------------------------------------------------------------------- + // Default + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineCaseExpressionDefaultIsFalse() + { + Assert.IsFalse(new SqlScriptGeneratorOptions().MultilineCaseExpression); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestDefaultKeepsSearchedCaseOnSingleLine() + { + const string input = "SELECT CASE WHEN a = 1 THEN 'x' WHEN a = 2 THEN 'y' ELSE 'z' END AS c FROM t;"; + var options = MakeOptions(false); + const string expected = "SELECT CASE WHEN a = 1 THEN 'x' WHEN a = 2 THEN 'y' ELSE 'z' END AS c FROM t;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestDefaultKeepsSimpleCaseOnSingleLine() + { + const string input = "SET @v = CASE @w WHEN 1 THEN 'a' WHEN 2 THEN 'b' ELSE 'c' END;"; + var options = MakeOptions(false); + const string expected = "SET @v = CASE @w WHEN 1 THEN 'a' WHEN 2 THEN 'b' ELSE 'c' END;"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // Multi-line layout + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineSearchedCasePutsEachBranchOnItsOwnLine() + { + // Each WHEN and the ELSE are indented one level (4) from CASE, and END is aligned with it. + const string input = "SELECT CASE WHEN a = 1 THEN 'x' WHEN a = 2 THEN 'y' ELSE 'z' END AS c FROM t;"; + var options = MakeOptions(true); + const string expected = @" +SELECT CASE + WHEN a = 1 THEN 'x' + WHEN a = 2 THEN 'y' + ELSE 'z' + END AS c FROM t;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineSimpleCaseKeepsInputExpressionOnCaseLine() + { + // The input expression of a simple CASE stays on the CASE line. + const string input = "SET @v = CASE @w WHEN 1 THEN 'a' WHEN 2 THEN 'b' ELSE 'c' END;"; + var options = MakeOptions(true); + const string expected = @" +SET @v = CASE @w + WHEN 1 THEN 'a' + WHEN 2 THEN 'b' + ELSE 'c' + END;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineWithoutElse() + { + const string input = "SELECT CASE WHEN a = 1 THEN 'x' WHEN a = 2 THEN 'y' END FROM t;"; + var options = MakeOptions(true); + const string expected = @" +SELECT CASE + WHEN a = 1 THEN 'x' + WHEN a = 2 THEN 'y' + END FROM t;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineSingleBranchCase() + { + // A CASE with a single WHEN is laid out the same way as one with several. + const string input = "SELECT CASE WHEN a = 1 THEN 1 ELSE 0 END FROM t;"; + var options = MakeOptions(true); + const string expected = @" +SELECT CASE + WHEN a = 1 THEN 1 + ELSE 0 + END FROM t;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineNestedCaseAlignsWithItsOwnCase() + { + // A nested CASE aligns its branches and END with its own CASE keyword. + const string input = "SELECT CASE WHEN a = 1 THEN CASE WHEN b = 1 THEN 'x' ELSE 'y' END ELSE 'z' END FROM t;"; + var options = MakeOptions(true); + const string expected = @" +SELECT CASE + WHEN a = 1 THEN CASE + WHEN b = 1 THEN 'x' + ELSE 'y' + END + ELSE 'z' + END FROM t;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineCollationFollowsEnd() + { + const string input = "SELECT CASE WHEN a = 1 THEN 'x' ELSE 'y' END COLLATE Latin1_General_CI_AS FROM t;"; + var options = MakeOptions(true); + const string expected = @" +SELECT CASE + WHEN a = 1 THEN 'x' + ELSE 'y' + END COLLATE Latin1_General_CI_AS FROM t;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineHonorsIndentationSize() + { + const string input = "SELECT CASE WHEN a = 1 THEN 'x' ELSE 'y' END FROM t;"; + var options = MakeOptions(true); + options.IndentationSize = 2; + const string expected = @" +SELECT CASE + WHEN a = 1 THEN 'x' + ELSE 'y' + END FROM t;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineKeepsTrailingCommentOnItsBranch() + { + // A trailing comment on a branch stays on that branch's line rather than moving to the end + // of the statement. + const string input = +@"SET @v = CASE @w + WHEN 1 THEN 'a' + ELSE 'b' -- anything else + END;"; + var options = MakeOptions(true); + options.PreserveComments = true; + const string expected = @" +SET @v = CASE @w + WHEN 1 THEN 'a' + ELSE 'b' -- anything else + END;"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // Interaction with other options + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineWithDefaultOptionsHasNoStaircase() + { + // With otherwise-default options, a WHEN predicate joined by AND still breaks at the AND + // (MultilineWherePredicatesList defaults to true) and the continuation aligns with the start + // of the predicate. Each following WHEN starts its own line, so the continuations do not + // compound. + const string input = +@"SELECT CASE + WHEN o.status = 'P' AND o.paid_amount >= o.total_amount THEN 'settled' + WHEN o.status = 'P' AND o.paid_amount > 0 THEN 'part-paid' + WHEN o.status = 'C' THEN 'cancelled' + ELSE 'open' + END AS settlement_state, + CASE WHEN o.due_date < SYSUTCDATETIME() THEN 1 ELSE 0 END AS is_overdue +FROM orders AS o +WHERE o.tenant_id = 42;"; + var options = new SqlScriptGeneratorOptions { MultilineCaseExpression = true }; + const string expected = @" +SELECT CASE + WHEN o.status = 'P' + AND o.paid_amount >= o.total_amount THEN 'settled' + WHEN o.status = 'P' + AND o.paid_amount > 0 THEN 'part-paid' + WHEN o.status = 'C' THEN 'cancelled' + ELSE 'open' + END AS settlement_state, + CASE + WHEN o.due_date < SYSUTCDATETIME() THEN 1 + ELSE 0 + END AS is_overdue +FROM orders AS o +WHERE o.tenant_id = 42;"; + + AssertGenerated(input, options, expected); + } + } +}