В 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 функции у нас есть возможность подключиться к тому же серверу и базе, но уже в другом соединении и вызвать процедуру, генерирующую нужный номер, не накладывая длительных блокировок.
Для этого придется сделать несколько дополнительных действий помимо написания самой функции:
- сделать unsafe сборку
- передавать из функции в сборку имя сервера и базы данных, чтобы можно было без лишних запросов сформировать строку соединения
- указать в строке соединения Enlist=false, чтобы транзакция, в которой будет происходить генерация, не "подключилась" к той транзакции, из которой мы пытаемся получить новое значение счетчика
- убедиться, что учетная запись, под которой запущен SQL Server, имеет права на подключение к базе данных, в которой будут использоваться счетчики (по умолчанию, у такой учетки есть права sysadmin, но шаловливые руки администраторов способны на многое)
- сделать set trustworthy on для той же базы данных
Причем сборку мы будем создавать из самого же SQL Server - не нужен даже VisualStudio, но об этом позже.
(Все запросы выполняем в той базе, где нам нужны счетчики)
Настраиваем сервер - включаем CLR:
if exists (
select
*
from
sys.configurations
where
name
=
'clr enabled'
and
value_in_use=0)
begin
exec
(
'sp_configure '
'show
advanced options'
', 1'
)
exec
(
'reconfigure'
)
exec
(
'sp_configure '
'clr
enabled'
', 1'
)
exec
(
'reconfigure'
)
end
go
Настраиваем базу - позволяем в ней работать unsafe сборкам:
declare
@sql nvarchar(
max
)
set
@sql = N
'alter database '
+DB_NAME()+N
' set trustworthy on'
exec
(@sql)
Создаем схему _Generator, в которой будут находиться основные объекты, необходимые для манипуляции со счетчиками:
if SCHEMA_ID('_Generator') is null exec ('create schema _Generator')
Создаем таблицу, в которой будут храниться параметры счетчиков:
create
table
_Generator.List
(
ID uniqueidentifier
not
null
default
newid() ,
Name
sysname
not
null
,
StartValue
int
not
null
constraint
DF__Generator_List_StartValue
default
0,
IsWorkWithHoles tinyint,
constraint
PK_List_ID
primary
key
clustered(ID),
constraint
AK_List_Name
unique
(
Name
)
)
go
Теперь создадим триггер, который для каждого внесенного в _Generator.List счетчика будет создавать в схеме с именем G$имя_счетчика, функции NextValue и CurrentValue. Причем в зависимости от параметра IsWorkWithHoles, реализации функции NextValue несколько различаются.
create
trigger
[_Generator].[TR_List_UpdateGenerator]
on
[_Generator].[List]
with
execute
as
owner
after
insert
,
update
,
delete
as
begin
set
nocount
on
set
ansi_nulls
on
declare
@
Name
nvarchar(128),
@IsWorkWithHoles tinyint,
@ID uniqueidentifier,
@FunctionName nvarchar(128),
@sql nvarchar(
maX
)
declare
cursd
cursor
local
static
forward_only
for
select
N
'G$'
+i.
Name
from
deleted i
open
cursd
while 1=1
begin
fetch
next
from
cursd
into
@
Name
if @@FETCH_STATUS <> 0 break
if object_id(@
Name
+N
'.CurrentValue'
,
N
'FN'
)
is
not
null
exec
(N
'drop function '
+@
Name
+N
'.CurrentValue'
)
if object_id(@
Name
+N
'.NextValue'
,
N
'FN'
)
is
not
null
exec
(N
'drop function '
+@
Name
+N
'.NextValue'
)
if object_id(@
Name
+N
'.NextValueHole'
,
N
'FN'
)
is
not
null
exec
(N
'drop function '
+@
Name
+N
'.NextValueHole'
)
if object_id(@
Name
+N
'.RegisterHole'
,
N
'P'
)
is
not
null
exec
(N
'drop procedure '
+@
Name
+N
'.RegisterHole'
)
-- If generator's schema is "empty" (i.e. it was used only by generator)
if
not
exists(
select
*
from
sys.all_objects
where
schema_id=schema_id(@
Name
))
exec
(N'
drop
schema
'+@Name)
end
declare cursi cursor local static forward_only for
select i.Name, i.ID, i.IsWorkWithHoles
from inserted i
open cursi
while 1=1
begin
fetch next from cursi into @Name, @ID, @IsWorkWithHoles
if @@FETCH_STATUS <> 0 break
set @FunctionName = '
NextValue
' + case when @IsWorkWithHoles=1
then '
Hole
' else '
' end
if SCHEMA_ID(@Name) is null
exec(N'
create
schema
G$
'+@Name)
if object_id(@Name+N'
.CurrentValue
') is null
begin
set @sql = N'
create
function
G$
'+@Name+N'
.CurrentValue(@
Sequence
nvarchar(256))
returns
int
as
begin
declare
@CV
int
select
@CV=CurrentValue
from
_Generator.
Sequence
where
Name
=
''
'+@Name+N'
.
''
+
isnull
(
''
.
''
+@
Sequence
,
''
''
)
if @@rowcount = 0
select
@CV = StartValue
from
_Generator.List
where
ID =
''
' +convert(varchar(40), @ID)+N'
''
return
@CV
end
'
exec(@sql)
end
if object_id(@Name+N'
.NextValue
') is null
begin
set @sql = N'
create
function
G$
'+@Name+N'
.NextValue(@
Sequence
nvarchar(256))
returns
int
as
begin
return
(_Generator.
'+@FunctionName+N'
(
''
'+@Name+'
.
''
+
isnull
(
''
.
''
+@
Sequence
,
''
''
), @@SPID, @@SERVERNAME, DB_NAME()))
end
'
exec
(@sql)
end
end
end
Обратите внимание - триггер создан с опцией with execute as owner, что позволяет фактически превратить операции по вставке данных в таблицу в DDL операции, расширяющие синтаксис в нашей базе. При этом создающий генератор пользователь вовсе не должен иметь
какие-либо права на модификацию структуры БД.
Кроме того, кому-то будет удобнее так отлаживать сам механизм.
Создаем таблицу, в которую будем помещать неиспользованные значения счетчиков:
create
table
_Generator.Hole
(
Sequence
nvarchar(256)
not
null
,
HoleValue
int
not
null
constraint
PK__Generator_Hole
primary
key
(
Sequence
, HoleValue)
)
go
Процедура добавления генератора в таблицу _Generator.List.
Она в общем-то скорее нужна, чтобы продемонстрировать, что достаточно дать права например на запуск одной процедуры для создания новых генераторов.
create
procedure
_Generator.New
@
Name
sysname,
-- Имя генератора
@StartValue
int
,
-- Начальное значение
@IsWorkWithHoles tinyint
-- Работа с пропущенными значениями
as
begin
set
nocount
on
if
not
exists(
select
*
from
_Generator.List
where
Name
= @
Name
)
insert
into
_Generator.List(
Name
, StartValue, IsWorkWithHoles)
values
(@
Name
,
@StartValue, @IsWorkWithHoles)
end
go
Теперь наконец-то создадим таблицу с текущими значениями счетчиков и процедуру, генерирующую значения.
Процедур будет 2 - одна для генераторов, для которых укist(Name
, StartValue, IsWorkWithHoles)