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..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; @@ -215,6 +216,42 @@ public function linkExtenders( return $linked; } + /** + * 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 { + $extenders = []; + + foreach ($entityClasses as $entityClass) { + $reflection = new ReflectionClass($entityClass); + $tableAttrs = $reflection->getAttributes(Table::class); + + 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); + } + } + /** * Clear the metadata cache. */ diff --git a/packages/database/tests/Entity/EntityMetadataFactoryTest.php b/packages/database/tests/Entity/EntityMetadataFactoryTest.php index 1c2aa2ee..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,12 +526,42 @@ 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; +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([ + 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([]); +}); - 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 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 {