src/Products/NotificationsBundle/Service/ListBuilderService.php line 248

Open in your IDE?
  1. <?php
  2. namespace Products\NotificationsBundle\Service;
  3. use App\Service\Query\ConditionQueryTransformer;
  4. use Cms\CoreBundle\Util\Doctrine\EntityManager;
  5. use Doctrine\ORM\QueryBuilder;
  6. use Products\NotificationsBundle\Entity\AbstractList;
  7. use Products\NotificationsBundle\Entity\AbstractRecipient;
  8. use Products\NotificationsBundle\Entity\Profile;
  9. use Products\NotificationsBundle\Entity\Student;
  10. use Products\NotificationsBundle\Service\ChannelHandlers\AbstractChannelHandler;
  11. use Products\NotificationsBundle\Service\Query\NotificationsConditionQueryBuilder;
  12. use Products\NotificationsBundle\Util\ListBuilder\AbstractListBuilder;
  13. use Products\NotificationsBundle\Util\ListBuilder\ConditionListBuilder;
  14. use Products\NotificationsBundle\Util\ListBuilder\DistrictListBuilder;
  15. use Products\NotificationsBundle\Util\ListBuilder\SchoolListBuilder;
  16. use Products\NotificationsBundle\Util\ListBuilder\StaticListBuilder;
  17. /**
  18.  * Common methods for dealing with AbstractLists and their complexities when it comes to querying against them.
  19.  *
  20.  * The query builders that this service returns follow this general format:
  21.  *
  22.  * SELECT the "root" entity
  23.  * FROM the "root" entity's table
  24.  * LEFT JOIN to get to the associated entity (if recipients, join in profiles; if profiles, join in recipients)
  25.  * WHERE
  26.  *     (
  27.  *         (
  28.  *             "root" entity id is IN (subquery to grab ids based on list 0's configuration)
  29.  *         )
  30.  *         ...
  31.  *         OR
  32.  *         ...
  33.  *         (
  34.  *             "root" entity id is IN (subquery to grab ids based on list N's configuration)
  35.  *         )
  36.  *     )
  37.  *     AND... (any other filters needed at the "top" layer of the query to limit the final results)
  38.  *
  39.  * Basically:
  40.  * 1. There is an "outer" query that wraps the whole thing; this is what is selected against.
  41.  * 2. The outer query will always have the "profiles" and the "recipients" available to it.
  42.  * 3. However, the "root" of the entire query (what entity is actually being selected from the database) changes.
  43.  * 4. There is a grouped (OR'd) set of subqueries that perform the list checks (can pass one or multiple lists; work in the query is the same).
  44.  * 5. The subqueries are done so that the all the entities and the params contained within them are unique and don't affect the "outer" query.
  45.  * 6. Once all the subqueris are done, in the "outer" query after the grouped subqueries, more specific filtering logic can be applied to the results of the "outer" query.
  46.  *
  47.  * In essence, this subgroup of queries determines which IDs of the "root" entity fall within the list's configuration.
  48.  * Then, more work can be done to filter the resultset afterwards.
  49.  */
  50. final class ListBuilderService
  51. {
  52.     const BUILDERS = [
  53.         DistrictListBuilder::class,
  54.         SchoolListBuilder::class,
  55.         ConditionListBuilder::class,
  56.         StaticListBuilder::class,
  57.     ];
  58.     private EntityManager $em;
  59.     private NotificationsConditionQueryBuilder $notificationsConditionQueryBuilder;
  60.     private ConditionQueryTransformer $conditionQueryTransformer;
  61.     /**
  62.      * @param EntityManager $em
  63.      * @param NotificationsConditionQueryBuilder $notificationsConditionQueryBuilder
  64.      * @param ConditionQueryTransformer $conditionQueryTransformer
  65.      */
  66.     public function __construct(EntityManager $emNotificationsConditionQueryBuilder $notificationsConditionQueryBuilderConditionQueryTransformer $conditionQueryTransformer)
  67.     {
  68.         $this->em $em;
  69.         $this->notificationsConditionQueryBuilder $notificationsConditionQueryBuilder;
  70.         $this->conditionQueryTransformer $conditionQueryTransformer;
  71.     }
  72.     /**
  73.      * Returns a query builder that has already been configured to return the results of a list.
  74.      * The query builder can then have more criteria applied to it to limit search results.
  75.      *
  76.      * @param array<AbstractList>|AbstractList $lists
  77.      * @param string|null $entity
  78.      * @return QueryBuilder
  79.      */
  80.     public function search(
  81.         $lists,
  82.         ?string $entity null
  83.     ): QueryBuilder
  84.     {
  85.         $qb $this->build(
  86.             $lists = !is_array($lists) ? [$lists] : $lists,
  87.             $entity $entity ?: $lists[0]->getEntityClass(),
  88.         );
  89.         switch (true) {
  90.             case $entity === Profile::class:
  91.                 $qb
  92.                     ->addSelect(AbstractListBuilder::ENTITIES__PROFILE_CONTACTS)
  93.                     ->addSelect(AbstractListBuilder::ENTITIES__RECIPIENTS);
  94.                 break;
  95.             case $entity === AbstractRecipient::class || in_array($entityAbstractRecipient::DISCRS):
  96.                 $qb
  97.                     ->addSelect(AbstractListBuilder::ENTITIES__PROFILE_CONTACTS)
  98.                     ->addSelect(AbstractListBuilder::ENTITIES__PROFILES);
  99.                 break;
  100.             case $entity === Student::class:
  101.                 $qb
  102.                     ->addSelect(AbstractListBuilder::ENTITIES__PROFILE_RELATIONSHIPS)
  103.                     ->addSelect(AbstractListBuilder::ENTITIES__PROFILES);
  104.                 break;
  105.             default:
  106.                 throw new \Exception();
  107.         }
  108.         return $qb;
  109.     }
  110.     /**
  111.      * Builds out a query for a list that can then be used to get the "sendable" IDs.
  112.      * Secondary classes can be given to return tuples of IDs for extra information in the sending process.
  113.      *
  114.      * @param array<AbstractList>|AbstractList $lists
  115.      * @param string|null $entity
  116.      * @return QueryBuilder
  117.      */
  118.     public function identify(
  119.         $lists,
  120.         ?string $entity null
  121.     ): QueryBuilder
  122.     {
  123.         return ($qb $this->build($lists$entity))
  124.             // reset the select because we are only getting the ids
  125.             // no need for ordering as the ids are only ever used in like a technical sense
  126.             ->resetDQLParts(['select''orderBy'])
  127.             // just get the id of the root alias
  128.             ->select(
  129.                 sprintf(
  130.                     '%s.id AS %s',
  131.                     $qb->getRootAliases()[0],
  132.                     AbstractChannelHandler::ITEMS__TARGET,
  133.                 )
  134.             );
  135.     }
  136.     /**
  137.      * Generates a count query builder that will count the total items in a given list.
  138.      *
  139.      * @param array<AbstractList>|AbstractList $lists
  140.      * @param string|null $entity
  141.      * @return QueryBuilder
  142.      */
  143.     public function count(
  144.         $lists,
  145.         ?string $entity null
  146.     ): QueryBuilder
  147.     {
  148.         return ($qb $this->build($lists$entity))
  149.             // clear what we are overriding
  150.             // clearing ordering as it will speed up the count
  151.             ->resetDQLParts(['select''orderBy'])
  152.             // select only a scalar value, count by the root entity
  153.             ->select(
  154.                 sprintf(
  155.                     'COUNT(DISTINCT %s)',
  156.                     // there should only ever be one root alias
  157.                     $qb->getRootAliases()[0],
  158.                 )
  159.             );
  160.     }
  161.     /**
  162.      * @param array<AbstractList>|AbstractList $lists
  163.      * @param string|null $entity
  164.      * @return QueryBuilder
  165.      */
  166.     public function build(
  167.         $lists,
  168.         ?string $entity null
  169.     ): QueryBuilder
  170.     {
  171.         // if we don't have an array, normalize it
  172.         $lists = !is_array($lists) ? [$lists] : $lists;
  173.         // default entity if not given
  174.         $entity $entity ?: $lists[0]->getEntityClass();
  175.         // create the base query builder, this depends on what type of target entity we want
  176.         switch (true) {
  177.             case $entity === Profile::class:
  178.                 $qb $this->em->getRepository($entity)
  179.                     ->createQueryBuilder(AbstractListBuilder::ENTITIES__PROFILES)
  180.                     ->leftJoin(
  181.                         AbstractListBuilder::ENTITIES__PROFILES.'.contacts',
  182.                         AbstractListBuilder::ENTITIES__PROFILE_CONTACTS,
  183.                     )
  184.                     ->leftJoin(
  185.                         AbstractListBuilder::ENTITIES__PROFILE_CONTACTS.'.recipient',
  186.                         AbstractListBuilder::ENTITIES__RECIPIENTS,
  187.                     );
  188.                 break;
  189.             case $entity === AbstractRecipient::class || in_array($entityAbstractRecipient::DISCRS):
  190.                 $qb $this->em->getRepository($entity)
  191.                     ->createQueryBuilder(AbstractListBuilder::ENTITIES__RECIPIENTS)
  192.                     ->leftJoin(
  193.                         AbstractListBuilder::ENTITIES__RECIPIENTS.'.contacts',
  194.                         AbstractListBuilder::ENTITIES__PROFILE_CONTACTS,
  195.                     )
  196.                     ->leftJoin(
  197.                         AbstractListBuilder::ENTITIES__PROFILE_CONTACTS.'.profile',
  198.                         AbstractListBuilder::ENTITIES__PROFILES,
  199.                     );
  200.                 break;
  201.             // mainly used when doing stuff with invocations...
  202.             case $entity === Student::class:
  203.                 $qb $this->em->getRepository($entity)
  204.                     ->createQueryBuilder(AbstractListBuilder::ENTITIES__STUDENTS)
  205.                     ->leftJoin(
  206.                         AbstractListBuilder::ENTITIES__STUDENTS.'.relationships',
  207.                         AbstractListBuilder::ENTITIES__PROFILE_RELATIONSHIPS,
  208.                     )
  209.                     ->leftJoin(
  210.                         AbstractListBuilder::ENTITIES__PROFILE_RELATIONSHIPS.'.profile',
  211.                         AbstractListBuilder::ENTITIES__PROFILES,
  212.                     );
  213.                 break;
  214.             default:
  215.                 throw new \Exception();
  216.         }
  217.         // if lists are empty, we need to ensure that nothing goes out
  218.         // we should never have an id on the root entity that is 0...
  219.         if (empty($lists)) {
  220.             return $qb->andWhere('0 = 1');
  221.         }
  222.         // NOTE: orm/dbal does not support unions, so have to workaround using subqueries...
  223.         // sets of lists are essentially unioned or "or'd" together
  224.         // meaning the id should match in any of the lists given, not all (that would be an intersect operation)
  225.         // loop over the lists and add in the proper subqueries
  226.         $clauses = [];
  227.         foreach ($lists as $list) {
  228.             // get the builder for the given list
  229.             $builder $this->getBuilder($list);
  230.             // generate a subquery qb using the lists specific builder
  231.             $subquery $builder->subquery();
  232.             // add to list of clauses that we need to eventually group into the query
  233.             $clauses[] = $qb->expr()->in(
  234.                 $qb->getRootAliases()[0].'.id',
  235.                 $subquery->getDQL(),
  236.             );
  237.             // loop through and attach the subqueries parameters to our own
  238.             // this is because the subquery ends up being our query, so the params need carried over
  239.             foreach ($subquery->getParameters() as $parameter) {
  240.                 $qb->setParameter(
  241.                     $parameter->getName(),
  242.                     $parameter->getValue(),
  243.                 );
  244.             }
  245.         }
  246.         // if we have clauses, we need to attach them into the query
  247.         $qb->andWhere(
  248.             $qb->expr()->orX(...$clauses),
  249.         );
  250.         // if we have a recipient entity, we want to filter by a few more things
  251.         // this ensures that recipients are only chosen that are "good" in the system
  252.         if ($entity === AbstractRecipient::class || in_array($entityAbstractRecipient::DISCRS)) {
  253.             $qb
  254.                 // the recipient itself should be active
  255.                 ->andWhere(AbstractListBuilder::ENTITIES__RECIPIENTS.'.active = :active')
  256.                 ->setParameter('active'true)
  257.                 // the assocation between the profile and the recipient should be enabled
  258.                 ->andWhere(AbstractListBuilder::ENTITIES__PROFILE_CONTACTS.'.enabled = :enabled')
  259.                 ->setParameter('enabled'true);
  260.         }
  261.         return $qb;
  262.     }
  263.     /**
  264.      * @param AbstractList $list
  265.      * @return array
  266.      */
  267.     public function serialize(AbstractList $list): array
  268.     {
  269.         return $this->getBuilder($list)->serialize();
  270.     }
  271.     /**
  272.      * @param string $class
  273.      * @param array $serialized
  274.      * @return AbstractList
  275.      */
  276.     public function unserialize(string $class, array $serialized): AbstractList
  277.     {
  278.         return $this->getBuilder($class)->unserialize($serialized);
  279.     }
  280.     /**
  281.      * @param AbstractList|string $list
  282.      * @return AbstractListBuilder
  283.      */
  284.     private function getBuilder($list): AbstractListBuilder
  285.     {
  286.         foreach (self::BUILDERS as $builder) {
  287.             if (call_user_func([$builder'supports'], $list)) {
  288.                 switch ($builder) {
  289.                     case StaticListBuilder::class:
  290.                         return new StaticListBuilder(
  291.                             $this->em,
  292.                             ($list instanceof AbstractList) ? $list null
  293.                         );
  294.                     case SchoolListBuilder::class:
  295.                         return new SchoolListBuilder(
  296.                             $this->em,
  297.                             $this->notificationsConditionQueryBuilder,
  298.                             ($list instanceof AbstractList) ? $list null
  299.                         );
  300.                     case DistrictListBuilder::class:
  301.                         return new DistrictListBuilder(
  302.                             $this->em,
  303.                             $this->notificationsConditionQueryBuilder,
  304.                             ($list instanceof AbstractList) ? $list null
  305.                         );
  306.                     case ConditionListBuilder::class:
  307.                         return new ConditionListBuilder(
  308.                             $this->em,
  309.                             $this->notificationsConditionQueryBuilder,
  310.                             $this->conditionQueryTransformer,
  311.                             ($list instanceof AbstractList) ? $list null
  312.                         );
  313.                 }
  314.             }
  315.         }
  316.         throw new \RuntimeException(
  317.             sprintf(
  318.                 'Could not load builder for "%s".',
  319.                 $list,
  320.             ),
  321.         );
  322.     }
  323. }