GenericDoc: add provider for AccompanyingCourseDocument, without filtering

This commit is contained in:
Julien Fastré 2023-05-24 11:42:30 +02:00
parent afcd6e0605
commit 8dbe2d6ec2
Signed by: julienfastre
GPG Key ID: BDE2190974723FCB
12 changed files with 414 additions and 46 deletions

View File

@ -11,8 +11,15 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle;
use Chill\DocStoreBundle\GenericDoc\ProviderForAccompanyingPeriodInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class ChillDocStoreBundle extends Bundle
{
public function build(ContainerBuilder $container)
{
$container->registerForAutoconfiguration(ProviderForAccompanyingPeriodInterface::class)
->addTag('chill_doc_store.generic_doc_accompanying_period_provider');
}
}

View File

@ -42,6 +42,10 @@ final readonly class GenericDocForAccompanyingPeriodController
$nb = $this->manager->countDocForAccompanyingPeriod($accompanyingPeriod);
foreach ($this->manager->findDocForAccompanyingPeriod($accompanyingPeriod) as $dto) {
dump($dto);
}
return new Response($nb);
}

View File

@ -11,7 +11,7 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\GenericDoc;
use Nelmio\Alice\Throwable\Exception\FixtureBuilder\Denormalizer\UnexpectedValueException;
use Doctrine\DBAL\Types\Types;
class FetchQuery implements FetchQueryInterface
{
@ -21,42 +21,56 @@ class FetchQuery implements FetchQueryInterface
private array $joins = [];
/**
* @var list<mixed>
* @var list<list<mixed>>
*/
private array $joinParams = [];
/**
* @var list<string>
* @var array<list<Types::*>>
*/
private array $joinTypes = [];
/**
* @var array<string>
*/
private array $wheres = [];
/**
* @var list<mixed>
* @var array<list<mixed>>
*/
private array $whereParams = [];
/**
* @var array<list<Types::*>>
*/
private array $whereTypes = [];
public function __construct(
private readonly string $selectKeyString,
private readonly string $selectIdentifierJsonB,
private readonly string $selectDate,
private string $from = '',
private array $selectIdentifierParams = [],
private array $selectIdentifierTypes = [],
private array $selectDateParams = [],
private array $selectDateTypes = [],
) {
}
public function addJoinClause(string $sql, array $params = []): int
public function addJoinClause(string $sql, array $params = [], array $types = []): int
{
$this->joins[] = $sql;
$this->joinParams[] = $params;
$this->joinTypes[] = $types;
return count($this->joins) - 1;
}
public function addWhereClause(string $sql, array $params = []): int
public function addWhereClause(string $sql, array $params = [], array $types = []): int
{
$this->wheres[] = $sql;
$this->whereParams[] = $params;
$this->whereTypes[] = $types;
return count($this->wheres) - 1;
}
@ -67,7 +81,7 @@ class FetchQuery implements FetchQueryInterface
throw new \UnexpectedValueException("this index does not exists");
}
unset($this->wheres[$index], $this->whereParams[$index]);
unset($this->wheres[$index], $this->whereParams[$index], $this->whereTypes[$index]);
}
@ -77,7 +91,7 @@ class FetchQuery implements FetchQueryInterface
throw new \UnexpectedValueException("this index does not exists");
}
unset($this->joins[$index], $this->joinParams[$index]);
unset($this->joins[$index], $this->joinParams[$index], $this->joinTypes[$index]);
}
@ -99,11 +113,21 @@ class FetchQuery implements FetchQueryInterface
return $this->selectIdentifierParams;
}
public function getSelectIdentifiersTypes(): array
{
return $this->selectIdentifierTypes;
}
public function getSelectDate(): string
{
return $this->selectDate;
}
public function getSelectDateTypes(): array
{
return $this->selectDateTypes;
}
/**
* @inheritDoc
*/
@ -131,6 +155,17 @@ class FetchQuery implements FetchQueryInterface
return $result;
}
public function getFromQueryTypes(): array
{
$result = [];
foreach ($this->joinTypes as $types) {
$result = [...$result, ...$types];
}
return $result;
}
public function getWhereQuery(): string
{
return implode(' AND ', $this->wheres);
@ -150,19 +185,49 @@ class FetchQuery implements FetchQueryInterface
return $result;
}
/**
* @param array $selectIdentifierParams
*/
public function setSelectIdentifierParams(array $selectIdentifierParams): void
public function getWhereQueryTypes(): array
{
$this->selectIdentifierParams = $selectIdentifierParams;
$result = [];
foreach ($this->whereTypes as $types) {
$result = [...$result, ...$types];
}
return $result;
}
/**
* @param array $selectDateParams
*/
public function setSelectDateParams(array $selectDateParams): void
public function setSelectIdentifierParams(array $selectIdentifierParams): self
{
$this->selectIdentifierParams = $selectIdentifierParams;
return $this;
}
public function setSelectDateParams(array $selectDateParams): self
{
$this->selectDateParams = $selectDateParams;
return $this;
}
public function setFrom(string $from): self
{
$this->from = $from;
return $this;
}
public function setSelectIdentifierTypes(array $selectIdentifierTypes): self
{
$this->selectIdentifierTypes = $selectIdentifierTypes;
return $this;
}
public function setSelectDateTypes(array $selectDateTypes): self
{
$this->selectDateTypes = $selectDateTypes;
return $this;
}
}

