В 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')
endgo
Настраиваем базу - позволяем в ней работать 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,
deleteas 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)