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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ Latest
------

## Changes
* [#194](https://github.com/cleverage/ui-process-bundle/issues/194) The tables and columns of the entities have explicit names, those created by the migrations of the bundle: the schema no longer depends on the Doctrine naming strategy of the application (the `underscore_number_aware` strategy is no longer a prerequisite; with the Doctrine default one, the indexes referenced missing columns). Same names with the `underscore` / `underscore_number_aware` strategies, no new migration. Update documentation, add tests.
* [#190](https://github.com/cleverage/ui-process-bundle/issues/190) Add aliases named after their class to `ProcessExecutionManager`, `ProcessConfigurationsManager`, `ProcessExecutionRepository` and `ProcessScheduleRepository` (as the Monolog handlers): they can be autowired. Update documentation, add tests.
* [#192](https://github.com/cleverage/ui-process-bundle/issues/192) The controllers and the HTTP value resolver are defined explicitly, without autowiring nor autoconfiguration (Symfony best practices for bundles, as [cleverage/process-bundle#149](https://github.com/cleverage/process-bundle/issues/149)): same arguments, tags and calls. The `$context` binding of the controllers no longer registers the `AdminContextFactory` service for the `AdminContext $context` arguments of the EasyAdmin actions (never used: EasyAdmin resolves the context first). Add tests.
* [#187](https://github.com/cleverage/ui-process-bundle/issues/187) Composer: require PHP `^8.2` instead of `>=8.2` (PHP 9 is no longer accepted before being tested), remove the authorization of the `symfony/flex` plugin, which is not required (the CI installs it globally), as [cleverage/process-bundle#319](https://github.com/cleverage/process-bundle/issues/319).
Expand Down
11 changes: 2 additions & 9 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,15 +89,8 @@ To configure the security in your application instead (e.g. on a dedicated firew

Supported databases: **MySQL / MariaDB** and **PostgreSQL** (maintained versions).

The entities of the bundle expect the `underscore_number_aware` Doctrine naming strategy (that of the Symfony recipe):
their indexes reference `snake_case` column names (e.g. `start_date`).

```yaml
# config/packages/doctrine.yaml
doctrine:
orm:
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
```
The tables and columns of the entities have explicit names, those created by the migrations of the bundle: they do
not depend on the Doctrine naming strategy of the application.

With Doctrine ORM 2, the lazy ghost objects must be enabled (also set by the Symfony recipe of the DoctrineBundle): the
entities of the bundle have readonly properties, which the legacy proxies of ORM 2 cannot handle. Without it, the
Expand Down
13 changes: 7 additions & 6 deletions src/Entity/LogRecord.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,29 +18,30 @@
use Symfony\Component\String\UnicodeString;

#[ORM\Entity]
#[ORM\Table(name: 'log_record')]
#[ORM\Index(name: 'idx_log_record_level', columns: ['level'])]
#[ORM\Index(name: 'idx_log_record_created_at', columns: ['created_at'])]
class LogRecord
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[ORM\Column(name: 'id')]
private ?int $id = null;

#[ORM\Column(type: Types::STRING, length: 64)]
#[ORM\Column(name: 'channel', type: Types::STRING, length: 64)]
public readonly string $channel;

#[ORM\Column(type: Types::INTEGER)]
#[ORM\Column(name: 'level', type: Types::INTEGER)]
public readonly int $level;

#[ORM\Column(type: Types::STRING, length: 512)]
#[ORM\Column(name: 'message', type: Types::STRING, length: 512)]
public readonly string $message;

/** @var array<mixed> $context */
#[ORM\Column(type: Types::JSON)]
#[ORM\Column(name: 'context', type: Types::JSON)]
public readonly array $context;

#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
#[ORM\Column(name: 'created_at', type: Types::DATETIME_IMMUTABLE)]
public readonly \DateTimeImmutable $createdAt;

public function getId(): ?int
Expand Down
21 changes: 11 additions & 10 deletions src/Entity/ProcessExecution.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,49 +19,50 @@
use Symfony\Component\String\UnicodeString;

#[ORM\Entity]
#[ORM\Table(name: 'process_execution')]
#[ORM\Index(name: 'idx_process_execution_code', columns: ['code'])]
#[ORM\Index(name: 'idx_process_execution_start_date', columns: ['start_date'])]
class ProcessExecution implements \Stringable
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[ORM\Column(name: 'id')]
private ?int $id = null;

#[ORM\Column(type: Types::STRING, length: 255)]
#[ORM\Column(name: 'code', type: Types::STRING, length: 255)]
public readonly string $code;

#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
#[ORM\Column(name: 'start_date', type: Types::DATETIME_IMMUTABLE)]
public readonly \DateTimeImmutable $startDate;

#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
#[ORM\Column(name: 'end_date', type: Types::DATETIME_IMMUTABLE, nullable: true)]
public ?\DateTimeImmutable $endDate = null;

#[ORM\Column(type: Types::STRING, enumType: ProcessExecutionStatus::class)]
#[ORM\Column(name: 'status', type: Types::STRING, enumType: ProcessExecutionStatus::class)]
public ProcessExecutionStatus $status = ProcessExecutionStatus::Started;

/**
* Requested from the UI: the process is stopped before its next task execution.
*/
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
#[ORM\Column(name: 'stop_requested_at', type: Types::DATETIME_IMMUTABLE, nullable: true)]
public ?\DateTimeImmutable $stopRequestedAt = null;

/**
* Updated while the process runs (see ProcessExecutionStopChecker), written with DBAL by the running process.
*/
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
#[ORM\Column(name: 'heartbeat_at', type: Types::DATETIME_IMMUTABLE, nullable: true)]
public ?\DateTimeImmutable $heartbeatAt = null;

/**
* @var array<string, mixed>
*/
#[ORM\Column(type: Types::JSON)]
#[ORM\Column(name: 'report', type: Types::JSON)]
private array $report = [];

/**
* @var array<string|int, mixed>
*/
#[ORM\Column(type: Types::JSON, nullable: true)]
#[ORM\Column(name: 'context', type: Types::JSON, nullable: true)]
private ?array $context;

public function getId(): ?int
Expand All @@ -74,7 +75,7 @@ public function getId(): ?int
*/
public function __construct(
string $code,
#[ORM\Column(type: Types::STRING, length: 255)] public readonly string $logFilename,
#[ORM\Column(name: 'log_filename', type: Types::STRING, length: 255)] public readonly string $logFilename,
?array $context = [],
) {
$this->code = (string) (new UnicodeString($code))->truncate(255);
Expand Down
15 changes: 8 additions & 7 deletions src/Entity/ProcessSchedule.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,21 @@
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity(repositoryClass: ProcessScheduleRepository::class)]
#[ORM\Table(name: 'process_schedule')]
class ProcessSchedule
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[ORM\Column(name: 'id')]
private ?int $id = null;

#[ORM\Column(length: 255)]
#[ORM\Column(name: 'process', length: 255)]
#[IsValidProcessCode]
private string $process;

#[ORM\Column(length: 6)]
#[ORM\Column(name: 'type', length: 6)]
private ProcessScheduleType $type;
#[ORM\Column(length: 255)]
#[ORM\Column(name: 'expression', length: 255)]
#[Assert\When(
expression: 'this.getType().value == "cron"', constraints: [new CronExpression()]
)]
Expand All @@ -45,16 +46,16 @@ class ProcessSchedule
)]
private string $expression;

#[ORM\Column(length: 255, nullable: true)]
#[ORM\Column(name: 'input', length: 255, nullable: true)]
private ?string $input = null;

/**
* @var string|array<string|int, mixed>
*/
#[ORM\Column(type: Types::JSON)]
#[ORM\Column(name: 'context', type: Types::JSON)]
private string|array $context = [];

#[ORM\Column(options: ['default' => true])]
#[ORM\Column(name: 'enabled', options: ['default' => true])]
private bool $enabled = true;

public function getId(): ?int
Expand Down
18 changes: 9 additions & 9 deletions src/Entity/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,34 +25,34 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[ORM\Column(name: 'id')]
private ?int $id = null;

#[ORM\Column(type: Types::STRING, length: 255, unique: true)]
#[ORM\Column(name: 'email', type: Types::STRING, length: 255, unique: true)]
private string $email;

#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
#[ORM\Column(name: 'firstname', type: Types::STRING, length: 255, nullable: true)]
private ?string $firstname = null;

#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
#[ORM\Column(name: 'lastname', type: Types::STRING, length: 255, nullable: true)]
private ?string $lastname = null;

/**
* @var string[]
*/
#[ORM\Column(type: Types::JSON)]
#[ORM\Column(name: 'roles', type: Types::JSON)]
private array $roles = [];

#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
#[ORM\Column(name: 'password', type: Types::STRING, length: 255, nullable: true)]
private ?string $password = null;

#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
#[ORM\Column(name: 'timezone', type: Types::STRING, length: 255, nullable: true)]
private ?string $timezone = null;

#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
#[ORM\Column(name: 'locale', type: Types::STRING, length: 255, nullable: true)]
private ?string $locale = null;

#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
#[ORM\Column(name: 'token', type: Types::STRING, length: 255, nullable: true)]
private ?string $token = null;

public function getId(): ?int
Expand Down
4 changes: 2 additions & 2 deletions tests/App/config/packages.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ doctrine:
url: '%env(resolve:DATABASE_URL)%'
orm:
auto_mapping: true
# As the Symfony recipe: the indexes of the bundle entities use snake_case column names
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
# No naming_strategy: the Doctrine default one, the names of the bundle entities are explicit (the CI validates
# the schema created by the migrations with it)
mappings:
CleverAgeUiProcessBundle:
type: attribute
Expand Down
140 changes: 140 additions & 0 deletions tests/Entity/MappingTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<?php

declare(strict_types=1);

/*
* This file is part of the CleverAge/UiProcessBundle 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\UiProcessBundle\Tests\Entity;

use CleverAge\UiProcessBundle\Entity\LogRecord;
use CleverAge\UiProcessBundle\Entity\ProcessExecution;
use CleverAge\UiProcessBundle\Entity\ProcessSchedule;
use CleverAge\UiProcessBundle\Entity\User;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Mapping\DefaultNamingStrategy;
use Doctrine\ORM\Mapping\Driver\AttributeDriver;
use Doctrine\ORM\Mapping\NamingStrategy;
use Doctrine\ORM\Mapping\UnderscoreNamingStrategy;
use Doctrine\Persistence\Mapping\RuntimeReflectionService;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

/**
* The tables and columns are created by the Doctrine migrations of the bundle: the mapping must not depend on the
* naming strategy of the application.
*/
#[CoversClass(LogRecord::class)]
#[CoversClass(ProcessExecution::class)]
#[CoversClass(ProcessSchedule::class)]
#[CoversClass(User::class)]
class MappingTest extends TestCase
{
/**
* @return iterable<string, array{NamingStrategy}>
*/
public static function provideNamingStrategies(): iterable
{
// Doctrine default (DoctrineBundle default when "naming_strategy" is not configured)
yield 'default' => [new DefaultNamingStrategy()];
// Symfony recipe
yield 'underscore_number_aware' => [self::createUnderscoreNamingStrategy(\CASE_LOWER)];
yield 'underscore_number_aware_upper' => [self::createUnderscoreNamingStrategy(\CASE_UPPER)];
}

#[DataProvider('provideNamingStrategies')]
public function testNamesDoNotDependOnTheNamingStrategy(NamingStrategy $namingStrategy): void
{
$tables = [
LogRecord::class => ['log_record', [
'id' => 'id',
'channel' => 'channel',
'level' => 'level',
'message' => 'message',
'context' => 'context',
'createdAt' => 'created_at',
]],
ProcessExecution::class => ['process_execution', [
'id' => 'id',
'code' => 'code',
'logFilename' => 'log_filename',
'startDate' => 'start_date',
'endDate' => 'end_date',
'status' => 'status',
'stopRequestedAt' => 'stop_requested_at',
'heartbeatAt' => 'heartbeat_at',
'report' => 'report',
'context' => 'context',
]],
ProcessSchedule::class => ['process_schedule', [
'id' => 'id',
'process' => 'process',
'type' => 'type',
'expression' => 'expression',
'input' => 'input',
'context' => 'context',
'enabled' => 'enabled',
]],
User::class => ['process_user', [
'id' => 'id',
'email' => 'email',
'firstname' => 'firstname',
'lastname' => 'lastname',
'roles' => 'roles',
'password' => 'password',
'timezone' => 'timezone',
'locale' => 'locale',
'token' => 'token',
]],
];

foreach ($tables as $class => [$table, $columns]) {
$metadata = $this->loadMetadata($class, $namingStrategy);

self::assertSame($table, $metadata->getTableName(), $class);
$mappedColumns = [];
foreach ($metadata->getFieldNames() as $field) {
$mappedColumns[$field] = $metadata->getColumnName($field);
}
self::assertEqualsCanonicalizing($columns, $mappedColumns, $class);

// The indexes reference existing columns
/** @var array<string, array{columns?: list<string>}> $indexes */
$indexes = $metadata->table['indexes'] ?? [];
foreach ($indexes as $name => $index) {
foreach ($index['columns'] ?? [] as $column) {
self::assertContains($column, $mappedColumns + ['process_execution_id'], \sprintf('%s: index %s', $class, $name));
}
}
}

self::assertSame('process_execution_id', $this->loadMetadata(LogRecord::class, $namingStrategy)->getSingleAssociationJoinColumnName('processExecution'));
}

private static function createUnderscoreNamingStrategy(int $case): UnderscoreNamingStrategy
{
// Doctrine ORM 2: not number aware by default (deprecated); ORM 3: always number aware, single argument
return (new \ReflectionClass(UnderscoreNamingStrategy::class))->newInstance($case, true);
}

/**
* @param class-string $class
*
* @return ClassMetadata<object>
*/
private function loadMetadata(string $class, NamingStrategy $namingStrategy): ClassMetadata
{
$metadata = new ClassMetadata($class, $namingStrategy);
$metadata->initializeReflection(new RuntimeReflectionService());
(new AttributeDriver([\dirname(__DIR__, 2).'/src/Entity'], true))->loadMetadataForClass($class, $metadata);

return $metadata;
}
}
Loading