View File

@ -11,6 +11,8 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\GenericDoc;
use Doctrine\DBAL\Types\Types;
interface FetchQueryInterface
{
public function getSelectKeyString(): string;
@ -22,6 +24,11 @@ interface FetchQueryInterface
*/
public function getSelectIdentifierParams(): array;
/**
* @return list<Types::*>
*/
public function getSelectIdentifiersTypes(): array;
public function getSelectDate(): string;
/**
@ -29,6 +36,11 @@ interface FetchQueryInterface
*/
public function getSelectDateParams(): array;
/**
* @return list<Types::*>
*/
public function getSelectDateTypes(): array;
public function getFromQuery(): string;
/**
@ -36,10 +48,20 @@ interface FetchQueryInterface
*/
public function getFromQueryParams(): array;
/**
* @return list<Types::*>
*/
public function getFromQueryTypes(): array;
public function getWhereQuery(): string;
/**
* @return list<mixed>
*/
public function getWhereQueryParams(): array;
/**
* @return list<Types::*>
*/
public function getWhereQueryTypes(): array;
}

View File

@ -11,20 +11,22 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\GenericDoc;
use Doctrine\DBAL\Types\Types;
final readonly class FetchQueryToSqlBuilder
{
private const SQL = <<<'SQL'
SELECT
'{{ key }}' AS key,
{{ identifiers }} AS identifiers,
{{ date }} AS doc_date
{{ date }}::date AS doc_date
FROM {{ from }}
WHERE {{ where }}
{{ where }}
SQL;
/**
* @param FetchQueryInterface $query
* @return array{sql: string, params: list<mixed>}
* @return array{sql: string, params: list<mixed>, types: list<Types::*>}
*/
public function toSql(FetchQueryInterface $query): array
{
@ -33,7 +35,7 @@ final readonly class FetchQueryToSqlBuilder
'{{ identifiers }}' => $query->getSelectIdentifierJsonB(),
'{{ date }}' => $query->getSelectDate(),
'{{ from }}' => $query->getFromQuery(),
'{{ where }}' => $query->getWhereQuery(),
'{{ where }}' => '' === ($w = $query->getWhereQuery()) ? '' : 'WHERE ' . $w,
]);
$params = [
@ -43,6 +45,13 @@ final readonly class FetchQueryToSqlBuilder
...$query->getWhereQueryParams()
];
return ['sql' => $sql, 'params' => $params];
$types = [
...$query->getSelectIdentifiersTypes(),
...$query->getSelectDateTypes(),
...$query->getFromQueryTypes(),
...$query->getWhereQueryTypes(),
];
return ['sql' => $sql, 'params' => $params, 'types' => $types];
}
}

View File

