65.9K
CodeProject 正在变化。 阅读更多。
Home

SQL 格式化的 C# 源代码

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.15/5 (5投票s)

2019 年 1 月 24 日

CPOL

5分钟阅读

viewsIcon

21957

downloadIcon

1028

SQL 格式化源代码(现在支持格式化选项)——而不是工具、Web 服务或插件/附加组件...

引言

一个简单通用的 SQL 格式化类的源代码。

例如

WITH Sales_CTE(SalesPersonID, SalesOrderID, SalesYear) 
AS(SELECT SalesPersonID, SalesOrderID, YEAR(OrderDate) AS SalesYear 
FROM Sales.SalesOrderHeader WHERE SalesPersonID IS NOT NULL) 
SELECT SalesPersonID, COUNT(SalesOrderID) AS TotalSales, SalesYear 
FROM Sales_CTE GROUP BY SalesYear, SalesPersonID ORDER BY SalesPersonID, SalesYear; 

变成

with Sales_CTE(SalesPersonID, SalesOrderID, SalesYear) as 
   (
   select SalesPersonID,
      SalesOrderID,
      Year(OrderDate) as SalesYear 
   from Sales.SalesOrderHeader 
   where SalesPersonID is not NULL
   ) 
select SalesPersonID,
   Count(SalesOrderID) as TotalSales,
   SalesYear 
from Sales_CTE 
group by SalesYear, SalesPersonID 
order by SalesPersonID, SalesYear;

背景

我最近管理了一个数据集成程序员团队,他们编写了大量复杂的 SQL。为了让我更容易理解他们的代码——并希望整个团队能够更标准化地进行格式化,我开始寻找 SQL 格式化程序。市面上的格式化程序很多,但...

我需要可以集成到我们自己工具和实用程序开发中的源代码;可以根据我们的偏好进行修改;不受特定方言限制——当然,必须是免费的。我可能错过了,但我找不到令人满意的解决方案,于是我花了几晚和周末时间,弄出了这个。

我分享这个,希望其他人能从中受益,同时也希望学习一些东西。这个问题相当棘手,让我感觉可能有更好的(更正式、更结构化的)方法来解决,但我却忽略了。

Using the Code

使用该类非常简单

var fmtr = new SQL_Formatter.Formatter();
var formattedStatement = fmtr.Format("... unformatted statement..." [, "... options..."]);
if (fmtr.Success)
    ... use the result ...;
else
    throw new exception(fmtr.LastResult);

该类不会抛出解析错误,而是将 `Success` 布尔属性设置为 `false`,并将 `LastResult` 属性设置为一条信息性消息。

方法

