From 586031b85a554f3919b5d06aa8815a03c849445b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Biarda?= <1135380+michalbiarda@users.noreply.github.com> Date: Thu, 14 May 2026 14:45:04 +0200 Subject: [PATCH 1/2] fix(database): link entity extenders at boot so companions hydrate during HTTP requests EntityMetadataFactory is now a singleton and a boot callback discovers all entity classes and calls linkExtendersFrom() so extender metadata is populated before any repository hydration occurs. Previously linkExtenders() was only called from CLI migration commands, leaving companions unattached at runtime. Closes #73 Co-Authored-By: Claude Sonnet 4.6 --- packages/database/module.php | 17 ++++++++++++ .../src/Entity/EntityMetadataFactory.php | 27 +++++++++++++++++++ .../Entity/EntityMetadataFactoryTest.php | 24 +++++++++++++++++ 3 files changed, 68 insertions(+) diff --git a/packages/database/module.php b/packages/database/module.php index 55671f32..d76ad934 100644 --- a/packages/database/module.php +++ b/packages/database/module.php @@ -5,11 +5,28 @@ use Marko\Core\Container\ContainerInterface; use Marko\Core\Path\ProjectPaths; use Marko\Database\Connection\TransactionInterface; +use Marko\Database\Entity\EntityDiscovery; +use Marko\Database\Entity\EntityMetadataFactory; use Marko\Database\Seed\SeederDiscovery; use Marko\Database\Seed\SeederDiscoveryInterface; use Marko\Database\Seed\SeederRunner; return [ + 'singletons' => [ + EntityMetadataFactory::class, + ], + 'boot' => function ( + EntityDiscovery $discovery, + EntityMetadataFactory $metadataFactory, + ProjectPaths $paths, + ): void { + $entityClasses = array_merge( + $discovery->discoverInVendor($paths->vendor), + $discovery->discoverInModules($paths->modules), + $discovery->discoverInApp($paths->app), + ); + $metadataFactory->linkExtendersFrom($entityClasses); + }, 'bindings' => [ SeederDiscoveryInterface::class => SeederDiscovery::class, SeederRunner::class => function (ContainerInterface $container): SeederRunner { diff --git a/packages/database/src/Entity/EntityMetadataFactory.php b/packages/database/src/Entity/EntityMetadataFactory.php index 5a8cdcb8..33952a77 100644 --- a/packages/database/src/Entity/EntityMetadataFactory.php +++ b/packages/database/src/Entity/EntityMetadataFactory.php @@ -215,6 +215,33 @@ public function linkExtenders( return $linked; } + /** + * Scan a list of entity classes and link any extenders to their parent metadata. + * + * @param array $entityClasses + */ + public function linkExtendersFrom(array $entityClasses): void + { + $extenders = []; + foreach ($entityClasses as $entityClass) { + $reflection = new ReflectionClass($entityClass); + $tableAttrs = $reflection->getAttributes(Table::class); + if (count($tableAttrs) === 0) { + continue; + } + $tableAttr = $tableAttrs[0]->newInstance(); + if ($tableAttr->extends !== null) { + $extenders[$tableAttr->extends][] = $entityClass; + } + } + foreach ($extenders as $parentClass => $extenderClasses) { + if (!class_exists($parentClass, true)) { + throw EntityException::extenderParentClassNotFound($extenderClasses[0], $parentClass); + } + $this->linkExtenders($parentClass, $extenderClasses); + } + } + /** * Clear the metadata cache. */ diff --git a/packages/database/tests/Entity/EntityMetadataFactoryTest.php b/packages/database/tests/Entity/EntityMetadataFactoryTest.php index 1c2aa2ee..1f1a3ff4 100644 --- a/packages/database/tests/Entity/EntityMetadataFactoryTest.php +++ b/packages/database/tests/Entity/EntityMetadataFactoryTest.php @@ -531,6 +531,30 @@ class UntypedPropertyEntity extends Entity ->toThrow(EntityException::class, "Chained extension is not supported. $extender's parent $parent is itself an extender. Extend the root entity directly."); }); +it('linkExtendersFrom scans entity classes and links extenders to their parents', function (): void { + $this->factory->linkExtendersFrom([ + ExtenderParentEntity::class, + BasicExtenderEntity::class, + ]); + + $parentMetadata = $this->factory->parse(ExtenderParentEntity::class); + + expect($parentMetadata->extenders)->toBe([BasicExtenderEntity::class]); +}); + +it('linkExtendersFrom ignores entities without extends', function (): void { + $this->factory->linkExtendersFrom([ExtenderParentEntity::class]); + + $parentMetadata = $this->factory->parse(ExtenderParentEntity::class); + + expect($parentMetadata->extenders)->toBe([]); +}); + +it('linkExtendersFrom throws when an extender references a parent class that cannot be autoloaded', function (): void { + expect(fn () => $this->factory->linkExtendersFrom([ExtenderWithMissingParentEntity::class])) + ->toThrow(EntityException::class, 'does not exist'); +}); + it('clears cached metadata', function (): void { $entity = new #[Table('test')] class () extends Entity { From 52d5f29777f08b7c2321d32f271eb714ad5f7541 Mon Sep 17 00:00:00 2001 From: Mark Shust Date: Sun, 24 May 2026 11:48:06 -0400 Subject: [PATCH 2/2] style(database): polish linkExtendersFrom for codebase conventions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add @throws EntityException|MissingPrimaryKeyException|ReflectionException - Promote ReflectionException import - Use === [] for empty array check - Add blank lines between logical blocks for readability - Apply phpcbf multi-line call formatting to new tests Co-Authored-By: MichaƂ Biarda <1135380+michalbiarda@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/Entity/EntityMetadataFactory.php | 16 +++++-- .../Entity/EntityMetadataFactoryTest.php | 45 +++++++++++-------- 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/packages/database/src/Entity/EntityMetadataFactory.php b/packages/database/src/Entity/EntityMetadataFactory.php index 33952a77..683fd5f2 100644 --- a/packages/database/src/Entity/EntityMetadataFactory.php +++ b/packages/database/src/Entity/EntityMetadataFactory.php @@ -15,6 +15,7 @@ use Marko\Database\Exceptions\EntityException; use Marko\Database\Exceptions\MissingPrimaryKeyException; use ReflectionClass; +use ReflectionException; use ReflectionNamedType; use ReflectionProperty; @@ -219,25 +220,34 @@ public function linkExtenders( * Scan a list of entity classes and link any extenders to their parent metadata. * * @param array $entityClasses + * + * @throws EntityException|MissingPrimaryKeyException|ReflectionException */ - public function linkExtendersFrom(array $entityClasses): void - { + public function linkExtendersFrom( + array $entityClasses, + ): void { $extenders = []; + foreach ($entityClasses as $entityClass) { $reflection = new ReflectionClass($entityClass); $tableAttrs = $reflection->getAttributes(Table::class); - if (count($tableAttrs) === 0) { + + if ($tableAttrs === []) { continue; } + $tableAttr = $tableAttrs[0]->newInstance(); + if ($tableAttr->extends !== null) { $extenders[$tableAttr->extends][] = $entityClass; } } + foreach ($extenders as $parentClass => $extenderClasses) { if (!class_exists($parentClass, true)) { throw EntityException::extenderParentClassNotFound($extenderClasses[0], $parentClass); } + $this->linkExtenders($parentClass, $extenderClasses); } } diff --git a/packages/database/tests/Entity/EntityMetadataFactoryTest.php b/packages/database/tests/Entity/EntityMetadataFactoryTest.php index 1f1a3ff4..304d512e 100644 --- a/packages/database/tests/Entity/EntityMetadataFactoryTest.php +++ b/packages/database/tests/Entity/EntityMetadataFactoryTest.php @@ -403,18 +403,21 @@ class UntypedPropertyEntity extends Entity ->and($metadata->columns[3]->name)->toBe('is_active'); }); -it('throws MissingPrimaryKeyException at metadata parse time when entity has no primary key attribute', function (): void { - $entity = new #[Table('no_pk')] class () extends Entity - { - #[Column] - public int $userId; - - #[Column] - public string $name; - }; +it( + 'throws MissingPrimaryKeyException at metadata parse time when entity has no primary key attribute', + function (): void { + $entity = new #[Table('no_pk')] class () extends Entity + { + #[Column] + public int $userId; + + #[Column] + public string $name; + }; - $this->factory->parse($entity::class); -})->throws(MissingPrimaryKeyException::class); + $this->factory->parse($entity::class); + }, +)->throws(MissingPrimaryKeyException::class); it('includes the entity class name in the exception message', function (): void { $entity = new #[Table('no_pk')] class () extends Entity @@ -523,13 +526,19 @@ class UntypedPropertyEntity extends Entity expect($result->isExtended())->toBeTrue(); }); -it('produces a chained-extension error message that names both the extender and its extender-parent and tells the user to extend the root', function (): void { - $extender = ChainedExtenderEntity::class; - $parent = BasicExtenderEntity::class; - - expect(fn () => $this->factory->parse($extender)) - ->toThrow(EntityException::class, "Chained extension is not supported. $extender's parent $parent is itself an extender. Extend the root entity directly."); -}); +it( + 'produces a chained-extension error message that names both the extender and its extender-parent and tells the user to extend the root', + function (): void { + $extender = ChainedExtenderEntity::class; + $parent = BasicExtenderEntity::class; + + expect(fn () => $this->factory->parse($extender)) + ->toThrow( + EntityException::class, + "Chained extension is not supported. $extender's parent $parent is itself an extender. Extend the root entity directly.", + ); + }, +); it('linkExtendersFrom scans entity classes and links extenders to their parents', function (): void { $this->factory->linkExtendersFrom([