SQL Server 2005:正则表达式使模式匹配和数据提取变得更容易

David Banister

本文讨论:
  • 使用正则表达式进行高效的 SQL 查询
  • SQL Server 2005 对正则表达式的支持
  • 从 SQL Server 使用 .NET Regex 类
  • 在数据库中有效地使用正则表达式
本文使用了以下技术:
SQL Server 2005,.NET Framework
下载本文中所用的代码: Regex2007_02.exe (154 KB)
浏览在线代码
尽管 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 所示,而且易于操作。
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 类是可枚举的,它还处理正则表达式处理过程。它使用新生成的关键字来创建比早期版本的框架更方便的枚举器。它将按需返回在输入字符串中检测到的各个匹配项。
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。
[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 函数的过程一样。
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 函数定义一样,除了它还返回匹配项中包含组名称的其他数据列。通过此函数,我们现在可在字符串中找到多个匹配项,并且可从每个匹配项中提取特定的信息片段。
[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 表中。任何被分隔的文本文件都可以相同的方法处理。对模式稍作更改就可以添加转义序列以支持字符串中的逗号。
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 结合起来可以提供许多处理数据的可选方法。使用这些函数可以减少将功能添加到数据库所需的时间以及使系统更易于维护。任何数据库都可以使用正则表达式,我建议您对这此函数进行试验以便发现新的、甚至更具创造性的用途。

http://msdn.microsoft.com/zh-cn/magazine/cc163473.aspx

加支付宝好友偷能量挖...


评论(0)网络
阅读(220)喜欢(0)SQL及数据库