From 7313833f34715e1887e69f50cd096ba1d21e51f1 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] 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 {