@ -15,6 +15,7 @@ use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception;
use Doctrine\DBAL\Types\Types;
class Manager
{
@ -41,7 +42,12 @@ class Manager
?string $origin = null
): int {
['sql' => $sql, 'params' => $params] = $this->buildUnionQuery($accompanyingPeriod, $startDate, $endDate, $content, $origin);
$countSql = "SELECT count(*) AS c FROM {$sql} AS sq";
if ($sql === '') {
return 0;
}
$countSql = "SELECT count(*) AS c FROM ({$sql}) AS sq";
$result = $this->connection->executeQuery($countSql, $params);
$number = $result->fetchOne();
@ -50,7 +56,7 @@ class Manager
throw new \UnexpectedValueException("number of documents failed to load");
}
return $number['c'];
return $number;
}
/**
@ -65,13 +71,18 @@ class Manager
?string $content = null,
?string $origin = null
): iterable {
['sql' => $sql, 'params' => $params] = $this->buildUnionQuery($accompanyingPeriod, $startDate, $endDate, $content, $origin);
['sql' => $sql, 'params' => $params, 'types' => $types] = $this->buildUnionQuery($accompanyingPeriod, $startDate, $endDate, $content, $origin);
$runSql = "{$sql} LIMIT ? OFFSET ?";
$runParams = [...$params, ...[$limit, $offset]];
$runTypes = [...$types, ...[Types::INTEGER, Types::INTEGER]];
foreach($this->connection->iterateAssociative($runSql, $runParams) as $row) {
yield new GenericDocDTO($row['key'], $row['identifiers'], $row['date_doc']);
foreach($this->connection->iterateAssociative($runSql, $runParams, $runTypes) as $row) {
yield new GenericDocDTO(
$row['key'],
json_decode($row['identifiers'], true, JSON_THROW_ON_ERROR),
new \DateTimeImmutable($row['doc_date'])
);
}
}
@ -86,17 +97,24 @@ class Manager
): array {
$sql = [];
$params = [];
$types = [];
if ($linked instanceof AccompanyingPeriod) {
foreach ($this->providersForAccompanyingPeriod as $provider) {
['sql' => $q, 'params' => $p ] = $this->builder
if (!$provider->isAllowedForAccompanyingPeriod($linked)) {
continue;
}
['sql' => $q, 'params' => $p, 'types' => $t ] = $this->builder
->toSql($provider->buildFetchQueryForAccompanyingPeriod($linked, $startDate, $endDate, $content, $origin));
$params = [...$params, ...$p];
$types = [...$types, ...$t];
$sql[] = $q;
}
}
return ['sql' => implode(' UNION ', $sql), 'params' => $params];
return ['sql' => implode(' UNION ', $sql), 'params' => $params, 'types' => $types];
}
}

View File

@ -28,6 +28,6 @@ interface ProviderForAccompanyingPeriodInterface
*
* @return bool
*/
public function isAllowed(): bool;
public function isAllowedForAccompanyingPeriod(AccompanyingPeriod $accompanyingPeriod): bool;
}

View File

