Статьи

Динамическое построение критериев GORM

Первоначально я написал большинство запросов в плагине spring-security-ui, используя HQL, потому что считаю его более интуитивным, чем критерии, но HQL работает только с Hibernate и реляционными базами данных. Пул-запрос обновил запросы, чтобы использовать критерии запросов, чтобы позволить плагину использоваться с хранилищами данных NoSQL, но один запрос не соответствовал стилю программирования, который я использовал. Это не имело большого значения, но, поскольку большая часть кода контроллера в основном является кодом CRUD и очень похожа на другие, я попытался сохранить универсальный код и внедрить общую логику в базовые классы.

Оригинальный HQL включил этот

1
hql.append " AND e.aclObjectIdentity.aclClass.id=:aclClass"

и преобразованный код критерия был

1
2
3
4
5
aclObjectIdentity {
   aclClass {
      eq 'id', params.long('aclClass')
   }
}

со всем запросом, подобным этому:

01
02
03
04
05
06
07
08
09
10
11
def results = lookupClass().createCriteria().list(max: max, offset: offset) {
   // other standard criteria method calls
 
   if (params.aclClass) {
      aclObjectIdentity {
         aclClass {
            eq 'id', params.long('aclClass')
         }
      }
   }
}

Это заставило меня задуматься о создании способа представления этой двухуровневой проекции и критерия в целом.

Если мы восстановим опущенные необязательные скобки, код станет

1
2
3
4
5
aclObjectIdentity({
   aclClass({
      eq('id', params.long('aclClass'))
   })
})

Поэтому должно быть более ясно, что это последовательность вызовов методов; вызов aclObjectIdentity с аргументом закрытия, затем aclClass с аргументом закрытия и, наконец, eq со String и long аргументом. Разделение замыканий как локальных переменных делает это более понятным, во-первых, как

1
2
3
4
5
6
7
def aclClassClosure = {
   eq('id', params.long('aclClass'))
}
 
aclObjectIdentity({
   aclClass(aclClassClosure)
})

а потом

1
2
3
4
5
6
7
8
9
def aclClassClosure = {
   eq 'id', params.long('aclClass')
}
 
def aclObjectIdentityClosure = {
   aclClass(aclClassClosure)
}
 
aclObjectIdentity(aclObjectIdentityClosure)

Чтобы сделать это немного более конкретным, скажем, у нас есть три класса предметной области;

Department :

1
2
3
class Department {
   String name
}

Manager :

1
2
3
4
class Manager {
   String name
   Department department
}

и Employee :

1
2
3
4
class Employee {
   String name
   Manager manager
}

Мы создаем несколько экземпляров:

1
2
3
4
Department d = new Department(name: 'department1').save()
Manager m = new Manager(name: 'manager1', department: d).save()
Employee e1 = new Employee(name: 'employee1', manager: m).save()
Employee e2 = new Employee(name: 'employee2', manager: m).save()

и позже хочу выполнить запрос:

1
2
3
4
5
6
7
8
9
Employee.createCriteria().list(max: 10, offset: 0) {
   eq 'name', 'employee1'
 
   manager {
      department {
         eq 'name', 'department1'
      }
   }
}

Моя цель — представить этот запрос только с некоторыми вспомогательными методами и без каких-либо замыканий (или как можно меньше). Разделив это, как указано выше, мы имеем

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
def departmentClosure = {
   eq 'name', 'department1'
}
 
def managerClosure = {
   department(departmentClosure)
}
 
def criteriaClosure = {
   eq 'name', 'employee1'
 
   manager(managerClosure)
}
 
Employee.createCriteria().list([max: 10, offset: 0], criteriaClosure)

Когда запрос выполняется, делегат criteriaClosure устанавливается на экземпляр HibernateCriteriaBuilder при использовании Hibernate или аналогичного компоновщика для MongoDB или любой другой используемой вами реализации GORM. В построителе определены методы для eq , like , between и т. Д., Поэтому, когда вы делаете эти вызовы в закрытии критериев, они запускаются в построителе.

