В SQL Server 2012 появилась долгожданная возможность генерировать числа из последовательности - объект SEQUENCE. Хорошо, что SEQUENCE-ом можно пользоваться, как функцией, а не только, как DEFAULT значением на поле таблицы и не увязывать момент получения значения с моментом физического добавления записи. Но в реальной жизни довольно популярное требование - динамическое формирование имени последовательности (или любой другой способ реализации "хочу нумерацию с начала года/месяца"). Также часто хотят иметь хоть какой-то механизм "повторного" получения "пропущенных значений". Обычно предлагаемые в решения по генерации "бизнес-номеров", написанные на TSQL и базирующиеся на таблицах с текущими значениями счетчиков, имеют один недостаток - блокировки. Действительно, если мы "генерируем" новый номер, то на время генерации мы должны заблокировать счетчик, чтобы в другом соединении не было получено такое же значение. При этом зачастую номер нам нужно получать в рамках уже открытой транзакции, что чревато тем, что два изначально независимых бизнес-процесса будут в лучшем случае долго блокировать один другого, а в худшем - окажутся не настолько независимыми, чтобы избежать взаимоблокировки (deadlock).
Кроме того, генерацию номера на базе таблицы счетчиков нельзя "завернуть" в функцию, чего бы очень хотелось для реализации конструкций вида:
insert
into
MyTable(DocNum, DocDate, Comment)
select
Generator.NextValue(
'SequenceFor_DocNum'
), IncomeDate, Comment
from
#SomeBuffer
поскольку в функциях запрещены любые изменения данных и мы не можем изменить текущее значение.
Но не совсем любые! В функциях есть возможность вызывать расширенные хранимые процедуры и CLR процедуры и функции.
А вот в CLR функции у нас есть возможность подключиться к тому же серверу и базе, но уже в другом соединении и вызвать процедуру, генерирующую нужный номер, не накладывая длительных блокировок. Для этого придется сделать несколько дополнительных действий помимо написания самой функции:
Причем сборку мы будем создавать из самого же SQL Server - не нужен даже VisualStudio, но об этом позже.
(Все запросы выполняем в той базе, где нам нужны счетчики)
Настраиваем сервер - включаем CLR:
if exists (
*
sys.configurations
where
name
=
'clr enabled'
and
value_in_use=0)
begin
exec
(
'sp_configure '
'show advanced options'
', 1'
)
'reconfigure'
end
go
Настраиваем базу - позволяем в ней работать unsafe сборкам:
declare
@sql nvarchar(
max
set
@sql = N
'alter database '
+DB_NAME()+N
' set trustworthy on'
(@sql)
Создаем схему _Generator, в которой будут находиться основные объекты, необходимые для манипуляции со счетчиками: if SCHEMA_ID('_Generator') is null exec ('create schema _Generator')
Создаем таблицу, в которой будут храниться параметры счетчиков:
create
table
_Generator.List
ID uniqueidentifier
not
null
default
newid() ,
Name
sysname
,
StartValue
int
constraint
DF__Generator_List_StartValue
0,
IsWorkWithHoles tinyint,
PK_List_ID
primary
key
clustered(ID),
AK_List_Name
unique
Теперь создадим триггер, который для каждого внесенного в _Generator.List счетчика будет создавать в схеме с именем G$имя_счетчика, функции NextValue и CurrentValue. Причем в зависимости от параметра IsWorkWithHoles, реализации функции NextValue несколько различаются.
trigger
[_Generator].[TR_List_UpdateGenerator]
on
[_Generator].[List]
with
execute
as
owner
after
update
delete
nocount
ansi_nulls
@
nvarchar(128),
@IsWorkWithHoles tinyint,
@ID uniqueidentifier,
@FunctionName nvarchar(128),
maX
cursd
cursor
local
static
forward_only
for
N
'G$'
+i.
deleted i
open
while 1=1
fetch
next
if @@FETCH_STATUS <> 0 break
if object_id(@
+N
'.CurrentValue'
, N
'FN'
is
(N
'drop function '
+@
'.NextValue'
'.NextValueHole'
'.RegisterHole'
'P'
'drop procedure '
-- If generator's schema is "empty" (i.e. it was used only by generator)
if
exists(
sys.all_objects
schema_id=schema_id(@
))
(N'
drop
schema
'+@Name)
declare cursi cursor local static forward_only for
select i.Name, i.ID, i.IsWorkWithHoles
from inserted i
open cursi
fetch next from cursi into @Name, @ID, @IsWorkWithHoles
set @FunctionName = '
NextValue
' + case when @IsWorkWithHoles=1 then '
Hole
' else '
' end
if SCHEMA_ID(@Name) is null
exec(N'
G$
if object_id(@Name+N'
.CurrentValue
') is null
set @sql = N'
function
'+@Name+N'
.CurrentValue(@
Sequence
nvarchar(256))
returns
@CV
@CV=CurrentValue
_Generator.
''
.
+
isnull
if @@rowcount = 0
@CV = StartValue
ID =
' +convert(varchar(40), @ID)+N'
return
'
exec(@sql)
.NextValue
.NextValue(@
(_Generator.
'+@FunctionName+N'
'+@Name+'
), @@SPID, @@SERVERNAME, DB_NAME()))
Обратите внимание - триггер создан с опцией with execute as owner, что позволяет фактически превратить операции по вставке данных в таблицу в DDL операции, расширяющие синтаксис в нашей базе. При этом создающий генератор пользователь вовсе не должен иметь какие-либо права на модификацию структуры БД. Кроме того, кому-то будет удобнее так отлаживать сам механизм. Создаем таблицу, в которую будем помещать неиспользованные значения счетчиков:
_Generator.Hole
nvarchar(256)
HoleValue
PK__Generator_Hole
, HoleValue)
Процедура добавления генератора в таблицу _Generator.List. Она в общем-то скорее нужна, чтобы продемонстрировать, что достаточно дать права например на запуск одной процедуры для создания новых генераторов.
procedure
_Generator.New
sysname,
-- Имя генератора
@StartValue
-- Начальное значение
@IsWorkWithHoles tinyint
-- Работа с пропущенными значениями
= @
_Generator.List(
, StartValue, IsWorkWithHoles)
values
(@
, @StartValue, @IsWorkWithHoles)
Теперь наконец-то создадим таблицу с текущими значениями счетчиков и процедуру, генерирующую значения.
Процедур будет 2 - одна для генераторов, для которых указана возможность работы с пропущенными значениями. Можно было обойтись и одной, но это хуже с точки зрения оптимизации быстродействия - лишние запросы и/или параметры и проверки для генераторов, у которых пропущенные значения неактуальны:
CurrentValue
PK_Sequence_GenID_Name
clustered(
-- Никогда не используйте эту процедуру "напрямую"!
_Generator.GenerateValue
@TC
@Value
@TC = @@TRANCOUNT,
@Value =
try
transaction
s
@Value = CurrentValue = CurrentValue + 1
(holdlock)
if @@ROWCOUNT = 0
@Value = l.StartValue
_Generator.List l
l.
SUBSTRING
, 1,
nullif
(CHARINDEX(
'.'
, @
), 0), 256)-1)
raiserror(
'Generator not found'
, 16, 1)
, CurrentValue)
, @Value)
commit
catch
if @@TRANCOUNT > @TC
rollback
_Generator.GenerateValueHole
@Hole
(HoleValue
-- Попытка найти зарегистрированные ранее пропущенные значения.
-- Можно сделать чуть хитрее, для получения "дырок" в порядке возрастания/убывания.
top
(1) h
output
deleted.HoleValue
_Generator.Hole h
if @@ROWCOUNT = 1
(1)
@Value = h.HoleValue
@Hole h
else
-- if there was no any hole...
-- If there is no Sequence yet
if @@ROWCOUNT = 0 raiserror(
Ну и процедура регистрации пропущенных значений:
_Generator.RegisterHole
nvarchar(256),
@HoleValue
HoleValue = @HoleValue
Теперь собственно CLR сборка.
Далеко не все разработчики баз данных в дружеских отношениях с C# и VisualStudio и представляют, как скомпилировать сборку. Скорее всего также мало кто захочет довериться сборке, выложенной в виде dll.
Поэтому скомпилируем и создадим сборку прямо в T-SQL. Единственное требование - на самом SQL Server должен быть установлен .NET Framework 3.5:
@t
(txt
varchar
(255))
temp
(255),
@sql
(8000),
@cs
-- Делаем базу данных trustworthy
@sql =
+db_name()+
xp_cmdshell
'set'
substring
(txt, 6, 255)
txt
like
'TEMP%'
@cs =
'using System;
using System.Data;
using System.Data.SqlClient;
using System.Data.SqlTypes;
using Microsoft.SqlServer.Server;
namespace DeColores
{
public partial class PGenerator
[Microsoft.SqlServer.Server.SqlFunction(
DataAccess = DataAccessKind.Read,
Name = "NextValue")]
public static SqlInt32 NextValue(SqlString Sequence, SqlInt32 SPID, SqlString ServerName, SqlString DatabaseName)
using (SqlConnection IsolatedConn
= new SqlConnection("Integrated Security=true; Initial Catalog=" + DatabaseName.ToString() + "; server=" + ServerName.ToString() + "; Application Name=_Generator_for_" + SPID.ToString() + "; Enlist=false"))
IsolatedConn.Open();
SqlCommand GenValue = new SqlCommand("_Generator.GenerateValue", IsolatedConn);
GenValue.CommandType = CommandType.StoredProcedure;
GenValue.Parameters.AddWithValue("Sequence", Sequence);
SqlParameter ret = new SqlParameter();
ret.ParameterName = "ReturnValue";
ret.DbType = DbType.Int32;
ret.Direction = ParameterDirection.ReturnValue;
GenValue.Parameters.Add(ret);
GenValue.ExecuteNonQuery();
SqlInt32 Val = (int)GenValue.Parameters["ReturnValue"].Value;
return Val;
}
return SqlInt32.Null;
Name = "NextValueHole")]
public static SqlInt32 NextValueHole(SqlString Sequence, SqlInt32 SPID, SqlString ServerName, SqlString DatabaseName)
SqlCommand GenValue = new SqlCommand("_Generator.GenerateValueHole", IsolatedConn);
};
@lpos
@prevpos
@prevpos = 1
'if exist '
'\generator.cs ( del '
'\generator.cs)'
xp_cmdshell @sql, no_output
@lpos = charindex(
char
(13), @cs, @prevpos)+2
if @lpos = 2 break
'echo '
(@cs, @prevpos, @lpos - @prevpos-2)+
' >> '+@temp+'\generator.cs'
'\generator.cs'
@prevpos = @lpos
'C:\WINDOWS\Microsoft.NET\Framework\v3.5\csc.exe /out:'
'\generator.dll /target:library /unsafe '
xp_cmdshell @sql , no_output
assembly Generator
'\generator.dll'
permission_set = unsafe;
Создадим сами CLR функции:
CREATE
FUNCTION
[_Generator].[NextValue](@
[nvarchar](4000), @SPID [
], @ServerName [nvarchar](4000), @DatabaseName [nvarchar](4000))
RETURNS
[
]
WITH
EXECUTE
AS
CALLER
EXTERNAL
NAME
[Generator].[DeColores.PGenerator].[NextValue]
[_Generator].[NextValueHole](@
[Generator].[DeColores.PGenerator].[NextValueHole]
Все готово. Теперь - примеры использования. Регистрируем генератор Test с начальным значением последовательностей 0 и возможностью работать с пропущенными значениями:
'Test'
@StartValue = 0,
@IsWorkWithHoles = 1
Просто получение значения для последовательности "123":
G$Test.NextValue(
'123'
Динамическое формирование имени последовательности:
sv.number, G$Test.NextValue(
'number'
convert
(20), sv.number%3))
master.dbo.spt_values sv
sv.[type] =
sv.number < 100
Убеждаемся, что откат транзакции не приводит к "откату" значения счетчика:
tran
'TestRollback'
Регистрируем пропущенное значение и получаем его из "стандартной" функции:
(G$Test.NextValue(
'TestHole'
'Test..TestHole'
, 12
Вот собственно и всё. Конечно, данный код приведен исключительно в качестве примера и заготовки. Например, в реальном применении лучше регистрировать пропущенные значения также в изолированной транзакции через CLR. И совсем не обязательно создавать функции-генераторы в триггере.Кроме того, можно делать различные макроподстановки в имени последовательности при генерации значения в самой CLR функции, например, заменять %YY% на 2 последние разряда текущего года. В нашем реальном проекте например сделано больше десятка подобных макро и другие дополнительные возможности вроде генерации не просто числа, а готовой форматированной строки....
Но это уже на вкус и цвет коллег по цеху.