diff --git a/CHANGELOG.md b/CHANGELOG.md index c8ba386..2ab3a6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/docs/index.md b/docs/index.md index 3b2bc05..d341cc0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 diff --git a/src/Entity/LogRecord.php b/src/Entity/LogRecord.php index 0efd67b..64c3d78 100644 --- a/src/Entity/LogRecord.php +++ b/src/Entity/LogRecord.php @@ -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 $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 diff --git a/src/Entity/ProcessExecution.php b/src/Entity/ProcessExecution.php index f6cc83c..8653d2c 100644 --- a/src/Entity/ProcessExecution.php +++ b/src/Entity/ProcessExecution.php @@ -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 */ - #[ORM\Column(type: Types::JSON)] + #[ORM\Column(name: 'report', type: Types::JSON)] private array $report = []; /** * @var array */ - #[ORM\Column(type: Types::JSON, nullable: true)] + #[ORM\Column(name: 'context', type: Types::JSON, nullable: true)] private ?array $context; public function getId(): ?int @@ -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); diff --git a/src/Entity/ProcessSchedule.php b/src/Entity/ProcessSchedule.php index 44fd049..1c0ca83 100644 --- a/src/Entity/ProcessSchedule.php +++ b/src/Entity/ProcessSchedule.php @@ -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()] )] @@ -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 */ - #[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 diff --git a/src/Entity/User.php b/src/Entity/User.php index 9eee0ae..0c3fcdc 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -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 diff --git a/tests/App/config/packages.yaml b/tests/App/config/packages.yaml index a57c681..a5c95b2 100644 --- a/tests/App/config/packages.yaml +++ b/tests/App/config/packages.yaml @@ -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 diff --git a/tests/Entity/MappingTest.php b/tests/Entity/MappingTest.php new file mode 100644 index 0000000..076fcc5 --- /dev/null +++ b/tests/Entity/MappingTest.php @@ -0,0 +1,140 @@ + + */ + 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}> $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 + */ + 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; + } +}