$service ]` * * @var TimelineProviderInterface[] */ private array $providers = []; /** * Record provider and their context. * * This array has the structure `[ 'context' => [ 'service id' ] ]` */ private array $providersByContext = []; public function __construct(private readonly EntityManagerInterface $em, private readonly Environment $twig) {} /** * add a provider id. * * @internal This function is called by the TimelineCompilerClass * * @param string $context the context of the service * @param string $id the */ public function addProvider($context, $id, TimelineProviderInterface $provider) { $this->providersByContext[$context][] = $id; $this->providers[$id] = $provider; } /** * Return the number of items for the given context and args. * * @param unknown $context * * @return \Doctrine\DBAL\Driver\Statement|mixed|null */ public function countItems($context, array $args) { $rsm = (new ResultSetMapping()) ->addScalarResult('total', 'total', Types::INTEGER); [$select, $parameters] = $this->buildUnionQuery($context, $args); // embed the union query inside a count query $countQuery = sprintf('SELECT COUNT(sq.id) AS total FROM (%s) as sq', $select); $nq = $this->em->createNativeQuery($countQuery, $rsm); $nq->setParameters($parameters); return $nq->getSingleScalarResult(); } /** * Get providers by context. * * @param string $context * * @return TimelineProviderInterface[] */ public function getProvidersByContext($context) { //throw an exception if no provider have been defined for this context if (!array_key_exists($context, $this->providersByContext)) { throw new LogicException(sprintf('No builders have been defined for "%s"' . ' context', $context)); } $providers = []; foreach ($this->providersByContext[$context] as $providerId) { $providers[] = $this->providers[$providerId]; } return $providers; } /** * return an HTML string with timeline. * * This function must be called from controller * * @example https://redmine.champs-libres.coop/projects/chillperson/repository/revisions/bd2e1b1808f73e39532e9538413025df5487cad0/entry/Controller/TimelinePersonController.php#L47 the implementation in person bundle * * @param string $context * @param array $args arguments defined by the bundle which create the context * @param int $firstItem first item number * @param int $number number of items by page * * @return string an HTML representation, must be included using `|raw` filter */ public function getTimelineHTML($context, array $args, $firstItem = 0, $number = 20) { [$union, $parameters] = $this->buildUnionQuery($context, $args); //add ORDER BY clause and LIMIT $query = $union . sprintf( ' ORDER BY date DESC LIMIT %d OFFSET %d', $number, $firstItem ); // run query and handle results $fetched = $this->runUnionQuery($query, $parameters); $entitiesByKey = $this->getEntities($fetched, $context); return $this->render($fetched, $entitiesByKey, $context, $args); } /** * Hack to replace the arbitrary "AS" statement in DQL * into proper SQL query * TODO remove * private function replaceASInDQL(string $dql): string * { * $pattern = '/^(SELECT\s+[a-zA-Z0-9\_\.\']{1,}\s+)(AS [a-z0-9\_]{1,})(\s{0,},\s{0,}[a-zA-Z0-9\_\.\']{1,}\s+)(AS [a-z0-9\_]{1,})(\s{0,},\s{0,}[a-zA-Z0-9\_\.\']{1,}\s+)(AS [a-z0-9\_]{1,})(\s+FROM.*)/'; * $replacements = '${1} AS id ${3} AS type ${5} AS date ${7}';. * * $s = \preg_replace($pattern, $replacements, $dql, 1); * * if (NULL === $s) { * throw new \RuntimeException('Could not replace the "AS" statement produced by '. * 'DQL with normal SQL AS: '.$dql); * } * * return $s; * } * * @param mixed $data */ /** * return the SQL SELECT query as a string,. * * @return array: first parameter is the sql string, second an array with parameters */ private function buildSelectQuery($data): array { return [$data->buildSql(), $data->getParameters()]; // dead code $parameters = []; $sql = sprintf( 'SELECT %s AS id, ' . '%s AS "date", ' . "'%s' AS type " . 'FROM %s ' . 'WHERE %s', $data['id'], $data['date'], $data['type'], $data['FROM'], is_string($data['WHERE']) ? $data['WHERE'] : $data['WHERE'][0] ); return [$sql, $data['WHERE'][1]]; } /** * build the UNION query with all providers. * * @uses self::buildSelectQuery to build individual SELECT queries * * @throws LogicException if no builder have been defined for this context * * @return array, where first element is the query, the second one an array with the parameters */ private function buildUnionQuery(string $context, array $args): array { //append SELECT queries with UNION keyword between them $union = ''; $parameters = []; foreach ($this->getProvidersByContext($context) as $provider) { $data = $provider->fetchQuery($context, $args); [$select, $selectParameters] = $this->buildSelectQuery($data); $append = empty($union) ? $select : ' UNION ' . $select; $union .= $append; $parameters = array_merge($parameters, $selectParameters); } return [$union, $parameters]; } /** * @param string $context * * @return array with the form array($type => [$entity, $entity, $entity]) */ private function getEntities(array $queriedIds, $context) { //gather entities by type to pass all id with same type to the TimelineProvider. $idsByType = []; foreach ($queriedIds as $result) { $idsByType[$result['type']][] = $result['id']; } //fetch entities from providers $entitiesByType = []; foreach ($idsByType as $type => $ids) { //iterate providers for current context foreach ($this->getProvidersByContext($context) as $provider) { if ($provider->supportsType($type)) { $entitiesByType[$type] = $provider->getEntities($ids); break; //we assume that providers have unique keys => we break the loop } } } return $entitiesByType; } /** * get the template data from the provider for the given entity, by type. * * @param string $type * @param string $context * @param mixed[] $args * @return array the template data fetched from the provider */ private function getTemplateData($type, mixed $entity, $context, array $args) { foreach ($this->getProvidersByContext($context) as $provider) { if ($provider->supportsType($type)) { return $provider->getEntityTemplate($entity, $context, $args); } } } /** * render the timeline as HTML. * * @param string $context * @param mixed[] $args * * @return string the HTML representation of the timeline */ private function render(array $fetched, array $entitiesByType, $context, array $args) { //add results to a pretty array $timelineEntries = []; foreach ($fetched as $result) { $data = $this->getTemplateData( $result['type'], $entitiesByType[$result['type']][$result['id']], //the entity $context, $args ); $timelineEntries[] = [ 'date' => new DateTime($result['date']), 'template' => $data['template'], 'template_data' => $data['template_data'], ]; } return $this->twig ->render('@ChillMain/Timeline/chain_timelines.html.twig', [ 'results' => $timelineEntries, ]); } /** * run the UNION query and return result as an array. * * @return array an array with the results */ private function runUnionQuery(string $query, array $parameters): array { $resultSetMapping = (new ResultSetMapping()) ->addScalarResult('id', 'id') ->addScalarResult('type', 'type') ->addScalarResult('date', 'date'); return $this->em->createNativeQuery($query, $resultSetMapping) ->setParameters($parameters) ->getArrayResult(); } }