@ -0,0 +1,84 @@
<?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\Providers;
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
use Chill\DocStoreBundle\GenericDoc\FetchQuery;
use Chill\DocStoreBundle\GenericDoc\FetchQueryInterface;
use Chill\DocStoreBundle\GenericDoc\ProviderForAccompanyingPeriodInterface;
use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Security\Core\Security;
final readonly class AccompanyingCourseDocumentProvider implements ProviderForAccompanyingPeriodInterface
{
public function __construct(
private Security $security,
private EntityManagerInterface $entityManager,
) {
}
public function buildFetchQueryForAccompanyingPeriod(AccompanyingPeriod $accompanyingPeriod, ?\DateTimeImmutable $startDate = null, ?\DateTimeImmutable $endDate = null, ?string $content = null, ?string $origin = null): FetchQueryInterface
{
$classMetadata = $this->entityManager->getClassMetadata(AccompanyingCourseDocument::class);
$query = new FetchQuery(
'accompanying_course_document',
sprintf('jsonb_build_object(\'id\', %s)', $classMetadata->getIdentifierColumnNames()[0]),
sprintf($classMetadata->getColumnName('date')),
$classMetadata->getSchemaName() . '.' . $classMetadata->getTableName()
);
$query->addWhereClause(
sprintf('%s = ?', $classMetadata->getSingleAssociationJoinColumnName('course')),
[$accompanyingPeriod->getId()],
[Types::INTEGER]
);
if (null !== $startDate) {
$query->addWhereClause(
sprintf('? >= %s', $classMetadata->getColumnName('date')),
[$startDate],
[Types::DATE_IMMUTABLE]
);
}
if (null !== $endDate) {
$query->addWhereClause(
sprintf('? < %s', $classMetadata->getColumnName('date')),
[$endDate],
[Types::DATE_IMMUTABLE]
);
}
if (null !== $content) {
$query->addWhereClause(
sprintf(
'(%s ilike ? OR %s ilike ?)',
$classMetadata->getColumnName('title'),
$classMetadata->getColumnName('description')
),
[$content, $content],
[Types::STRING, Types::STRING]
);
}
return $query;
}
public function isAllowedForAccompanyingPeriod(AccompanyingPeriod $accompanyingPeriod): bool
{
return $this->security->isGranted(AccompanyingCourseDocumentVoter::SEE, $accompanyingPeriod);
}
}

View File

@ -13,6 +13,7 @@ namespace Chill\DocStoreBundle\Tests\GenericDoc;
use Chill\DocStoreBundle\GenericDoc\FetchQueryToSqlBuilder;
use Chill\DocStoreBundle\GenericDoc\FetchQuery;
use Doctrine\DBAL\Types\Types;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
@ -29,15 +30,15 @@ class FetchQueryToSqlBuilderTest extends KernelTestCase
'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->addJoinClause('LEFT JOIN other b ON a.id = b.foreign_id', ['foo'], [Types::STRING]);
$index = $query->addJoinClause('LEFT JOIN other c ON a.id = c.foreign_id', ['bar'], [Types::STRING]);
$query->addJoinClause('LEFT JOIN other d ON a.id = d.foreign_id', ['bar_baz'], [Types::STRING]);
$query->removeJoinClause($index);
$query->addWhereClause('b.item = ?', ['baz']);
$index = $query->addWhereClause('b.cancel', [ 'foz']);
$query->addWhereClause('b.item = ?', ['baz'], [Types::STRING]);
$index = $query->addWhereClause('b.cancel', [ 'foz'], [Types::STRING]);
$query->removeWhereClause($index);
['sql' => $sql, 'params' => $params] = (new FetchQueryToSqlBuilder())->toSql($query);
['sql' => $sql, 'params' => $params, 'types' => $types] = (new FetchQueryToSqlBuilder())->toSql($query);
$filteredSql =
implode(" ", array_filter(
@ -48,10 +49,41 @@ class FetchQueryToSqlBuilderTest extends KernelTestCase
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 = ?",
"a.datecolumn::date 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);
self::assertEquals([Types::STRING, Types::STRING, Types::STRING], $types);
}
public function testToSqlWithoutWhere(): void
{
$query = new FetchQuery(
'test',
'jsonb_build_object(\'id\', a.column)',
'a.datecolumn',
'my_table a'
);
['sql' => $sql, 'params' => $params, 'types' => $types] = (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::date AS doc_date FROM my_table a",
$filteredSql
);
self::assertEquals([], $params);
self::assertEquals([], $types);
}
}

View File

