SQL Server 2005
正則表達式使模式匹配和數據提取變得更容易
David Banister
本文討論:
- 使用正則表達式進行高效的 SQL 查詢
- SQL Server 2005 對正則表達式的支持
- 從 SQL Server 使用 .NET Regex 類
- 在數據庫中有效地使用正則表達式
本文使用了以下技術:
SQL Server 2005,.NET Framework
下載本文中所用的代碼: Regex2007_02.exe (154 KB)
瀏覽在線代碼
目錄
CLR 用戶定義函數
模式匹配
數據提取
模式存儲
匹配
在匹配項中進行數據提取
總結
盡管 T-SQL 對多數數據處理而言極其強大,但它對文本分析或操作所提供的支持卻很少。嘗試使用內置的字符串函數執行任何復雜的文本分析會導致難於調試和維護的龐大的函數和存儲過程。有更好的辦法嗎?
實際上,正則表達式提供了更高效且更佳的解決方案。它在比較文本以便標識記錄方面的益處顯而易見,但是它的用途並不僅限於此。我們將介紹如何執行各種簡單或令人驚異的任務,這些任務在 SQL Server™ 2000 中被視為不切實際或不可能的,但現在由於 SQL Server 2005 對托管公共語言運行庫 (CLR) 的支持而可行。
正則表達式對 SQL 來說並非新事物。Oracle 在 10g 中引進了內置的正則表達式,而且許多開源數據庫解決方案也使用某種正則表達式庫。實際上,正則表達式可在 SQL Server 的早期版本中使用,但是過程的效率很低。
如果使用 sp_OACreate 存儲過程,則可以使用任何實現正則表達式的 OLE 自動化對象,但您必須首先創建一個 COM 對象,至少調用 IDispatch 一次,然后銷毀此對象。多數情況下,這樣做效率太低而且導致了太多性能問題。唯一的備選方案是創建擴展的存儲過程。然而,現在有 SQLCLR,CLR 用戶定義函數 (UDF),它允許您使用 Microsoft® .NET Framework 創建高效的且減少了出錯可能性的函數集。
CLR 用戶定義函數
CLR 用戶定義函數只是在 .NET 程序集中定義的靜態方法(Visual Basic 中的共享函數)。要使用 SQLCLR 對象,您必須使用新的 CREATE ASSEMBLY 語句在 SQL Server 注冊程序集,然后在程序集中創建指向其實現的各個對象。對函數而言,CREATE FUNCTION 語句已擴展為支持創建 CLR 用戶定義函數。為了簡化操作,使用 SQL Server Project 時,Visual Studio® 2005 將代表您處理所有注冊過程。此類項目與多數 Visual Studio 項目不同,因為當您嘗試調試(或啟動而未調試)時,項目將被重新編譯,生成的程序集以及其中定義的所有 SQLCLR 對象將隨后部署到 SQL Server,然后注冊到 SQL Server。然后,IDE 將運行為項目指定的測試腳本。可以在 SQL 腳本和您的 .NET 代碼中設置斷點,這樣可以簡化調試過程。
添加函數就像將新類添加到任何其他項目類型一樣。僅將一個新項添加到項目並且在提示時選擇“用戶定義函數”。新方法則被添加到包含所有函數的局部類。新方法還將有一個適用它的 SqlFunction 屬性。Visual Studio 使用此屬性來創建注冊函數所需的 SQL 語句。SqlFunction 中的 IsDeterministic、IsPrecise、DataAccess 和 SystemDataAccess 字段也由 SQL Server 用於各種用途。
模式匹配
確定字符串是否與模式匹配是對正則表達式的最簡單應用,如圖 1 所示,而且易於操作。
Figure 1 字符串匹配
public static partial class UserDefinedFunctions { public static readonly RegexOptions Options = RegexOptions.IgnorePatternWhitespace | RegexOptions.Singleline; [SqlFunction] public static SqlBoolean RegexMatch( SqlChars input, SqlString pattern) { Regex regex = new Regex( pattern.Value, Options ); return regex.IsMatch( new string( input.Value ) ); } }
首先,我使用“選項”字段來存儲函數的正則表達式選項。在此情況下,我選擇了 RegexOptions.SingleLine 和 RegexOptions.IgnorePatternWhitespace。前者指定單行模式,而后者則從正則表達式消除保留的空格並且啟用由磅符號標記的注釋。仔細考慮和分析后,您可能想要使用的另一個選項是 RegexOption.Compiled。如果將 Compiled 用於大量使用的表達式,只要選項不是太多,您會發現顯著的性能改進。反復使用的表達式應明確編譯。然而,對於很少使用的正則表達式,則不要使用 Compiled,否則會增加啟動成本和內存開銷。同樣,您可能想要通過指定您是否想要編譯表達式的其他參數來增強通用的 RegexMatch 函數;這樣,您可以根據具體情況確定花費額外開銷而帶來的性能改進是否值得。
指定了要使用的 RegexOptions 后,我使用 SqlChars 數據類型而不是 SqlString 來定義 RegexMatch 函數。SqlString 數據類型轉換成 nvarchar(4,000),而 SqlChars 轉換成 nvarchar(max)。新的最大尺寸功能允許字符串擴展到超過 SQL Server 2000 的 8,000 字節限制。在整篇文章中,我盡可能使用 nvarchar(max) 並且最大程度地保證靈活性。然而,如果所有相關字符串包含的字符都少於 4,000 個,使用 nvarchar(4,000) 則性能可得到顯著改善。您應檢查一下您的特定需求及其相應代碼。
此方法中的余下代碼非常簡單。通過定義的選項和提供的模式創建 Regex 實例,然后 IsMatch 方法將被用於確定指定的輸入是否與模式匹配。現在,您需要將一個簡單的查詢添加到測試腳本:
select dbo.RegexMatch( N'123-45-6789', N'^\d{3}-\d{2}-\d{4}$' )
此語句中的模式是用來測試美國社會安全號碼的簡單測試。在新查詢中設置斷點,然后開始單步調試函數。此函數允許您進行許多不同的測試,但我將為您介紹多數人未考慮到的一些內容。例如,在數據庫中保持一致的命名約定非常重要,而編寫查詢來驗證所有的存儲過程是否符合組織的指導原則卻很困難。RegexMatch 函數使得此項任務變得更加簡單。例如,以下查詢測試可以執行此項任務:
select ROUTINE_NAME from INFORMATION_SCHEMA.ROUTINES where ROUTINE_TYPE = N'PROCEDURE' and dbo.RegexMatch( ROUTINE_NAME, N'^usp_(Insert|Update|Delete|Select)([A-Z][a-z]+)+$' ) = 0
此查詢測試每個存儲過程是否以“usp_”開頭,后跟“Insert”、“Update”、“Delete”或“Select”,然后跟至少一個實體名稱。此外,它還驗證實體中的每個詞是否以大寫字母開始。請將這四行代碼與下面僅使用內置函數的過於簡化的版本相比較:
select ROUTINE_NAME from INFORMATION_SCHEMA.ROUTINES where ROUTINE_TYPE = N'PROCEDURE' and ( LEN( ROUTINE_NAME ) < 11 or LEFT( ROUTINE_NAME, 4 ) <> N'usp_' or SUBSTRING( ROUTINE_NAME, 5, 6 ) not in ( N'Insert', N'Update', N'Delete', N'Select' ) )
即使代碼數量多了,但此查詢實際上缺少幾項正則表達式版本中包含的功能。首先,它不區分大小寫而且在查詢中使用排序來執行測試會使其無規則可循。其次,它並未對包含在過程名稱中的實際實體名稱執行任何測試。第三,問題在於查詢中測試的四個字符串的長度均為六個字符,這樣我可以通過從六個字符中提取一個子串來簡化代碼,然后根據每個可接受的操作進行比較。由於所有操作名稱的長度均為六個字符,因此該問題並不特定於此示例,但需要構想一個可以指定更復雜動詞(例如“Get”、“List”或“Find”)的標准。RegexMatch 函數可以輕松處理這些動詞,因為它們恰好是列表中的其他備選方案。
驗證是正則表達式的常見用法,可以驗證從電話號碼到郵政編碼以及自定義帳號數字格式的任何內容。CHECK 約束非常適合執行此項操作,如以下所示表定義。
CREATE TABLE [Account] ( [AccountNumber] nvarchar(20) CHECK (dbo.RegexMatch( [AccountNumber], '^[A-Z]{3,5}\d{5}-\d{3}$' ) = 1), [PhoneNumber] nchar(13) CHECK (dbo.RegexMatch( [PhoneNumber], '^\(\d{3}\)\d{3}-\d{4}$' ) = 1), [ZipCode] nvarchar(10) CHECK (dbo.RegexMatch( [ZipCode], '^\d{5}(\-\d{4})?$' ) = 1) )
AccountNumber 列是按照滿足以下條件的任意約定來驗證的,即以三到五個字母開始,后跟五個數字,然后是一個破折號,最后又是三個數字。電話號碼和郵政編碼都根據標准的美國電話號碼和郵政編碼格式進行驗證。RegexMatch 函數為 SQL Server 提供了許多功能,而 .NET 中的正則表達式實現提供的功能則更多,正如您在下面內容中將看到的一樣。
數據提取
正則表達式的分組功能可用於從字符串中提取數據。我的 RegexGroup 函數為 T-SQL 提供了此功能:
[SqlFunction] public static SqlChars RegexGroup( SqlChars input, SqlString pattern, SqlString name ) { Regex regex = new Regex( pattern.Value, Options ); Match match = regex.Match( new string( input.Value ) ); return match.Success ? new SqlChars( match.Groups[name.Value].Value ) : SqlChars.Null; }
此函數同 RegexMatch 函數一樣可創建 Regex 對象。然而,Match 對象並非用於測試匹配而是為在輸入字符串中找到的第一個匹配項創建的。Match 對象用於檢索指定的組。如果在輸入中未找到匹配項,則返回空值。如果您喜歡用編號組而非命名組,則此函數仍然有效。僅將整數值傳遞給 SQL 代碼中的函數,它會隱式地轉換為 nvarchar 並且返回相應的組。
您可以在 SELECT 列表中使用 RegexGroup 函數來從其他一些數據片段中提取特定的信息片段。例如,如果您有一個存儲了 URL 的列,您現在可以輕松地分析此 URL 以確定各個片段。此查詢使用分組來確定存儲在 UrlTable 表的 Url 列中的每個不同的服務器。
select distinct dbo.RegexGroup( [Url], N'https?://(?<server>([\w-]+\.)*[\w-]+)', N'server' ) from [UrlTable]
您還可以在計算列中使用此函數。下面的表定義將電子郵件地址分為郵箱和域。
CREATE TABLE [Email] ( [Address] nvarchar(max), [Mailbox] as dbo.RegexGroup( [Address], N'(?<mailbox>[^@]*)@', N'mailbox' ), [Domain] as dbo.RegexGroup( [Address], N'@(?<domain>.*)', N'domain' )
郵箱列將返回電子郵件地址的郵箱或用戶名。域列將返回電子郵件地址的域。
模式存儲
這些函數使用的所有模式均僅為字符串,這意味着其中任何一個都可存儲在數據庫中的一個表中。多數存儲國際數據的數據庫都有一個表示國家的表。通過將額外列添加到此表,您可以存儲特定於國家的驗證模式。這樣可允許適用於某地址行的約束根據該行對應的國家而變化。
在代表客戶端存儲數據的數據庫中,通常已經有一個表示客戶端的表。此表可用於存儲允許您描述在數據庫中存儲原始客戶端數據方式的分組模式,這樣您就可以創建計算列以便從客戶端數據中提取實際需要的數據。例如,如果您的每個客戶端都有唯一的帳號方案而且您只需要該帳號的特定段,您可以輕松創建一個提取每個客戶端信息正確片段的表達式。
匹配
並非確定字符串是否與模式匹配,它有時需要提取每個匹配項。以前,這類提取需要游標循環訪問字符串的各部分。該過程不僅速度慢,而且代碼也難於理解和維護。正則表達式是執行此操作的更好方法。現在的問題是如何在 SQL 構造中返回全部所需的數據。表值函數可以解決這個問題。
表值函數有點類似先前的函數,但在兩個方面有所不同。首先,應用到方法的屬性必須完全聲明返回的表結構。其次,涉及兩個方法。第一個方法返回可枚舉對象而不是實際的函數結果。第二個方法傳遞可枚舉對象以填充各行的字段。通過枚舉器檢索的每個值都應與結果集的一行對應。.NET Framework 中的 ICollection 接口實現了 IEnumerable,這意味着任何集合都可由第一個方法返回。Regex 類包含 Match 方法,該方法返回您可使用的 MatchCollection。MatchCollection 的問題在於,必須在 Match 方法返回前處理整個字符串。SQL Server 包括依賴於按需發生的處理過程的優化措施,因此我更願意編寫自己的枚舉器(按需返回各匹配項)而不是預先返回整個集合。此決策實際取決於優化枚舉器之前如何使用函數以及應如何對函數進行大量測試。
圖 2 中的代碼表示枚舉器。跟蹤各個匹配在返回的匹配集中的位置時,MatchNode 類在字符串中封裝各個匹配。MatchIterator 類是可枚舉的,它還處理正則表達式處理過程。它使用新生成的關鍵字來創建比早期版本的框架更方便的枚舉器。它將按需返回在輸入字符串中檢測到的各個匹配項。
Figure 2 匹配的自定義可枚舉對象
internal class MatchNode { private int _index; public int Index { get{ return _index; } } private string _value; public string Value { get { return _value; } } public MatchNode( int index, string value ) { _index = index; _value = value; } } internal class MatchIterator : IEnumerable { private Regex _regex; private string _input; public MatchIterator( string input, string pattern ) { _regex = new Regex( pattern, UserDefinedFunctions.Options ); _input = input; } public IEnumerator GetEnumerator() { int index = 0; Match current = null; do { current = (current == null) ? _regex.Match( _input ) : current.NextMatch( ); if (current.Success) { yield return new MatchNode( ++index, current.Value ); } } while (current.Success); } }
圖 3 中的代碼定義了表值 CLR UDF。RegexMatches 方法返回一個新的 MatchIterator。RegexMatches 方法中的 SqlFunctionAttribute 還包括某些其他屬性。TableDefinition 屬性被設置為函數的表定義。FillRowMethodName 被設置為調用返回可枚舉對象的每個迭代的方法名稱。在此情況下,該方法為 FillMatchRow。
Figure 3 匹配的表值 CLR UDF
[SqlFunction( FillRowMethodName = "FillMatchRow", TableDefinition = "[Index] int,[Text] nvarchar(max)" )] public static IEnumerable RegexMatches(SqlChars input, SqlString pattern) { return new MatchIterator( new string( input.Value ), pattern.Value ); } [SuppressMessage( "Microsoft.Design", "CA1021:AvoidOutParameters" )] public static void FillMatchRow( object data, out SqlInt32 index, out SqlChars text ) { MatchNode node = (MatchNode)data; index = new SqlInt32( node.Index ); text = new SqlChars( node.Value.ToCharArray( ) ); }
對於 MatchIterator 的每個迭代,MatchNode 將被作為第一個參數傳遞到 FillMatchRow 方法。FillMatchRow 方法的其余參數必須聲明為輸出參數而且必須與第一個函數中定義的表定義匹配。FillMatchRow 函數僅使用 MatchNode 屬性來填充字段數據。
最后,您可通過此函數從字符串輕松地提取多個數據片段。為了說明對 RegexMatches 函數的應用,讓我們處理一個字符串以便使用此查詢來確定其中包含多少個不同的單詞:
declare @text nvarchar(max), @pattern nvarchar(max) select @text = N'Here are four words.', @pattern = '\w+' select count(distinct [Text]) from dbo.RegexMatches( @text, @pattern )
此示例非常簡單。不過它通過刪除不同的關鍵字來顯示使用此函數的某些可能性並且返回字符串的總字數。許多網站的文本輸入限制似乎為任意長度的字符串。通過將此類測試與新的 nvarchar(max) 表示法相結合,它可以限制輸入字數。此類查詢可用於滿足各種分析處理需求,而 RegexMatches 函數還可用於執行常見的任務。遺憾的是,此類查詢還體現出對於使用正則表達式的過度熱衷。此例中通過“\w+”表達式完成的拆分操作可以恰好通過 String.Split 方法輕松地完成,那樣速度會更快。正則表達式是一個非常強大的工具,但一定要確保有充分理由應用它們。可能存在用於特定情況的更簡單且性能更佳的工具。
我經常查看 MSDN® 論壇中有關如何將一列值傳遞到存儲過程的問題。我見過各種復雜的方法,它們將這類列表解析為實際列表以確定相關記錄。RegexMatches 函數提供了更簡潔的方法。
declare @pattern nvarchar(max), @list nvarchar(max) select @pattern = N'[^,]+', @list = N'2,4,6' select d.* from [Data] d inner join dbo.RegexMatches( @list, @pattern ) re on d.[ID] = re.[Text]
此模式與任何不包含逗號的字符組匹配。如果給定一個名為 Data 的表和一個名為 ID 的整數列,此查詢將返回列表中標識的每個記錄。鑒於 SQL Server 中的隱式轉換功能,這樣會更有用。同一查詢還可用於整數、日期/時間、GUID 或浮點數據類型。處理一列值的其他方法需要使用多個函數或存儲過程才能達到這種靈活程度。此函數還可用於未以逗號分隔的列表。也可處理以空格、分號、制表符、回車或任何其他可識別字符分隔的列表。
在匹配項中進行數據提取
類似於返回匹配項,我們還可以從每個匹配項中提取數據。嘗試使用 SQL 來進行這種操作是非常困難的。通常,這類任務將在應用程序而不是數據庫中實現,這樣會產生問題,因為使用該數據庫的每個應用程序都必須實現所需過程。在此情況下,合理的方法是在存儲過程中實現此功能。
同 RegexMatches 實現一樣,我喜歡使用自定義的可枚舉對象來返回組信息。由於我們還必須在每個匹配項中循環訪問組,因此分組是唯一略微復雜的操作。在圖 4 中,GroupNode 類與 MatchNode 類一樣,除了它還包括其所代表的組的名稱。GroupIterator 類與 MatchIterator 類類似,除了它還包括返回每個組的額外循環。由於擁有可枚舉對象,因此我定義表值函數的過程與定義 RegexMatches 函數的過程一樣。
Figure 4 組的自定義可枚舉對象
internal class GroupNode { private int _index; public int Index { get { return _index; } } private string _name; public string Name { get { return _name; } } private string _value; public string Value { get { return _value; } } public GroupNode( int index, string group, string value ) { _index = index; _name = group; _value = value; } } internal class GroupIterator : IEnumerable { private Regex _regex; private string _input; public GroupIterator( string input, string pattern ) { _regex = new Regex( pattern, UserDefinedFunctions.Options ); _input = input; } public IEnumerator GetEnumerator() { int index = 0; Match current = null; string[] names = _regex.GetGroupNames(); do { index++; current = (current == null) ? _regex.Match( _input ) : current.NextMatch( ); if (current.Success) { foreach(string name in names) { Group group = current.Groups[name]; if (group.Success) { yield return new GroupNode( index, name, group.Value ); } } } } while(current.Success); } }
在圖 5 中,RegexGroups 函數定義與 RegexMatches 函數定義一樣,除了它還返回匹配項中包含組名稱的其他數據列。通過此函數,我們現在可在字符串中找到多個匹配項,並且可從每個匹配項中提取特定的信息片段。
Figure 5 組的表值 CLR UDF
[SqlFunction( FillRowMethodName = "FillGroupRow", TableDefinition = "[Index] int,[Group] nvarchar(max),[Text] nvarchar(max)" )] public static IEnumerable RegexGroups( SqlChars input, SqlString pattern ) { return new GroupIterator( new string( input.Value ), pattern.Value ); } [SuppressMessage( "Microsoft.Design", "CA1021:AvoidOutParameters" )] public static void FillGroupRow( object data, out SqlInt32 index, out SqlChars group, out SqlChars text ) { GroupNode node = (GroupNode)data; index = new SqlInt32( node.Index ); group = new SqlChars( node.Name.ToCharArray( ) ); text = new SqlChars( node.Value.ToCharArray( ) ); }
處理數據庫時,以不同格式導入數據是常見的任務。以逗號分隔格式導入文件則更常見。多數開發人員創建這樣的應程序,它處理各行、提取數據,然后為各行執行存儲過程。盡管該過程可行,但我願意推薦另一種解決方案。如果您可以將整個文件傳遞到存儲過程並且讓存儲過程處理整個過程,情況會怎樣?通常這種想法被認為太復雜而無法實現,但是通過 RegexGroups 函數,您可以使用單一查詢實際執行此項插入。例如,考慮以下客戶數據。
2309478,Janet Leverling,J 2039748,Nancy Davolio,N 0798124,Andrew Fuller,M 4027392,Robert King,L
您需要從各行獲得三項不同的信息:七位數的客戶號、客戶名以及單個字符的客戶類型。通過以下表達式,您可以提取所有三項信息。
(?<CustomerNumber>\d{7}),(?<CustomerName>[^,]*),(?<CustomerType>[A-Z])\r?\n
您現在面臨的問題是,RegexGroups 函數返回的結果不能直接使用。您可以使用 SQL Server 2005 中的樞軸功能而不是游標來循環訪問結果。將所有的訪問結果一起放入存儲過程,這樣您就獲得了全部所需內容。圖 6 中的存儲過程接受包含最多 2GB Unicode 數據的以逗號分隔的文件的整個文本。它處理整個文件,將文件中的每一行作為行插入到 Customer 表中。任何被分隔的文本文件都可以相同的方法處理。對模式稍作更改就可以添加轉義序列以支持字符串中的逗號。
Figure 6 處理以逗號分隔的文件
create proc ImportCustomers ( @file nvarchar(max) ) as declare @pattern nvarchar(max) set @pattern = N'(?<CustomerNumber>\d{7}), (?<CustomerName>[^,]*),(?<CustomerType>[A-Z])\r?\n' insert [Customer] ( [CustomerNumber], [CustomerName], [CustomerType] ) select f.[CustomerNumber], f.[CustomerName], f.[CustomerType] from dbo.RegExGroups( @file, @pattern ) regex pivot ( max([Text]) for [Group] in ( [CustomerNumber], [CustomerName], [CustomerType] ) ) as f
然而,此過程也再次說明執行同一任務有多種方法,而且有時正則表達式並非總是最佳選擇。在此例中,使用樞軸功能有效地撤消 RegexGroups 所執行的所有操作以便以特殊分組格式返回數據。還可以使用更簡單且更快捷的 TVF 將數據直接插入表中,它只讀取每一行,根據逗號執行 String.Split,然后返回每一行。
總結
盡管這些匹配函數功能非常強大,但它們還不完善。還有許多確定執行匹配操作確切方法的可能選項。如果您的數據庫排序不區分大小寫,您可能希望函數也以不區分大小寫的方式執行匹配操作。可能會要求顯式捕獲選項以減少某些結果集。多行選項允許您為某些任務創建更精確的模式。您甚至可能希望創建用戶定義的類型以便將確切的所需選項傳遞到每個函數,這樣將允許每個函數的執行使用一組不同的選項。
您還應了解處理文本時會涉及本地化問題。例如,.NET Framework Regex 類比我的示例中的拉丁語 Regex 類識別更多字符,因此在開發使用國際數據的數據庫時,應多加注意。
當然,如本文中多次提及的那樣,盡管正則表達式極其強大,但請確保您確實需要該功能。某些任務通過更基本的工具集來執行會更快且更簡單。
為了方便起見,我提供的示例缺乏驗證和錯誤處理,這些是任何生產系統中都應包括的。應驗證函數的每個輸入並且應由您的要求來確定如何響應 null 或空的字符串輸入。無法分析模式或選項無效時,Regex 類可能會引發異常。應妥善處理這些異常。
將正則表達式與 SQL 結合起來可以提供許多處理數據的可選方法。使用這些函數可以減少將功能添加到數據庫所需的時間以及使系統更易於維護。任何數據庫都可以使用正則表達式,我建議您對這此函數進行試驗以便發現新的、甚至更具創造性的用途。
David Banister 是亞特蘭大第四大會計事務所的高級軟件開發人員。他已從事軟件編寫工作多年。閑暇時他喜歡閱讀 ECMA 語言規范、打網球和資助本地樂隊。