diff --git a/src/Collections/Composite.php b/src/Collections/Composite.php index 28c0fa7..8f7057c 100644 --- a/src/Collections/Composite.php +++ b/src/Collections/Composite.php @@ -6,6 +6,8 @@ final class Composite extends Collection { + public const string COMPOSITION_MARKER = '_WITH_'; + /** @param array> $compositions */ public function __construct( string $name, diff --git a/src/EntityFactory.php b/src/EntityFactory.php index 88facc8..d9027e4 100644 --- a/src/EntityFactory.php +++ b/src/EntityFactory.php @@ -38,6 +38,9 @@ class EntityFactory /** @var array> */ private array $relationCache = []; + /** @var array> */ + private array $fieldCache = []; + public function __construct( public readonly Styles\Stylable $style = new Styles\Standard(), private readonly string $entityNamespace = '\\', @@ -61,9 +64,9 @@ public function resolveClass(string $name): string return $this->resolveCache[$name] = $entityClass; } - public function set(object $entity, string $prop, mixed $value): void + public function set(object $entity, string $prop, mixed $value, bool $styled = false): void { - $styledProp = $this->style->styledProperty($prop); + $styledProp = $styled ? $prop : $this->style->styledProperty($prop); $mirror = $this->reflectProperties($entity::class)[$styledProp] ?? null; if ($mirror === null) { @@ -236,6 +239,32 @@ public function extractProperties(object $entity): array return $props; } + /** + * Enumerate persistable fields for a collection, mapping DB column names to styled property names. + * + * @return array DB column name → styled property name + */ + public function enumerateFields(string $collectionName): array + { + if (isset($this->fieldCache[$collectionName])) { + return $this->fieldCache[$collectionName]; + } + + $class = $this->resolveClass($collectionName); + $relations = $this->detectRelationProperties($class); + $fields = []; + + foreach ($this->reflectProperties($class) as $name => $prop) { + if ($prop->getAttributes(NotPersistable::class) || isset($relations[$name])) { + continue; + } + + $fields[$this->style->realProperty($name)] = $name; + } + + return $this->fieldCache[$collectionName] = $fields; + } + /** * @param class-string $class * diff --git a/src/Hydrators/Flat.php b/src/Hydrators/Flat.php deleted file mode 100644 index 2b0386e..0000000 --- a/src/Hydrators/Flat.php +++ /dev/null @@ -1,151 +0,0 @@ -|false */ - public function hydrate( - mixed $raw, - Collection $collection, - EntityFactory $entityFactory, - ): SplObjectStorage|false { - if (!$raw || !is_array($raw)) { - return false; - } - - /** @var SplObjectStorage $entities */ - $entities = new SplObjectStorage(); - $entitiesInstances = $this->buildEntitiesInstances($collection, $entities, $entityFactory); - - if (!$entitiesInstances) { - return false; - } - - $entityInstance = array_pop($entitiesInstances); - - foreach (array_reverse($raw, true) as $col => $value) { - $columnName = $this->resolveColumnName($col, $raw); - $primaryName = $entityFactory->style->identifier( - (string) $entities[$entityInstance]->name, - ); - - $entityFactory->set( - /** @phpstan-ignore argument.type (array_pop returns object|null but SplObjectStorage guarantees object key) */ - $entityInstance, - $columnName, - $value, - ); - - if ($primaryName != $columnName && !$this->isEntityBoundary($col, $raw)) { - continue; - } - - $entityInstance = array_pop($entitiesInstances); - } - - $entities = $this->resolveTypedEntities($entities, $entityFactory); - - if ($entities->count() > 1) { - $this->wireRelationships($entities, $entityFactory); - } - - return $entities; - } - - /** Resolve the column name for a given reference (numeric index, namespaced key, etc.) */ - abstract protected function resolveColumnName(mixed $reference, mixed $raw): string; - - /** Check if this column is the last one for the current entity (boundary without identity) */ - protected function isEntityBoundary(mixed $col, mixed $raw): bool - { - return false; - } - - /** - * @param SplObjectStorage $entities - * - * @return SplObjectStorage - */ - private function resolveTypedEntities( - SplObjectStorage $entities, - EntityFactory $entityFactory, - ): SplObjectStorage { - /** @var SplObjectStorage $resolved */ - $resolved = new SplObjectStorage(); - - foreach ($entities as $entity) { - $coll = $entities[$entity]; - - $defaultClass = $entityFactory->resolveClass((string) $coll->name); - $entityClass = $this->resolveEntityClass($coll, $entityFactory, $entity); - - if ($entityClass === $defaultClass) { - $resolved[$entity] = $coll; - - continue; - } - - $typed = $entityFactory->create($entityClass); - foreach ($entityFactory->extractProperties($entity) as $name => $value) { - $entityFactory->set($typed, $name, $value); - } - - $resolved[$typed] = $coll; - } - - return $resolved; - } - - /** - * @param SplObjectStorage $entities - * - * @return array - */ - private function buildEntitiesInstances( - Collection $collection, - SplObjectStorage $entities, - EntityFactory $entityFactory, - ): array { - $entitiesInstances = []; - - foreach (CollectionIterator::recursive($collection) as $c) { - if ($c->name === null || ($c instanceof Filtered && !$c->filters)) { - continue; - } - - $entityInstance = $entityFactory->create($entityFactory->resolveClass($c->name)); - - if ($c instanceof Composite) { - $compositionCount = count($c->compositions); - for ($i = 0; $i < $compositionCount; $i++) { - $entitiesInstances[] = $entityInstance; - } - } - - $entities[$entityInstance] = $c; - $entitiesInstances[] = $entityInstance; - } - - return $entitiesInstances; - } -} diff --git a/src/Hydrators/PrestyledAssoc.php b/src/Hydrators/PrestyledAssoc.php new file mode 100644 index 0000000..9d9c8e9 --- /dev/null +++ b/src/Hydrators/PrestyledAssoc.php @@ -0,0 +1,130 @@ + */ + private array $collMap = []; + + private Collection|null $cachedCollection = null; + + /** @return SplObjectStorage|false */ + public function hydrate( + mixed $raw, + Collection $collection, + EntityFactory $entityFactory, + ): SplObjectStorage|false { + if (!$raw || !is_array($raw)) { + return false; + } + + $collMap = $this->buildCollMap($collection); + + /** @var array> $grouped */ + $grouped = []; + foreach ($raw as $alias => $value) { + [$prefix, $prop] = explode('__', $alias, 2); + $grouped[$prefix][$prop] = $value; + } + + /** @var SplObjectStorage $entities */ + $entities = new SplObjectStorage(); + /** @var array $instances */ + $instances = []; + + foreach ($grouped as $prefix => $props) { + $basePrefix = $this->resolveCompositionBase($prefix, $collMap); + + if (!isset($instances[$basePrefix])) { + $coll = $collMap[$basePrefix]; + $class = $this->resolveEntityClass($coll, $entityFactory, $props); + $instances[$basePrefix] = $entityFactory->create($class); + $entities[$instances[$basePrefix]] = $coll; + } + + $entity = $instances[$basePrefix]; + foreach ($props as $prop => $value) { + $entityFactory->set($entity, $prop, $value, styled: true); + } + } + + if ($entities->count() > 1) { + $this->wireRelationships($entities, $entityFactory); + } + + return $entities; + } + + /** @return array */ + private function buildCollMap(Collection $collection): array + { + if ($this->cachedCollection === $collection) { + return $this->collMap; + } + + $this->collMap = []; + foreach (CollectionIterator::recursive($collection) as $spec => $c) { + if ($c->name === null || ($c instanceof Filtered && !$c->filters)) { + continue; + } + + $this->collMap[$spec] = $c; + } + + $this->cachedCollection = $collection; + + return $this->collMap; + } + + /** + * Resolve a composition prefix back to its base entity specifier. + * + * Composition columns use prefixes like "post_WITH_comment" (see Composite::COMPOSITION_MARKER). + * This returns "post" so properties are merged into the parent entity. + * + * @param array $collMap + */ + private function resolveCompositionBase(string $prefix, array $collMap): string + { + if (isset($collMap[$prefix])) { + return $prefix; + } + + // Look for a base specifier where this prefix is a composition alias + foreach ($collMap as $spec => $coll) { + if (!$coll instanceof Composite) { + continue; + } + + foreach (array_keys($coll->compositions) as $compName) { + if ($prefix === $spec . Composite::COMPOSITION_MARKER . $compName) { + return $spec; + } + } + } + + throw new DomainException('Unknown column prefix "' . $prefix . '" in hydration row'); + } +} diff --git a/tests/EntityFactoryTest.php b/tests/EntityFactoryTest.php index c1a2ac3..aa1b628 100644 --- a/tests/EntityFactoryTest.php +++ b/tests/EntityFactoryTest.php @@ -596,4 +596,42 @@ public function mergeEntitiesClonesWhenBasePropertyUninitialized(): void $this->assertSame(1, $merged->id); $this->assertSame('Bob', $merged->name); } + + #[Test] + public function enumerateFieldsReturnsScalarColumnsOnly(): void + { + $factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\'); + $fields = $factory->enumerateFields('post'); + + $this->assertSame(['id' => 'id', 'title' => 'title', 'text' => 'text'], $fields); + } + + #[Test] + public function enumerateFieldsExcludesNotPersistable(): void + { + $factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\'); + $fields = $factory->enumerateFields('entity_with_excluded'); + + $this->assertSame(['name' => 'name'], $fields); + } + + #[Test] + public function enumerateFieldsCachesResults(): void + { + $factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\'); + $first = $factory->enumerateFields('author'); + $second = $factory->enumerateFields('author'); + + $this->assertSame($first, $second); + } + + #[Test] + public function setWithStyledFlagSkipsConversion(): void + { + $factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\'); + $entity = $factory->create($factory->resolveClass('author')); + + $factory->set($entity, 'name', 'Alice', styled: true); + $this->assertSame('Alice', $factory->get($entity, 'name')); + } } diff --git a/tests/Hydrators/FlatTest.php b/tests/Hydrators/FlatTest.php deleted file mode 100644 index 3e7b024..0000000 --- a/tests/Hydrators/FlatTest.php +++ /dev/null @@ -1,176 +0,0 @@ -factory = new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\'); - } - - #[Test] - public function hydrateReturnsFalseForEmpty(): void - { - $hydrator = $this->hydrator(['id']); - $coll = Collection::author(); - - $this->assertFalse($hydrator->hydrate(null, $coll, $this->factory)); - $this->assertFalse($hydrator->hydrate([], $coll, $this->factory)); - $this->assertFalse($hydrator->hydrate(false, $coll, $this->factory)); - } - - #[Test] - public function hydrateReturnsFalseWhenNoEntitiesBuilt(): void - { - $hydrator = $this->hydrator([]); - $filtered = Filtered::post(); - - $this->assertFalse($hydrator->hydrate([1, 'value'], $filtered, $this->factory)); - } - - #[Test] - public function hydrateSingleEntity(): void - { - $hydrator = $this->hydrator(['id', 'name']); - $collection = Collection::author(); - - $result = $hydrator->hydrate([1, 'Author'], $collection, $this->factory); - - $this->assertNotFalse($result); - $this->assertCount(1, $result); - $result->rewind(); - $entity = $result->current(); - $this->assertEquals(1, $this->factory->get($entity, 'id')); - $this->assertEquals('Author', $this->factory->get($entity, 'name')); - } - - #[Test] - public function hydrateMultipleEntitiesWithPkBoundary(): void - { - $hydrator = $this->hydrator(['id', 'name', 'author_id', 'id', 'title']); - $collection = Collection::author(); - $collection->stack(Collection::post()); - - $result = $hydrator->hydrate([1, 'Author', 1, 10, 'Post Title'], $collection, $this->factory); - - $this->assertNotFalse($result); - $this->assertCount(2, $result); - - $entities = []; - foreach ($result as $entity) { - $entities[] = $entity; - } - - $this->assertEquals(1, $this->factory->get($entities[0], 'id')); - $this->assertEquals('Author', $this->factory->get($entities[0], 'name')); - $this->assertEquals(10, $this->factory->get($entities[1], 'id')); - $this->assertEquals('Post Title', $this->factory->get($entities[1], 'title')); - } - - #[Test] - public function hydrateSkipsWiringForNullPkChild(): void - { - $hydrator = $this->hydrator(['id', 'text', 'post_id', 'id', 'title']); - $collection = Collection::comment(); - $collection->stack(Collection::post()); - - $result = $hydrator->hydrate([1, 'Hello', 5, null, null], $collection, $this->factory); - - $this->assertNotFalse($result); - $result->rewind(); - $entity = $result->current(); - $this->assertEquals(1, $this->factory->get($entity, 'id')); - $this->assertNull($this->factory->get($entity, 'post')); - } - - #[Test] - public function hydrateSkipsUnfilteredFilteredCollections(): void - { - $hydrator = $this->hydrator(['id', 'title']); - $filtered = Filtered::post(); - // No filters set — Filtered without filters is skipped - $collection = Collection::author(); - $collection->stack($filtered); - - $result = $hydrator->hydrate([1, 'Post Title'], $collection, $this->factory); - - $this->assertNotFalse($result); - $this->assertCount(1, $result); - } - - #[Test] - public function hydrateFilteredCollectionWithFilters(): void - { - $hydrator = $this->hydrator(['id', 'name', 'id']); - $filtered = Filtered::author('name'); - $collection = Collection::post(); - $collection->stack($filtered); - - $result = $hydrator->hydrate([1, 'Author', 10], $collection, $this->factory); - - $this->assertNotFalse($result); - $this->assertCount(2, $result); - } - - #[Test] - public function hydrateResolvesTypedEntities(): void - { - $hydrator = $this->hydrator(['id', 'type', 'title']); - $collection = Typed::issue('type'); - - $result = $hydrator->hydrate([1, 'Bug', 'Bug Report'], $collection, $this->factory); - - $this->assertNotFalse($result); - $result->rewind(); - $this->assertInstanceOf(Bug::class, $result->current()); - } - - #[Test] - public function hydrateWithComposite(): void - { - $hydrator = $this->hydrator(['id', 'title', 'id', 'bio']); - $composite = Composite::author(['profile' => ['bio']]); - - $result = $hydrator->hydrate([1, 'Author', 1, 'A bio'], $composite, $this->factory); - - $this->assertNotFalse($result); - $this->assertCount(1, $result); - $result->rewind(); - $entity = $result->current(); - $this->assertEquals('A bio', $this->factory->get($entity, 'bio')); - } - - /** @param list $columnNames */ - private function hydrator(array $columnNames): Flat - { - return new class ($columnNames) extends Flat { - /** @param list $columnNames */ - public function __construct( - private readonly array $columnNames, - ) { - } - - protected function resolveColumnName(mixed $reference, mixed $raw): string - { - /** @phpstan-ignore offsetAccess.invalidOffset */ - return $this->columnNames[$reference]; - } - }; - } -} diff --git a/tests/Hydrators/PrestyledAssocTest.php b/tests/Hydrators/PrestyledAssocTest.php new file mode 100644 index 0000000..cda0014 --- /dev/null +++ b/tests/Hydrators/PrestyledAssocTest.php @@ -0,0 +1,214 @@ +factory = new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\'); + } + + #[Test] + public function hydrateReturnsFalseForEmpty(): void + { + $hydrator = new PrestyledAssoc(); + $coll = Collection::author(); + + $this->assertFalse($hydrator->hydrate(null, $coll, $this->factory)); + $this->assertFalse($hydrator->hydrate([], $coll, $this->factory)); + $this->assertFalse($hydrator->hydrate(false, $coll, $this->factory)); + } + + #[Test] + public function hydrateSingleEntity(): void + { + $hydrator = new PrestyledAssoc(); + $collection = Collection::author(); + + $result = $hydrator->hydrate( + ['author__id' => 1, 'author__name' => 'Alice'], + $collection, + $this->factory, + ); + + $this->assertNotFalse($result); + $this->assertCount(1, $result); + $result->rewind(); + $entity = $result->current(); + $this->assertEquals(1, $this->factory->get($entity, 'id')); + $this->assertEquals('Alice', $this->factory->get($entity, 'name')); + } + + #[Test] + public function hydrateMultipleEntitiesFromJoinedRow(): void + { + $hydrator = new PrestyledAssoc(); + $collection = Collection::author()->post; + + $result = $hydrator->hydrate( + [ + 'author__id' => 1, + 'author__name' => 'Alice', + 'post__id' => 10, + 'post__title' => 'Hello', + 'post__author' => 1, + ], + $collection, + $this->factory, + ); + + $this->assertNotFalse($result); + $this->assertCount(2, $result); + + $entities = []; + foreach ($result as $entity) { + $entities[] = $entity; + } + + $this->assertEquals(1, $this->factory->get($entities[0], 'id')); + $this->assertEquals('Alice', $this->factory->get($entities[0], 'name')); + $this->assertEquals(10, $this->factory->get($entities[1], 'id')); + $this->assertEquals('Hello', $this->factory->get($entities[1], 'title')); + } + + #[Test] + public function hydrateWiresRelationships(): void + { + $hydrator = new PrestyledAssoc(); + $collection = Collection::post()->author; + + $result = $hydrator->hydrate( + [ + 'post__id' => 10, + 'post__title' => 'Hello', + 'post__author' => 1, + 'author__id' => 1, + 'author__name' => 'Alice', + ], + $collection, + $this->factory, + ); + + $this->assertNotFalse($result); + $result->rewind(); + $post = $result->current(); + $author = $this->factory->get($post, 'author'); + $this->assertInstanceOf(Author::class, $author); + $this->assertEquals(1, $this->factory->get($author, 'id')); + } + + #[Test] + public function hydrateResolvesTypedEntities(): void + { + $hydrator = new PrestyledAssoc(); + $collection = Typed::issue('type'); + + $result = $hydrator->hydrate( + ['issue__id' => 1, 'issue__type' => 'Bug', 'issue__title' => 'Bug Report'], + $collection, + $this->factory, + ); + + $this->assertNotFalse($result); + $result->rewind(); + $this->assertInstanceOf(Bug::class, $result->current()); + } + + #[Test] + public function hydrateSkipsUnfilteredFilteredCollections(): void + { + $hydrator = new PrestyledAssoc(); + $filtered = Filtered::post(); + $collection = Collection::author(); + $collection->stack($filtered); + + $result = $hydrator->hydrate( + ['author__id' => 1, 'author__name' => 'Alice'], + $collection, + $this->factory, + ); + + $this->assertNotFalse($result); + $this->assertCount(1, $result); + } + + #[Test] + public function hydrateCompositeEntity(): void + { + $hydrator = new PrestyledAssoc(); + $composite = Composite::author(['profile' => ['bio']])->post; + + $result = $hydrator->hydrate( + [ + 'author__id' => 1, + 'author__name' => 'Alice', + 'author_WITH_profile__bio' => 'A bio', + 'post__id' => 10, + 'post__title' => 'Hello', + ], + $composite, + $this->factory, + ); + + $this->assertNotFalse($result); + $this->assertCount(2, $result); + $result->rewind(); + $entity = $result->current(); + $this->assertEquals(1, $this->factory->get($entity, 'id')); + $this->assertEquals('A bio', $this->factory->get($entity, 'bio')); + } + + #[Test] + public function hydrateCachesCollMapAcrossRows(): void + { + $hydrator = new PrestyledAssoc(); + $collection = Collection::author(); + + $first = $hydrator->hydrate( + ['author__id' => 1, 'author__name' => 'Alice'], + $collection, + $this->factory, + ); + $second = $hydrator->hydrate( + ['author__id' => 2, 'author__name' => 'Bob'], + $collection, + $this->factory, + ); + + $this->assertNotFalse($first); + $this->assertNotFalse($second); + } + + #[Test] + public function hydrateThrowsOnUnknownPrefix(): void + { + $hydrator = new PrestyledAssoc(); + $collection = Collection::author(); + + $this->expectException(DomainException::class); + $this->expectExceptionMessage('Unknown column prefix'); + $hydrator->hydrate( + ['author__id' => 1, 'unknown__foo' => 'bar'], + $collection, + $this->factory, + ); + } +}