@ -11,9 +11,15 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Tests\GenericDoc;
use Chill\DocStoreBundle\GenericDoc\FetchQuery;
use Chill\DocStoreBundle\GenericDoc\FetchQueryInterface;
use Chill\DocStoreBundle\GenericDoc\GenericDocDTO;
use Chill\DocStoreBundle\GenericDoc\Manager;
use Chill\DocStoreBundle\GenericDoc\ProviderForAccompanyingPeriodInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
@ -22,21 +28,17 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
*/
class ManagerTest extends KernelTestCase
{
private Manager $manager;
use ProphecyTrait;
private EntityManagerInterface $em;
private Connection $connection;
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");
}
$this->connection = self::$container->get(Connection::class);
}
public function testCountByAccompanyingPeriod(): void
@ -49,8 +51,51 @@ class ManagerTest extends KernelTestCase
throw new \UnexpectedValueException("period not found");
}
$nb = $this->manager->countDocForAccompanyingPeriod($period);
$manager = new Manager(
[new SimpleProvider()],
$this->connection,
);
$nb = $manager->countDocForAccompanyingPeriod($period);
self::assertIsInt($nb);
}
public function testFindDocByAccompanyingPeriod(): void
{
$period = $this->em->createQuery('SELECT a FROM '.AccompanyingPeriod::class.' a')
->setMaxResults(1)
->getSingleResult();
if (null === $period) {
throw new \UnexpectedValueException("period not found");
}
$manager = new Manager(
[new SimpleProvider()],
$this->connection,
);
foreach ($manager->findDocForAccompanyingPeriod($period) as $doc) {
self::assertInstanceOf(GenericDocDTO::class, $doc);
}
}
}
final readonly class SimpleProvider implements ProviderForAccompanyingPeriodInterface
{
public function buildFetchQueryForAccompanyingPeriod(AccompanyingPeriod $accompanyingPeriod, ?\DateTimeImmutable $startDate = null, ?\DateTimeImmutable $endDate = null, ?string $content = null, ?string $origin = null): FetchQueryInterface
{
return new FetchQuery(
'accompanying_course_document',
sprintf('jsonb_build_object(\'id\', %s)', 'id'),
'd',
'(VALUES (1, \'2023-05-01\'::date), (2, \'2023-05-01\'::date)) AS sq (id, d)',
);
}
public function isAllowedForAccompanyingPeriod(AccompanyingPeriod $accompanyingPeriod): bool
{
return true;
}
}

View File

@ -0,0 +1,78 @@
<?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\Providers;
use Chill\DocStoreBundle\GenericDoc\FetchQueryToSqlBuilder;
use Chill\DocStoreBundle\GenericDoc\Providers\AccompanyingCourseDocumentProvider;
use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Security\Core\Security;
/**
* @internal
* @coversNothing
*/
class AccompanyingCourseDocumentProviderTest extends KernelTestCase
{
use ProphecyTrait;
private EntityManagerInterface $entityManager;
public function setUp(): void
{
self::bootKernel();
$this->entityManager = self::$container->get(EntityManagerInterface::class);
}
/**
* @dataProvider provideSearchArguments
*/
public function testWithoutAnyArgument(?\DateTimeImmutable $startDate, ?\DateTimeImmutable $endDate, ?string $content = null): void
{
$period = $this->entityManager->createQuery('SELECT a FROM '.AccompanyingPeriod::class.' a')
->setMaxResults(1)
->getSingleResult();
if (null === $period) {
throw new \UnexpectedValueException("period not found");
}
$security = $this->prophesize(Security::class);
$security->isGranted(AccompanyingCourseDocumentVoter::SEE, $period)
->willReturn(true);
$provider = new AccompanyingCourseDocumentProvider(
$security->reveal(),
$this->entityManager
);
$query = $provider->buildFetchQueryForAccompanyingPeriod($period, $startDate, $endDate, $content);
['sql' => $sql, 'params' => $params, 'types' => $types] = (new FetchQueryToSqlBuilder())->toSql($query);
$this->entityManager->getConnection()->executeQuery($sql, $params, $types);
self::assertTrue(true, "test that no errors occurs");
}
public function provideSearchArguments(): iterable
{
yield [null, null, null];
yield [new \DateTimeImmutable('1 month ago'), null, null];
yield [new \DateTimeImmutable('1 month ago'), new \DateTimeImmutable('now'), null];
yield [null, null, 'test'];
}
}

View File

@ -51,3 +51,7 @@ services:
arguments:
$providersForAccompanyingPeriod: !tagged_iterator chill_doc_store.generic_doc_accompanying_period_provider
Chill\DocStoreBundle\GenericDoc\Providers\:
autowire: true
autoconfigure: true
resource: '../GenericDoc/Providers/'