Инструмент для построения условий

В прошлой статье «Игра со списками условий» я показал, как строить запросы. В качестве примера я взял несколько условий, написанных на SQL, и использовал их в запросе.

Но пользователь не умеет писать на SQL, пользователя нельзя подпускать к написанию запросов. Но как же быть, если очень надо? Я предлагаю дать пользователю простой инструмент, результаты которого уже трансформировать в SQL.

Накидаем список вопросов:

1. Что нужно пользователю?
2. Какими понятиями может оперировать пользователь?
3. Как нам преобразовать эти понятия в запрос?
4. Как нам проверить, что условие составленное пользователем охватывает все множество записей?
5. Как нам сделать не одноразовый инструмент?
В своей практике я прошел через несколько этапов: от простых перечислений в запросах, до построения списков в таблицах, но иногда возникали задачи, где простых списком мало. И тогда я придумал следующий механизм:

Создаются группы из разнотипных элементов (если требуется). Все элементы в такой группе объединяются по ИЛИ.
В одно условие входит одна или несколько групп объединяющихся по И и, возможно, одна или несколько групп отрицающих. Примерно так:
(a1 or a2 or a3) and (b1 or b2) and not (c1 or c2) and not (d1)

Теперь об элементах:

1. Один элемент — имя поля и его значение. (Учтите, что ко всему описанному ниже нужно будет прикрутить пользовательский интерфейс, а там нужно использовать понятные обозначения: не «pa.city», а «Город доставки», …
2. Можно сделать элементы — константы (просто прописывается программистом SQL-выражение). В качестве констант очень полезна положительное условие (1=1). С ее помощью можно выбрать все строки, построить множество дополнений, блокировать условия.
3. Элемент — ссылка на другое условие. Это очень важный и сильный элемент.
Пример: Усл1 = (a1 or a2 or a3), тогда Усл2 = (1=1) and not (Усл1) является дополнением множества Усл1.
Оба эти условия перекрывают все множество записей, и при изменении Усл1 автоматически меняется Усл2

В прошлой своей статье я приводил примеры некоторых условий:

if OBJECT_ID('tempdb..#Conditions') is not null drop table #Conditions
create table #Conditions (
  ConditionID   int identity(1,1) primary key,
  Name                  varchar(100),
  [Text]                varchar(200),
  [Value]               varchar(200)
)

insert #Conditions(Name, [Text], [Value]) select 'Доставка в Берлин и Бонн байков', 'pa.city in (''Berlin'', ''Bonn'') and ppc.Name in (''Byke'')' , 'sd.OrderQty * pp.Weight'
insert #Conditions(Name, [Text], [Value]) select 'Доставка в Берлин и Бонн других товаров', 'pa.city in (''Berlin'', ''Bonn'') and ppc.Name not in (''Byke'')', 'sd.OrderQty * pp.Weight'
insert #Conditions(Name, [Text], [Value]) select 'Доставка в другие города', 'pa.city not in (''Berlin'', ''Bonn'')', 'sd.OrderQty * pp.Weight'


В свете этой статьи предлагаю построить следующую таблицу (для полномасштабного проекта больше таблиц):

if OBJECT_ID('tempdb..#ConditionAtoms') is not null drop table #ConditionAtoms
create table #ConditionAtoms (
  AtomID int identity(1,1) primary key,
  ConditionID int,
  GroupNumber int, -- номер группы, Номер > 0 группа положительных условий, Номер < 0 - группа отрицания
  Field varchar(50), -- Немного упрощаю
  Value varchar(1000) -- Здесь могут хранится значения разных типов
)



Если переписать текст условий в лоб, то должно получиться где-то так


insert #ConditionAtoms(ConditionID, GroupNumber, Field, Value) select 1, 1, 'pa.city', 'Berlin'
insert #ConditionAtoms(ConditionID, GroupNumber, Field, Value) select 1, 1, 'pa.city', 'Bonn'
insert #ConditionAtoms(ConditionID, GroupNumber, Field, Value) select 1, 2, 'ppc.Name', 'Byke'

insert #ConditionAtoms(ConditionID, GroupNumber, Field, Value) select 2,  1, 'pa.city', 'Berlin'
insert #ConditionAtoms(ConditionID, GroupNumber, Field, Value) select 2,  1, 'pa.city', 'Bonn'
insert #ConditionAtoms(ConditionID, GroupNumber, Field, Value) select 2, -1, 'ppc.Name', 'Byke'