Оказывается, это работает одинаково, если вы разбиваете замыкание на несколько замыканий и вызываете их с помощью построителя в качестве делегата для каждого. Итак, такой метод работает:

1
2
3
4
5
6
7
8
def runCriteria(Class clazz, List<Closure> criterias, Map paginateParams) {
   clazz.createCriteria().list(paginateParams) {
      for (Closure criteria in criterias) {
         criteria.delegate = delegate
         criteria()
      }
   }
}

а это значит, что мы можем разделить

1
2
3
4
5
6
7
8
9
Employee.createCriteria().list(max: 10, offset: 0) {
   eq 'name', 'employee1'
 
   manager {
      department {
         eq 'name', 'department1'
      }
   }
}

в

01
02
03
04
05
06
07
08
09
10
11
def closure1 = {
   eq 'name', 'employee1'
}
 
def closure2 = {
   manager {
      department {
         eq 'name', 'department1'
      }
   }
}

и запустить его как

1
runCriteria Employee, [closure1, closure2], [max: 10, offset: 0]

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

В конечном итоге я хочу уметь указывать проекцию с внутренним критерием вызова без замыканий:

1
2
3
def projection = buildProjection('manager.department',
                                 'eq', ['name', 'department1'])
runCriteria Employee, [closure1, projection], [max: 10, offset: 0]

Вот метод buildProjection который делает это:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
Closure buildProjection(String path, String criterionMethod, List args) {
 
   def invoker = { String projectionName, Closure subcriteria ->
      delegate."$projectionName"(subcriteria)
   }
 
   def closure = { ->
      delegate."$criterionMethod"(args)
   }
 
   for (String projectionName in (path.split('\\.').reverse())) {
      closure = invoker.clone().curry(projectionName, closure)
   }
 
   closure
}

Чтобы понять, как это работает, еще раз посмотрим на внутреннее замыкание:

1
2
3
department {
   eq 'name', 'department1'
}

Это будет вызвано как вызов метода для делегата, по сути

1
2
3
delegate.department({
   eq 'name', 'department1'
})

Groovy позволяет нам вызывать методы динамически, используя GString s, так что это то же самое, что

1
2
3
4
5
String methodName = 'department'
 
delegate."$methodName"({
   eq 'name', 'department1'
})

Таким образом, мы можем представить вложенные замыкания как внутреннее замыкание, вызываемое как аргумент замыкания его содержащего замыкания, и вызываемое как аргумент замыкания его содержащего замыкания, и так далее, пока у нас не закончатся уровни.

И мы можем построить замыкание, которое вызывает eq 'name', 'department1' (или любой метод критерия с аргументами, это просто упрощенный пример), так как

1
2
3
def closure = { ->
   delegate."$criterionMethod"(args)
}

Итак, чтобы представить вложенные замыкания, начните с замыкания ‘invoker’:

1
2
3
def invoker = { String projectionName, Closure subcriteria ->
   delegate."$projectionName"(subcriteria)
}

и последовательно клонируем его на каждом уровне вложенности и каррируем, чтобы внедрить имя проекции и его внутреннее замыкание, поскольку построитель критериев не ожидает никаких аргументов замыкания, работая изнутри:

1
2
3
for (String projectionName in (path.split('\\.').reverse())) {
   closure = invoker.clone().curry(projectionName, closure)
}

Итак, наконец, мы можем запустить разложенный запрос как одно или несколько «основных» критериальных замыканий со стандартными вызовами методов критериев плюс ноль или более производных замыканий проекций:

1
2
3
4
5
6
7
def criteria = {
   eq 'name', 'employee1'
}
def projection = buildProjection('manager.department',
                                 'eq', ['name', 'department1'])
 
runCriteria Employee, [criteria, projection], [max: 10, offset: 0]

Честно говоря, я сомневаюсь, что здесь есть много возможностей для повторного использования, но проработка этого помогла мне лучше понять, как GORM выполняет запросы критериев. Я буду говорить об этом и некоторых других темах GORM на Greach в следующем месяце, поэтому, если вам это интересно, обязательно посмотрите запись этого выступления.