`Format` 方法首先调用 `private` 的 `UnFormat` 方法来折叠并添加空格,以便将输入解析为一系列空格分隔的元素。然后,每个元素被提取到 `token` 变量中,并根据其语法重要性进行检查。

       public string Format(string sql)
       
       ...
       
       // Remove formatting
       try { sql = UnFormat(sql); }
       catch (Exception ex)
       { return Fail(ex.Message); }

       // Parse the statement 
       while (stmtIndex < sql.Length)
       {
         // Skip leading spaces
         while (stmtIndex < sql.Length && sql.Substring(stmtIndex, 1) == " ")
           stmtIndex++;

         // Grab the next token, space delimited
         if (sql.IndexOf(" ", stmtIndex) > -1)
           token = sql.Substring(stmtIndex, sql.IndexOf(" ", stmtIndex) - stmtIndex);
         else
           token = sql.Substring(stmtIndex, sql.Length - stmtIndex);
       ...

当元素标识了注释或引号字面量的开始时,它会被扩展——所有后续元素都被简单地添加到其中——直到找到该构造的结束。然后将其附加到结果中,并继续解析下一个元素。

引入多词 SQL 关键字(例如,“`left`”在“`left join`”中)的元素被保存在 `previousToken` 变量中,并与后续元素合并,直到关键字完成。

然后,每个元素都会被考虑其在格式化(增加或减少缩进、插入换行符)中的作用,然后再添加到格式化后的输出中。意外的元素,例如没有前面“`case`”的“`when`”,会导致格式化失败。助手函数 `Fail` 实现上述错误处理。

缩进级别受关键字、括号等影响,这些在本地变量中单独跟踪:`tabLevel`、`parenLevel` 等。`netParens` 函数评估每个元素因括号引起的缩进影响,而 `Tabs` 函数会考虑这些变量,在将每个元素添加到结果时返回适当的缩进和垂直空格。当达到语句末尾时,变量中任何非零值都表示输入中的语法无效(括号不匹配等),格式化会失败。

`currentKeyword` 堆栈,由 `tabLevel` 索引,跟踪 SQL 构造的嵌套,这反映在缩进中。CTE 和 `case` 语句也需要特别考虑。

实际上,需要考虑许多变体和细微差别才能获得所需的结果。例如,找到一个“`select`”元素后,它可能是 CTE 的一部分、一个新的语句的开始、受 T-SQL 或 PL/SQL 条件的影响等等。考虑本文开头处的示例,以及处理“`select`”的部分代码。

       ...
           case "select":
             // Begin a select statement
             // Pushes occur below, see tabLevel
             if (cte == tabLevel)
             {
               // Keep together--prevent the default vertical whitespace
               token = Tabs(true) + token.ToLower();
               cte = -1;
             }
             else if (currentKeyword[tabLevel] == "")
               // New statement
               token = Tabs() + token.ToLower();
             else if (currentKeyword[tabLevel] == "if")
               // SQL conditional
               token = Tabs(true) + "\t" + token.ToLower();
             else if (!currentKeyword[tabLevel].In(new string[] _
             { "select", "insert", "insert into", "if" }))
               // Force vertical whitespace
               token = (result.gtr("") & _
               result.Right(4) != Str.Repeat(Str.NewLine, 2) ? Str.NewLine : "") + _
               Tabs(true, 1) + token.ToLower();
             else
               // Newline only
               token = Tabs(true) + token.ToLower();
       ...

以“`with`”关键字开始的示例语句表明正在构建一个 CTE,并且 `cte` 被设置为反映当前的 `tabLevel`——当第二个“`select`”关键字(引用)需要格式化时,就需要此信息。CTE 定义中的关键字按常规进行格式化,第一个“`select`”被识别为需要前导缩进,因为它前面有一个开括号。

       ...
         // Increase tab level -- select
         if (token.Equals("(select", Str.IgnoreCase))
         {
           tabLevel++;
           token = (result.Right(1) != "\t" ? Tabs(true) : "") + "(" + Str.NewLine + Tabs() + "select";
           currentKeyword.Add("select");
           currentParens = parenLevel;
         }
       ...

将括号作为找到的元素的一部分来处理似乎不够优雅,但实际上效果很好——这是空格分隔元素策略的自然结果,并且有助于区分组成语句的括号和函数调用中出现的括号。

格式化选项以等号 / 分号分隔的字符串形式传递。当前支持的选项和默认值是:

  • LeadingCommas = false
  • LeadingJoins = true
  • RemoveComments = false

前两个选项反映了开发人员常用的格式化 SQL 的做法,以便可以轻松地为部分 SQL 添加注释以进行调试——这与我最初使 SQL 更易于阅读的意图相反。

随着时间的推移,我认为 `Dialect` 选项会变得必要或有用,但我目前还没有看到这种需求。

Debug

在调试时,格式化程序会添加一个信息性标题,如下例所示:

/*
Formatted -- https://codeproject.org.cn/Articles/1275027/Csharp-source-for-SQL-formatting 
Length:   273
Elapsed:  46 milliseconds
*/

演示

解决方案包括一个非常简单的 winforms Demo 可执行文件以及 `Formatter` 类。

关注点

虽然旨在支持多种方言,但目前 T-SQL 的倾向性非常明显。

格式化程序的实现采用了来自不相关库代码的许多例程;我已将这些片段提取到一个附加文件中:*LIB.cs*。特别是,用于管理格式化选项的 `KVP.List` 类提供了基于键值对的广泛功能,但在 *LIB.cs* 中被简单地模拟以支持此格式化逻辑中使用的唯一方法:`GetBoolean`。

注释

注释带来了非常特殊且有趣挑战。此代码不尝试格式化注释,但它们带来的麻烦让我怀疑这是否是正确的选择。

这个问题的一个有趣组成部分是,与注释相关的格式化通常出现在指示它正在解析注释的分隔符*外部*——如下面示例中的换行符所示。

select * /* first comment */
from table
/* second comment */

此外,注释倾向于在未格式化(或者更确切地说,“原始格式化”)的代码中进行格式化以提高可读性。当您更改格式化时,注释的格式会变得随意,从而分散注意力,大大降低其帮助性。

我还没有找到解决这个问题的最佳方法,目前只能手动清理。

历史

  • 2019 年 1 月 24 日
    • 首次发布
  • 2019 年 1 月 27 日
    • 修正了源代码中的差异
    • 增加了对格式化选项的支持
© . All rights reserved.