insert #ConditionAtoms(ConditionID, GroupNumber, Field, Value) select 3,  1, ''    , '1=1'
insert #ConditionAtoms(ConditionID, GroupNumber, Field, Value) select 3, -1, 'pa.city', 'Berlin'
insert #ConditionAtoms(ConditionID, GroupNumber, Field, Value) select 3, -1, 'pa.city', 'Bonn'


—, а можно и по-другому

-- Объединяем Берлин и Бонн в первое условие
insert #ConditionAtoms(ConditionID, GroupNumber, Field, Value) select 1, 1, 'pa.city', 'Berlin'
insert #ConditionAtoms(ConditionID, GroupNumber, Field, Value) select 1, 1, 'pa.city', 'Bonn'

-- Ограничиваем первое условие только байками
insert #ConditionAtoms(ConditionID, GroupNumber, Field, Value) select 2, 1, '', '1'
insert #ConditionAtoms(ConditionID, GroupNumber, Field, Value) select 2, 2, 'ppc.Name', 'Byke'

-- Исключаем из первого условия, то что попало во второе (байки)
insert #ConditionAtoms(ConditionID, GroupNumber, Field, Value) select 3,  1, '', '1'
insert #ConditionAtoms(ConditionID, GroupNumber, Field, Value) select 3, -1, '', '2'

-- А все остальное и есть наше последнее условие
insert #ConditionAtoms(ConditionID, GroupNumber, Field, Value) select 4,  1, '', '1=1'
insert #ConditionAtoms(ConditionID, GroupNumber, Field, Value) select 4, -1, '', '1'


Чтоб не перегружать текст SQL-кодом остановлюсь не нескольких моментах:

1. Как видно из последнего кода, для построения Условия № 3, нужно построить все предыдущие, а для этого нужно найти сначала Условия, которые не содержат вложенных условий, на следующем шаге нужно строить только те условия, для которых известны все вложенные условия и т.д.
2. Защититесь от зацикливания
3. Защититесь от удаленных условий
4. Для оптимизации для некоторых типов полей значения можно объединять в «IN».
5. Для построения запросов очень удобно пользоваться инструкцией WITH и оконными функциями.

Вот и сама функция для построения условия (обратите внимание, что я вместо временной таблицы, использовал постоянную).

create function test.ConditionSQL( @ConditionID int , @Lvl int = 0 )
returns varchar(max)
begin
  declare @Ret varchar(max)
  if @Lvl < 100
        with  Data as (
        select * 
        ,[SQL] = '( ' + case
          when Field = '' then value
          when Field = '' then test.ConditionSQL( try_convert(int, value), @Lvl+1  )
          else Field + ' = ''' + Replace( Value, '''', '''''') + ''''
        end + ')'
        ,[Row] = ROW_NUMBER() over(partition by GroupNumber  order by Field ) 
        from ConditionAtoms ca where ConditionID = @ConditionID
        ),
        Build as (
          select GroupNumber, [Row], [Sql] = convert(varchar(max), [SQL]), MaxRow = (select  max([ROW]) from data d where d.GroupNumber = data.GroupNumber )  from Data where [Row] = 1
          union all
          select Build.GroupNumber, Data.Row, Build.SQL + ' or ' + data.SQL , Build.MaxRow
            from Build
          join Data on Build.GroupNumber = data.GroupNumber and Build.Row + 1 = Data.Row
        )
        select @Ret = iif( @Ret is null, '', @Ret + ' and ' )
           + iif( GroupNumber < 0 , ' not ', '' )
           + '  (' + [SQL] + ')  '
           from Build where [Row] = [MaxRow]

  return IsNull( @Ret, 'Error ConditionID = ' + format(@ConditionID, '0') )

end 



Сама функция немного упрощена. С помощью еще одной рекурсии можно OR по одному полю объединить в секцию IN. Можно добавить обработку не только списков, но и диапазонов, сравнения с числами и т.д. (на сколько хватит фантазии)

Пример работы функции:

print dbo.ConditionSQL(1,0)
print dbo.ConditionSQL(2,0)
print dbo.ConditionSQL(3,0)
print dbo.ConditionSQL(4,0)

---------------------------------------


  (( pa.city = 'Berlin') or ( pa.city = 'Bonn'))  

  ((   (( pa.city = 'Berlin') or ( pa.city = 'Bonn'))  ))   and   (( ppc.Name = 'Byke'))  

 not   ((   ((   (( pa.city = 'Berlin') or ( pa.city = 'Bonn'))  ))   and   (( ppc.Name = 'Byke'))  ))   and   ((   (( pa.city = 'Berlin') or ( pa.city = 'Bonn'))  ))  

 not   ((   (( pa.city = 'Berlin') or ( pa.city = 'Bonn'))  ))   and   (( 1=1))  



© Habrahabr.ru