Кратко о спецификациях:
Спецификация — это шаблон проектирования, с помощью которого можно отразить правила бизнес-логики в виде цепочки объектов, связанных операциями булевой логики. Спецификации позволяют избавится от повторяющихся, однотипных методов в репозитории и от дублирования бизнес-логики.
На сегодня существует два (если знаете другие проекты, напишите пожалуйста в комментариях) успешных и популярных проекта на PHP, позволяющих описывать бизнес-правила в спецификациях и фильтровать наборы данных. Это RulerZ и Happyr Doctrine Specification. Оба проекта являются мощными инструментами со своими преимуществами и недостатками. Сравнение этих проектов потянет на целую статью. Здесь же я хочу рассказать, что нам привнес новый релиз в Doctrine Specification.
Кратко о Doctrine Specification
Те, кто в той или иной степени знакомы с проектом, могут смело пропустить этот раздел.
С помощью этого проекта можно описывать спецификации в виде объектов, составляя из них композицию и, тем самым, составлять сложные бизнес-правила. Полученные композиции можно свободно реиспользовать, и комбинировать в ещё более сложные композиции, которые легко тестировать. Спецификации Doctrine Specification используются для построения запросов Doctrine. По сути, Doctrine Specification — это уровень абстракции над Doctrine ORM QueryBuilder и Doctrine ORM Query.
Спецификации применяются через Doctrine Repository:
$result = $em->getRepository(MyEntity::class)->match($spec);
$spec = ...
$alias = 'e';
$qb = $em->getRepository(MyEntity::class)->createQueryBuilder($alias);
$spec->modify($qb, $alias);
$filter = (string) $spec->getFilter($qb, $alias);
$qb->andWhere($filter);
$result = $qb->getQuery()->execute();
В репозитории есть несколько методов:
match
— получение всех результатов соответствующих спецификации;matchSingleResult
— эквивалентQuery::getSingleResult()
;matchOneOrNullResult
— эквивалентmatchSingleResult
, но разрешает вернутьnull
;getQuery
— создаёт QueryBuilder, применив к нему спецификацию и возвращает объект Query из него.
С недавних пор к ним добавилися метод getQueryBuilder
, который создаёт QueryBuilder и, применив к нему спецификацию, возвращает его.
В проекте выделяются несколько типов спецификаций:
Логические спецификации
Спецификации andX
и orX
так же выполняют роль коллекции спецификаций.
Spec::andX()
Spec::orX()
Spec::not()
Инстациировать объекты библиотечных спецификаций принято через фасад Spec
, но это не обязательно. Можно в явном виде инстациировать объект спецификации:
new AndX();
new OrX():
new Not();
Фильтрующие спецификации
Фильтрующие спецификации, собственно, и составляют правила бизнес-логики и используются в WHERE
запроса. К ним относятся операции сравнения:
isNull
— эквивалент SQLIS NULL
isNotNull
— эквивалент SQLIS NOT NULL
in
— эквивалентIN ()
notIn
— эквивалентNOT IN ()
eq
— проверка на равенство=
neq
— проверка на неравенство!=
lt
— меньше чем<
lte
— меньше или равно<=
gt
— больше чем>
gte
— больше или равно>=
like
— эквивалент SQLLIKE
instanceOfX
— эквивалент DQLINSTANCE OF
Пример использования фильтрующий спецификаций:
$spec = Spec::andX(
Spec::eq('ended', 0),
Spec::orX(
Spec::lt('endDate', new DateTime()),
Spec::andX(
Spec::isNull('endDate'),
Spec::lt('startDate', new DateTime('-4 weeks'))
)
)
);
Модификаторы запроса
Модификаторы запроса не имеют никакого отношения к бизнес-логике и бизнес-правилам. Как и следует из названия, они только изменяют QueryBuilder. Название и назначение предустановленных модификаторов соответствует аналогичным методам в QueryBuilder.
join
leftJoin
innerJoin
limit
offset
orderBy
groupBy
having
Хочу отдельно отметить модификатор slice
. Он объединяет в себе функции limit
и offset
и сам высчитывает offset исходя из размера слайса и его порядкового номера. В реализации этого модификатора мы разошлись во мнениях с автором проекта. Создавая модификатор я преследовал цель упрощения конфигурирования спецификаций при пагинации. В этом контексте первая страница с порядковым номером 1 должна была быть эквивалентна первому слайсу с порядковым номером 1. Но автор проекта посчитал правильным начинать отсчёт в стиле программирования, то есть с 0. Пэтому стоит помнить, что если вам нужен первый слайс, вам необходимо указывать 0 в качестве порядкового номера.
Модификаторы результата
Модификаторы результата существуют немного отдельно от спецификаций. Они применяются к Doctrine Query. Следующие модификаторы управляют гидрацией данных (Query::setHydrationMode()
):
asArray
asSingleScalar
asScalar
Модификатор cache
управляет кэшированием результата запроса.
Отдельно стоит упомянуть модификатор roundDateTimeParams
. Он помогает решить проблемы с кэшированием, когда нужно работать с бизнес-правилами, требующими сравнивать какие-то значения с текущим временем. Это нормальные бизнес-правила, но из-за того, что время не постоянная величина, у вас не будет работать кэширование более чем на одну секунду. Решить эту проблему призван модификатор roundDateTimeParams
. Он проходится по всем параметрам запроса, ищет в них дату и округляет ее до заданного значения в нижнюю сторону, что даёт нам значения даты всегда кратные одному значению и мы не получим дату в будущем. То есть, если мы хотим закэшировать запрос на 10 минут, мы используем Spec::cache(600)
и Spec::roundDateTimeParams(600)
. Изначально предлагалось объединить эти два модификатара ради удобства, но решено было их разделить ради SRP.
Встроенные спецификации
В Happyr Doctrine-Specification для спецификаций выделен отдельный интерфейс который объединяет в себе фильтр и модификатор запроса. Единственная предустановленная спецификация это countOf
позволяющая получить количество сущностей соответствующее спецификации. Для создания собственных спецификаций принято расширять абстрактный класс BaseSpecification
.
Нововведения
В репозиторий добавились новые методы:
matchSingleScalarResult
— эквивалентQuery::getSingleScalarResult()
;matchScalarResult
— эквивалентQuery::getScalarResult()
;iterate
— эквивалентQuery::iterate()
.
Добавлена спецификация MemberOfX
— эквивалент DQL MEMBER OF
и добавлен модификатор запроса indexBy
— эквивалент QueryBuilder::indexBy()
.
Операнды
В новом релизе введено понятие Операнд. Все условия в фильтрах состоят из левого, правого операндов и оператора между ними.
<left_operand> <operator> <right_operand>
В предыдущих версиях левый операнд мог быть только полем сущности, а правый — только значением. Это простой и эффективный механизм которого хватает для большинства задач. В тоже время он накладывает определенные ограничения:
- Невозможно использовать функции;
- Невозможно использовать псевдонимы для полей;
- Невозможно сравнить два поля;
- Невозможно сравнить два значения;
- Невозможно использовать арифметическое операции;
- Невозможно указать тип данных для значения (value).
В новой версии фильтрам в аргументах передаются объекты операнды и трансформация их в DQL делегируется самим операндам. Это открывает много возможностей и делает фильтры более простыми.
Поле и значение
Для сохранения обратной совместимости первый аргумент в фильтрах преобразуется в операнд поля, если не является операндом, и так же последний аргумент преобразуется в операнд значения. Таким образом у вас не должно возникнуть проблем с обновлением.
// DQL: e.day > :day
Spec::gt('day', $day);
// or
Spec::gt(Spec::field('day'), $day);
// or
Spec::gt(Spec::field('day', $dqlAlias), $day);
// DQL: e.day > :day
Spec::gt('day', $day);
// or
Spec::gt('day', Spec::value($day));
// or
Spec::gt('day', Spec::value($day, Type::DATE));
Можно сравнивать 2 поля:
// DQL: e.price_current < e.price_old
Spec::lt(Spec::field('price_current'), Spec::field('price_old'));
Можно сравнить 2 поля разных сущностей:
// DQL: a.email = u.email
Spec::eq(Spec::field('email', 'a'), Spec::field('email', 'u'));
Арифметические операции
Добавлена поддержка стандартных арифметических операций -
, +
, *
, /
, %
. Для примера рассмотрим рассчёт очков пользователя:
// DQL: e.posts_count + e.likes_count > :user_score
Spec::gt(
Spec::add(Spec::field('posts_count'), Spec::field('likes_count')),
$user_score
);
Арифметические операции можно вкладывать одни в другие:
// DQL: ((e.price_old - e.price_current) / (e.price_current / 100)) > :discount
Spec::gt(
Spec::div(
Spec::sub(Spec::field('price_old'), Spec::field('price_current')),
Spec::div(Spec::field('price_current'), Spec::value(100))
),
Spec::value($discount)
);
Функции
В новом релизе добавились операнды с функциями. Их можно использовать как статические методы класса Spec
, так и через метод Spec::fun()
.
// DQL: size(e.products) > 2
Spec::gt(Spec::size('products'), 2);
// or
Spec::gt(Spec::fun('size', 'products'), 2);
// or
Spec::gt(Spec::fun('size', Spec::field('products')), 2);
Функции могут быть вложенным одна в другую:
// DQL: trim(lower(e.email)) = :email
Spec::eq(Spec::trim(Spec::lower('email')), trim(strtolower($email)));
// or
Spec::eq(
Spec::fun('trim', Spec::fun('lower', Spec::field('email'))),
trim(strtolower($email))
);
Аргументы для функций можно передавать как отдельные аргументы, так и передав их в массиве:
// DQL: DATE_DIFF(e.create_at, :date)
Spec::DATE_DIFF('create_at', $date);
// or
Spec::DATE_DIFF(['create_at', $date]);
// or
Spec::fun('DATE_DIFF', 'create_at', $date);
// or
Spec::fun('DATE_DIFF', ['create_at', $date]);
Управление выборкой
Иногда нужно управлять списком возвращаемых значений. Например:
- Добавить в результат ещё одну сущность, чтобы не делать подзапросы для получения связей;
- Возращать не всю сущность, а только набор отдельных полей;
- Использовать псевдонимы;
- Использовать скрытые псевдонимы с условиями для сортировки (так требует Doctrine, но обещают исправить).
До версии 0.8.0 для выполнения этих задач требовалось создавать свои спецификации для этих нужд. Начиная с версии 0.8.0 можно воспользоваться методом getQueryBuilder()
и уже через интерфейс QueryBuilder управлять выборкой.
В новом релизе 1.0.0 добавились модификаторы запроса select
и addSelect
. select
полностью заменяет список выбираемых значений, а addSelect
добавляет к списку новые значения. В качестве значения можно использовать объект реализующий интерфейс Selection
или фильтр. Таким образом можно расширять возможности библиотеки под свои нужды. Рассмотрим возможности, которые есть уже сейчас.
Можно выбрать одно поле:
// DQL: SELECT e.email FROM ...
Spec::select('email')
// or
Spec::select(Spec::field('email'))
Можно добавить одно поле к выборке:
// DQL: SELECT e, u.email FROM ...
Spec::addSelect(Spec::field('email', $dqlAlias))
Можно выбрать несколько полей:
// DQL: SELECT e.title, e.cover, u.name, u.avatar FROM ...
Spec::andX(
Spec::select('title', 'cover'),
Spec::addSelect(Spec::field('name', $dqlAlias), Spec::field('avatar', $dqlAlias))
)
Можно добавить сущность к возвращаемым значениям:
// DQL: SELECT e, u FROM ...
Spec::addSelect(Spec::selectEntity($dqlAlias))
Можно использовать псевдонимы для выбираемых полей:
// DQL: SELECT e.name AS author FROM ...
Spec::select(Spec::selectAs(Spec::field('name'), 'author'))
Можно добавлять скрытые поля в выборку:
// DQL: SELECT e, u.name AS HIDDEN author FROM ...
Spec::addSelect(Spec::selectHiddenAs(Spec::field('email', $dqlAlias), 'author')))
Можно использовать выражения, например для получения скидки на товар:
// DQL: SELECT (e.price_old is not null and e.price_current < e.price_old) AS discount FROM ...
Spec::select(Spec::selectAs(
Spec::andX(
Spec::isNotNull('price_old'),
Spec::lt(Spec::field('price_current'), Spec::field('price_old'))
),
'discount'
))
Можно использовать псевдонимы в спецификациях:
// DQL: SELECT e.price_current AS price FROM ... WHERE price < :low_cost_limit
Spec::andX(
Spec::select(Spec::selectAs('price_current', 'price')),
Spec::lt(Spec::alias('price'), $low_cost_limit)
)
Вот в общем-то и все. На этом нововведения заканчиваются. Новый релиз привнес много интересных и полезных фич. Надеюсь, они вас заинтересовали.
PS: я могу на примере разобрать использование спецификаций и показать преимущества и недостатки их использования. Если это вам интересно, напишите в комментариях или в личку.
Автор: Грибанов Петр