Спецификации в PHP

в 5:39, , рубрики: DDD, doctrine, Doctrine ORM, php, Программирование, проектирование, Проектирование и рефакторинг, разработка, спецификации
Happyr Doctrine Specification

Кратко о спецификациях:

Спецификация — это шаблон проектирования, с помощью которого можно отразить правила бизнес-логики в виде цепочки объектов, связанных операциями булевой логики. Спецификации позволяют избавится от повторяющихся, однотипных методов в репозитории и от дублирования бизнес-логики.

На сегодня существует два (если знаете другие проекты, напишите пожалуйста в комментариях) успешных и популярных проекта на 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 — эквивалент SQL IS NULL
  • isNotNull — эквивалент SQL IS NOT NULL
  • in — эквивалент IN ()
  • notIn — эквивалент NOT IN ()
  • eq — проверка на равенство =
  • neq — проверка на неравенство !=
  • lt — меньше чем <
  • lte — меньше или равно <=
  • gt — больше чем >
  • gte — больше или равно >=
  • like — эквивалент SQL LIKE
  • instanceOfX — эквивалент DQL INSTANCE 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: я могу на примере разобрать использование спецификаций и показать преимущества и недостатки их использования. Если это вам интересно, напишите в комментариях или в личку.

Автор: Грибанов Петр

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js