Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
Latest
------

### Changes
* [#38](https://github.com/cleverage/doctrine-process-bundle/issues/38) Add missing tests: DatabaseUpdaterTask on a SQLite database (options, params, connection), AbstractDoctrineQueryTask query builder (criteria, order, limit, offset), readers `next()` before execution, bundle and DI extension.

### Fixes
* [#32](https://github.com/cleverage/doctrine-process-bundle/issues/32) Fix EntityManager tasks: use the entity manager given by the `entity_manager` option (it was ignored by every task except ClearEntityManagerTask), the one managing the entity class otherwise. Update documentation, add tests.
* [#33](https://github.com/cleverage/doctrine-process-bundle/issues/33) Fix DatabaseReaderTask and DoctrineReaderTask: execute the query again for each input (the input following a complete iteration was skipped). Update documentation, add tests.
Expand Down
30 changes: 30 additions & 0 deletions tests/CleverAgeDoctrineProcessBundleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

/*
* This file is part of the CleverAge/DoctrineProcessBundle package.
*
* Copyright (c) Clever-Age
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace CleverAge\DoctrineProcessBundle\Tests;

use CleverAge\DoctrineProcessBundle\CleverAgeDoctrineProcessBundle;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;

#[CoversClass(CleverAgeDoctrineProcessBundle::class)]
class CleverAgeDoctrineProcessBundleTest extends TestCase
{
public function testPathIsTheBundleRoot(): void
{
$path = (new CleverAgeDoctrineProcessBundle())->getPath();

self::assertSame(\dirname(__DIR__), $path);
self::assertDirectoryExists($path.'/config/services');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

declare(strict_types=1);

/*
* This file is part of the CleverAge/DoctrineProcessBundle package.
*
* Copyright (c) Clever-Age
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace CleverAge\DoctrineProcessBundle\Tests\DependencyInjection;

use CleverAge\DoctrineProcessBundle\DependencyInjection\CleverAgeDoctrineProcessExtension;
use CleverAge\DoctrineProcessBundle\Task\Database\DatabaseReaderTask;
use CleverAge\DoctrineProcessBundle\Task\Database\DatabaseUpdaterTask;
use CleverAge\DoctrineProcessBundle\Task\EntityManager\ClearEntityManagerTask;
use CleverAge\DoctrineProcessBundle\Task\EntityManager\DoctrineBatchWriterTask;
use CleverAge\DoctrineProcessBundle\Task\EntityManager\DoctrineCleanerTask;
use CleverAge\DoctrineProcessBundle\Task\EntityManager\DoctrineDetacherTask;
use CleverAge\DoctrineProcessBundle\Task\EntityManager\DoctrineReaderTask;
use CleverAge\DoctrineProcessBundle\Task\EntityManager\DoctrineRefresherTask;
use CleverAge\DoctrineProcessBundle\Task\EntityManager\DoctrineRemoverTask;
use CleverAge\DoctrineProcessBundle\Task\EntityManager\DoctrineWriterTask;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\ContainerBuilder;

#[CoversClass(CleverAgeDoctrineProcessExtension::class)]
class CleverAgeDoctrineProcessExtensionTest extends TestCase
{
/**
* @return iterable<string, array{string, class-string}>
*/
public static function provideTasks(): iterable
{
yield 'database_reader' => ['cleverage_doctrine_process.task.database_reader', DatabaseReaderTask::class];
yield 'database_updater' => ['cleverage_doctrine_process.task.database_updater', DatabaseUpdaterTask::class];
yield 'doctrine_clear_entity_manager' => ['cleverage_doctrine_process.task.doctrine_clear_entity_manager', ClearEntityManagerTask::class];
yield 'doctrine_batch_writer' => ['cleverage_doctrine_process.task.doctrine_batch_writer', DoctrineBatchWriterTask::class];
yield 'doctrine_cleaner' => ['cleverage_doctrine_process.task.doctrine_cleaner', DoctrineCleanerTask::class];
yield 'doctrine_detacher' => ['cleverage_doctrine_process.task.doctrine_detacher', DoctrineDetacherTask::class];
yield 'doctrine_reader' => ['cleverage_doctrine_process.task.doctrine_reader', DoctrineReaderTask::class];
yield 'doctrine_refresher' => ['cleverage_doctrine_process.task.doctrine_refresher', DoctrineRefresherTask::class];
yield 'doctrine_remover' => ['cleverage_doctrine_process.task.doctrine_remover', DoctrineRemoverTask::class];
yield 'doctrine_writer' => ['cleverage_doctrine_process.task.doctrine_writer', DoctrineWriterTask::class];
}

/**
* @param class-string $class
*/
#[DataProvider('provideTasks')]
public function testTaskIsRegistered(string $id, string $class): void
{
$container = new ContainerBuilder();
(new CleverAgeDoctrineProcessExtension())->load([], $container);

$definition = $container->getDefinition($id);
self::assertSame($class, $definition->getClass());
// Tasks are stateful: each process execution must get its own instance
self::assertFalse($definition->isShared());
// Logged in the process tasks channel
self::assertSame([['channel' => 'cleverage_process_task']], $definition->getTag('monolog.logger'));

// Referenced as '@<class>' in process configurations
$alias = $container->getAlias($class);
self::assertSame($id, (string) $alias);
self::assertTrue($alias->isPublic());
}

public function testEveryTaskIsTested(): void
{
$container = new ContainerBuilder();
(new CleverAgeDoctrineProcessExtension())->load([], $container);

$ids = array_filter(
array_keys($container->getDefinitions()),
static fn (string $id): bool => str_starts_with($id, 'cleverage_doctrine_process.task.')
);
self::assertEqualsCanonicalizing(array_column(iterator_to_array(self::provideTasks()), 0), array_values($ids));
}
}
24 changes: 21 additions & 3 deletions tests/Task/Database/DatabaseReaderTaskSqliteTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,15 +107,33 @@ public function testArrayParameter(): void
self::assertSame([['title' => 'It'], ['title' => 'Fahrenheit 451']], $this->iterate($task, $state, null));
}

public function testNextBeforeExecute(): void
{
[$task, $state] = $this->createTask(['table' => 'book']);

self::assertFalse($task->next($state));
}

public function testConnectionOption(): void
{
$doctrine = $this->createMock(ManagerRegistry::class);
$doctrine->expects(self::once())->method('getConnection')->with('legacy')->willReturn($this->connection);
[$task, $state] = $this->createTask(['sql' => 'SELECT id FROM book WHERE id = 1', 'connection' => 'legacy'], $doctrine);

self::assertSame([['id' => 1]], $this->iterate($task, $state, null));
}

/**
* @param array<string, mixed> $options
*
* @return array{DatabaseReaderTask, ProcessState}
*/
private function createTask(array $options): array
private function createTask(array $options, ?ManagerRegistry $doctrine = null): array
{
$doctrine = $this->createStub(ManagerRegistry::class);
$doctrine->method('getConnection')->willReturn($this->connection);
if (!$doctrine instanceof ManagerRegistry) {
$doctrine = $this->createStub(ManagerRegistry::class);
$doctrine->method('getConnection')->willReturn($this->connection);
}

$processConfiguration = new ProcessConfiguration('test', []);
$state = new ProcessState($processConfiguration, new ProcessHistory($processConfiguration));
Expand Down
154 changes: 154 additions & 0 deletions tests/Task/Database/DatabaseUpdaterTaskSqliteTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
<?php

declare(strict_types=1);

/*
* This file is part of the CleverAge/DoctrineProcessBundle package.
*
* Copyright (c) Clever-Age
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace CleverAge\DoctrineProcessBundle\Tests\Task\Database;

use CleverAge\DoctrineProcessBundle\Task\Database\DatabaseUpdaterTask;
use CleverAge\ProcessBundle\Configuration\ProcessConfiguration;
use CleverAge\ProcessBundle\Configuration\TaskConfiguration;
use CleverAge\ProcessBundle\Context\ContextualOptionResolver;
use CleverAge\ProcessBundle\Model\ProcessHistory;
use CleverAge\ProcessBundle\Model\ProcessState;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\DriverManager;
use Doctrine\Persistence\ManagerRegistry;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException;
use Symfony\Component\OptionsResolver\Exception\MissingOptionsException;

/**
* DatabaseUpdaterTask on a real (in-memory SQLite) database.
*/
#[CoversClass(DatabaseUpdaterTask::class)]
class DatabaseUpdaterTaskSqliteTest extends TestCase
{
private Connection $connection;

protected function setUp(): void
{
$this->connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
$this->connection->executeStatement('CREATE TABLE book (id INTEGER PRIMARY KEY, title VARCHAR(255), stock INTEGER)');
foreach (['It', 'Salem', 'Fahrenheit 451'] as $i => $title) {
$this->connection->insert('book', ['id' => $i + 1, 'title' => $title, 'stock' => 0]);
}
}

public function testInputAsParamsByDefault(): void
{
[$task, $state] = $this->createTask(['sql' => 'UPDATE book SET stock = :stock WHERE id = :id']);

self::assertSame(1, $this->execute($task, $state, ['stock' => 5, 'id' => 2]));
self::assertSame(1, $this->execute($task, $state, ['stock' => 7, 'id' => 3]));

self::assertSame([0, 5, 7], $this->getStocks());
}

public function testParamsOption(): void
{
[$task, $state] = $this->createTask([
'sql' => 'UPDATE book SET stock = :stock WHERE id IN (:ids)',
'input_as_params' => false,
'params' => ['stock' => 3, 'ids' => [1, 3]],
'types' => ['ids' => ArrayParameterType::INTEGER],
]);

// The input is ignored, the number of affected rows is output
self::assertSame(2, $this->execute($task, $state, ['stock' => 9]));

self::assertSame([3, 0, 3], $this->getStocks());
}

public function testNoAffectedRow(): void
{
[$task, $state] = $this->createTask(['sql' => 'UPDATE book SET stock = 1 WHERE id = :id']);

self::assertSame(0, $this->execute($task, $state, ['id' => 42]));
}

public function testNonArrayInputIsRejected(): void
{
[$task, $state] = $this->createTask(['sql' => 'UPDATE book SET stock = 1']);

$this->expectException(\UnexpectedValueException::class);
$this->expectExceptionMessage('Expecting an array of params');
$this->execute($task, $state, 'not an array');
}

public function testSqlIsRequired(): void
{
$this->expectException(MissingOptionsException::class);
$this->createTask([]);
}

public function testInvalidOptionType(): void
{
$this->expectException(InvalidOptionsException::class);
$this->createTask(['sql' => 'UPDATE book SET stock = 1', 'input_as_params' => 'yes']);
}

public function testConnectionOption(): void
{
$doctrine = $this->createMock(ManagerRegistry::class);
$doctrine->expects(self::once())->method('getConnection')->with('legacy')->willReturn($this->connection);
[$task, $state] = $this->createTask(['sql' => 'UPDATE book SET stock = 1', 'connection' => 'legacy'], $doctrine);

self::assertSame(3, $this->execute($task, $state, []));
}

/**
* @param array<string, mixed> $options
*
* @return array{DatabaseUpdaterTask, ProcessState}
*/
private function createTask(array $options, ?ManagerRegistry $doctrine = null): array
{
if (!$doctrine instanceof ManagerRegistry) {
$doctrine = $this->createStub(ManagerRegistry::class);
$doctrine->method('getConnection')->willReturn($this->connection);
}

$processConfiguration = new ProcessConfiguration('test', []);
$state = new ProcessState($processConfiguration, new ProcessHistory($processConfiguration));
$state->setContextualOptionResolver(new ContextualOptionResolver());
$state->setContext([]);
$state->setTaskConfiguration(new TaskConfiguration('update', DatabaseUpdaterTask::class, $options));

$task = new DatabaseUpdaterTask($doctrine, new NullLogger());
$task->initialize($state);

return [$task, $state];
}

private function execute(DatabaseUpdaterTask $task, ProcessState $state, mixed $input): mixed
{
$state->reset(true);
$state->setInput($input);
$task->execute($state);

return $state->getOutput();
}

/**
* @return list<int>
*/
private function getStocks(): array
{
return array_map(
static fn (mixed $stock): int => is_numeric($stock) ? (int) $stock : -1,
$this->connection->fetchFirstColumn('SELECT stock FROM book ORDER BY id')
);
}
}
51 changes: 51 additions & 0 deletions tests/Task/EntityManager/AbstractDoctrineQueryTaskTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,55 @@ public function testGetQueryBuilderWithInvalidField(): void

$method->invoke($task, $repository, ['e.field; DROP TABLE dummy;' => 'value'], []);
}

public function testGetQueryBuilder(): void
{
$qb = $this->getQueryBuilder(
['title' => 'It', 'deletedAt' => null, 'id' => [1, 3]],
['title' => 'ASC', 'id' => null],
10,
20
);

self::assertMatchesRegularExpression(
'/^SELECT e FROM App\\\\Entity\\\\Book e WHERE e\\.title = :(param_[0-9a-f]{8}) AND e\\.deletedAt IS null AND e\\.id IN \\(:(param_[0-9a-f]{8})\\) ORDER BY e\\.title ASC, e\\.id ASC$/',
$qb->getDQL()
);
$parameters = [];
foreach ($qb->getParameters() as $parameter) {
$parameters[] = $parameter->getValue();
}
// No parameter for the null criteria, ASC by default for a null order
self::assertSame(['It', [1, 3]], $parameters);
self::assertSame(10, $qb->getMaxResults());
self::assertSame(20, $qb->getFirstResult());
}

public function testGetQueryBuilderWithoutCriteria(): void
{
$qb = $this->getQueryBuilder([], []);

self::assertSame('SELECT e FROM App\\Entity\\Book e', $qb->getDQL());
self::assertNull($qb->getMaxResults());
self::assertSame(0, $qb->getFirstResult());
}

/**
* @param array<string, string|array<string|int>|null> $criteria
* @param array<string, string|null> $orderBy
*/
private function getQueryBuilder(array $criteria, array $orderBy, ?int $limit = null, ?int $offset = null): QueryBuilder
{
$task = $this->createStub(AbstractDoctrineQueryTask::class);
$repository = $this->createStub(EntityRepository::class);
$repository->method('createQueryBuilder')->willReturn(
(new QueryBuilder($this->createStub(EntityManagerInterface::class)))->select('e')->from('App\\Entity\\Book', 'e')
);

/** @var QueryBuilder $qb */
$qb = (new \ReflectionMethod(AbstractDoctrineQueryTask::class, 'getQueryBuilder'))
->invoke($task, $repository, $criteria, $orderBy, $limit, $offset);

return $qb;
}
}
9 changes: 9 additions & 0 deletions tests/Task/EntityManager/DoctrineReaderTaskTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,15 @@ protected function getOptions(?ProcessState $state = null): array
$task->execute($state);
}

public function testNextBeforeExecute(): void
{
[$task, $state] = $this->createIteratingTask(static function (): \Generator {
yield (object) ['name' => 'entity1'];
});

self::assertFalse($task->next($state));
}

public function testEntitiesAreHydratedWhileIterating(): void
{
$consumed = 0;
Expand Down
Loading