bootstrap generic doc manager and associated services

This commit is contained in:
Julien Fastré 2023-05-23 22:12:18 +02:00
parent 977299192f
commit afcd6e0605
Signed by: julienfastre
GPG Key ID: BDE2190974723FCB
10 changed files with 585 additions and 0 deletions

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Controller;
use Chill\DocStoreBundle\GenericDoc\Manager;
use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
final readonly class GenericDocForAccompanyingPeriodController
{
public function __construct(
private Security $security,
private Manager $manager
) {
}
/**
* @param AccompanyingPeriod $accompanyingPeriod
* @return Response
* @throws \Doctrine\DBAL\Exception
*
* @Route("/{_locale}/doc-store/generic-doc/by-period/{id}/index", name="chill_docstore_generic-doc_by-period_index")
*/
public function list(AccompanyingPeriod $accompanyingPeriod): Response
{
if (!$this->security->isGranted(AccompanyingCourseDocumentVoter::SEE, $accompanyingPeriod)) {
throw new AccessDeniedHttpException("not allowed to see the documents for accompanying period");
}
$nb = $this->manager->countDocForAccompanyingPeriod($accompanyingPeriod);
return new Response($nb);
}
}

View File

@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\GenericDoc;
use Nelmio\Alice\Throwable\Exception\FixtureBuilder\Denormalizer\UnexpectedValueException;
class FetchQuery implements FetchQueryInterface
{
/**
* @var list<string>
*/
private array $joins = [];
/**
* @var list<mixed>
*/
private array $joinParams = [];
/**
* @var list<string>
*/
private array $wheres = [];
/**
* @var list<mixed>
*/
private array $whereParams = [];
public function __construct(
private readonly string $selectKeyString,
private readonly string $selectIdentifierJsonB,
private readonly string $selectDate,
private string $from = '',
private array $selectIdentifierParams = [],
private array $selectDateParams = [],
) {
}
public function addJoinClause(string $sql, array $params = []): int
{
$this->joins[] = $sql;
$this->joinParams[] = $params;
return count($this->joins) - 1;
}
public function addWhereClause(string $sql, array $params = []): int
{
$this->wheres[] = $sql;
$this->whereParams[] = $params;
return count($this->wheres) - 1;
}
public function removeWhereClause(int $index): void
{
if (!array_key_exists($index, $this->wheres)) {
throw new \UnexpectedValueException("this index does not exists");
}
unset($this->wheres[$index], $this->whereParams[$index]);
}
public function removeJoinClause(int $index): void
{
if (!array_key_exists($index, $this->joins)) {
throw new \UnexpectedValueException("this index does not exists");
}
unset($this->joins[$index], $this->joinParams[$index]);
}
public function getSelectKeyString(): string
{
return $this->selectKeyString;
}
public function getSelectIdentifierJsonB(): string
{
return $this->selectIdentifierJsonB;
}
/**
* @inheritDoc
*/
public function getSelectIdentifierParams(): array
{
return $this->selectIdentifierParams;
}
public function getSelectDate(): string
{
return $this->selectDate;
}
/**
* @inheritDoc
*/
public function getSelectDateParams(): array
{
return $this->selectDateParams;
}
public function getFromQuery(): string
{
return $this->from . " " . implode(' ', $this->joins);
}
/**
* @inheritDoc
*/
public function getFromQueryParams(): array
{
$result = [];
foreach ($this->joinParams as $params) {
$result = [...$result, ...$params];
}
return $result;
}
public function getWhereQuery(): string
{
return implode(' AND ', $this->wheres);
}
/**
* @inheritDoc
*/
public function getWhereQueryParams(): array
{
$result = [];
foreach ($this->whereParams as $params) {
$result = [...$result, ...$params];
}
return $result;
}
/**
* @param array $selectIdentifierParams
*/
public function setSelectIdentifierParams(array $selectIdentifierParams): void
{
$this->selectIdentifierParams = $selectIdentifierParams;
}
/**
* @param array $selectDateParams
*/
public function setSelectDateParams(array $selectDateParams): void
{
$this->selectDateParams = $selectDateParams;
}
}

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\GenericDoc;
interface FetchQueryInterface
{
public function getSelectKeyString(): string;
public function getSelectIdentifierJsonB(): string;
/**
* @return list<mixed>
*/
public function getSelectIdentifierParams(): array;
public function getSelectDate(): string;
/**
* @return list<mixed>
*/
public function getSelectDateParams(): array;
public function getFromQuery(): string;
/**
* @return list<mixed>
*/
public function getFromQueryParams(): array;
public function getWhereQuery(): string;
/**
* @return list<mixed>
*/
public function getWhereQueryParams(): array;
}

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\GenericDoc;
final readonly class FetchQueryToSqlBuilder
{
private const SQL = <<<'SQL'
SELECT
'{{ key }}' AS key,
{{ identifiers }} AS identifiers,
{{ date }} AS doc_date
FROM {{ from }}
WHERE {{ where }}
SQL;
/**
* @param FetchQueryInterface $query
* @return array{sql: string, params: list<mixed>}
*/
public function toSql(FetchQueryInterface $query): array
{
$sql = strtr(self::SQL, [
'{{ key }}' => $query->getSelectKeyString(),
'{{ identifiers }}' => $query->getSelectIdentifierJsonB(),
'{{ date }}' => $query->getSelectDate(),
'{{ from }}' => $query->getFromQuery(),
'{{ where }}' => $query->getWhereQuery(),
]);
$params = [
...$query->getSelectIdentifierParams(),
...$query->getSelectDateParams(),
...$query->getFromQueryParams(),
...$query->getWhereQueryParams()
];
return ['sql' => $sql, 'params' => $params];
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\GenericDoc;
class GenericDocDTO
{
public function __construct(
public readonly string $key,
public readonly array $identifiers,
public readonly \DateTimeImmutable $docDate
) {
}
}

View File

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\GenericDoc;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception;
class Manager
{
private readonly FetchQueryToSqlBuilder $builder;
public function __construct(
/**
* @var iterable<ProviderForAccompanyingPeriodInterface>
*/
private readonly iterable $providersForAccompanyingPeriod,
private readonly Connection $connection,
) {
$this->builder = new FetchQueryToSqlBuilder();
}
/**
* @throws Exception
*/
public function countDocForAccompanyingPeriod(
AccompanyingPeriod $accompanyingPeriod,
?\DateTimeImmutable $startDate = null,
?\DateTimeImmutable $endDate = null,
?string $content = null,
?string $origin = null
): int {
['sql' => $sql, 'params' => $params] = $this->buildUnionQuery($accompanyingPeriod, $startDate, $endDate, $content, $origin);
$countSql = "SELECT count(*) AS c FROM {$sql} AS sq";
$result = $this->connection->executeQuery($countSql, $params);
$number = $result->fetchOne();
if (false === $number) {
throw new \UnexpectedValueException("number of documents failed to load");
}
return $number['c'];
}
/**
* @throws Exception
*/
public function findDocForAccompanyingPeriod(
AccompanyingPeriod $accompanyingPeriod,
int $offset = 0,
int $limit = 20,
?\DateTimeImmutable $startDate = null,
?\DateTimeImmutable $endDate = null,
?string $content = null,
?string $origin = null
): iterable {
['sql' => $sql, 'params' => $params] = $this->buildUnionQuery($accompanyingPeriod, $startDate, $endDate, $content, $origin);
$runSql = "{$sql} LIMIT ? OFFSET ?";
$runParams = [...$params, ...[$limit, $offset]];
foreach($this->connection->iterateAssociative($runSql, $runParams) as $row) {
yield new GenericDocDTO($row['key'], $row['identifiers'], $row['date_doc']);
}
}
/**
*/
private function buildUnionQuery(
AccompanyingPeriod|Person $linked,
?\DateTimeImmutable $startDate = null,
?\DateTimeImmutable $endDate = null,
?string $content = null,
?string $origin = null
): array {
$sql = [];
$params = [];
if ($linked instanceof AccompanyingPeriod) {
foreach ($this->providersForAccompanyingPeriod as $provider) {
['sql' => $q, 'params' => $p ] = $this->builder
->toSql($provider->buildFetchQueryForAccompanyingPeriod($linked, $startDate, $endDate, $content, $origin));
$params = [...$params, ...$p];
$sql[] = $q;
}
}
return ['sql' => implode(' UNION ', $sql), 'params' => $params];
}
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\GenericDoc;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
interface ProviderForAccompanyingPeriodInterface
{
public function buildFetchQueryForAccompanyingPeriod(
AccompanyingPeriod $accompanyingPeriod,
?\DateTimeImmutable $startDate = null,
?\DateTimeImmutable $endDate = null,
?string $content = null,
?string $origin = null
): FetchQueryInterface;
/**
* Return true if the user is allowed to see some documents for this provider.
*
* @return bool
*/
public function isAllowed(): bool;
}

View File

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\GenericDoc;
use Chill\DocStoreBundle\GenericDoc\FetchQueryToSqlBuilder;
use Chill\DocStoreBundle\GenericDoc\FetchQuery;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
* @coversNothing
*/
class FetchQueryToSqlBuilderTest extends KernelTestCase
{
public function testToSql(): void
{
$query = new FetchQuery(
'test',
'jsonb_build_object(\'id\', a.column)',
'a.datecolumn',
'my_table a'
);
$query->addJoinClause('LEFT JOIN other b ON a.id = b.foreign_id', ['foo']);
$index = $query->addJoinClause('LEFT JOIN other c ON a.id = c.foreign_id', ['bar']);
$query->addJoinClause('LEFT JOIN other d ON a.id = d.foreign_id', ['bar_baz']);
$query->removeJoinClause($index);
$query->addWhereClause('b.item = ?', ['baz']);
$index = $query->addWhereClause('b.cancel', [ 'foz']);
$query->removeWhereClause($index);
['sql' => $sql, 'params' => $params] = (new FetchQueryToSqlBuilder())->toSql($query);
$filteredSql =
implode(" ", array_filter(
explode(" ", str_replace("\n", "", $sql)),
fn (string $tok) => $tok !== ""
))
;
self::assertEquals(
"SELECT 'test' AS key, jsonb_build_object('id', a.column) AS identifiers, ".
"a.datecolumn AS doc_date FROM my_table a LEFT JOIN other b ON a.id = b.foreign_id LEFT JOIN other d ON a.id = d.foreign_id WHERE b.item = ?",
$filteredSql
);
self::assertEquals(['foo', 'bar_baz', 'baz'], $params);
}
}

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\GenericDoc;
use Chill\DocStoreBundle\GenericDoc\Manager;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
* @coversNothing
*/
class ManagerTest extends KernelTestCase
{
private Manager $manager;
private EntityManagerInterface $em;
protected function setUp(): void
{
self::bootKernel();
$this->em = self::$container->get(EntityManagerInterface::class);
if (null !== $manager = self::$container->get(Manager::class)) {
$this->manager = $manager;
} else {
throw new \UnexpectedValueException("the manager was not found in the kernel");
}
}
public function testCountByAccompanyingPeriod(): void
{
$period = $this->em->createQuery('SELECT a FROM '.AccompanyingPeriod::class.' a')
->setMaxResults(1)
->getSingleResult();
if (null === $period) {
throw new \UnexpectedValueException("period not found");
}
$nb = $this->manager->countDocForAccompanyingPeriod($period);
self::assertIsInt($nb);
}
}

View File

@ -45,3 +45,9 @@ services:
autoconfigure: true
resource: '../Service/'
Chill\DocStoreBundle\GenericDoc\Manager:
autowire: true
autoconfigure: true
arguments:
$providersForAccompanyingPeriod: !tagged_iterator chill_doc_store.generic_doc_accompanying_period_provider