Skip to content

Commit 4055991

Browse files
committed
Replace Flat hydrator with PrestyledAssoc for alias-keyed rows
PrestyledAssoc hydrates associative rows whose keys follow the `specifier__styledProp` convention, grouping by prefix and mapping directly to entities. This eliminates the reverse-iteration, boundary detection, and entity-stack machinery from the old Flat hydrator. Also adds EntityFactory::enumerateFields() for DB-column-to-property mapping, a `styled` flag on set() to skip redundant style conversion, and Composite::COMPOSITION_MARKER to centralize the join-alias prefix.
1 parent bc63e1e commit 4055991

7 files changed

Lines changed: 415 additions & 329 deletions

File tree

src/Collections/Composite.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
final class Composite extends Collection
88
{
9+
public const string COMPOSITION_MARKER = '_WITH_';
10+
911
/** @param array<string, list<string>> $compositions */
1012
public function __construct(
1113
string $name,

src/EntityFactory.php

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ class EntityFactory
3838
/** @var array<string, array<string, true>> */
3939
private array $relationCache = [];
4040

41+
/** @var array<string, array<string, string>> */
42+
private array $fieldCache = [];
43+
4144
public function __construct(
4245
public readonly Styles\Stylable $style = new Styles\Standard(),
4346
private readonly string $entityNamespace = '\\',
@@ -61,9 +64,9 @@ public function resolveClass(string $name): string
6164
return $this->resolveCache[$name] = $entityClass;
6265
}
6366

64-
public function set(object $entity, string $prop, mixed $value): void
67+
public function set(object $entity, string $prop, mixed $value, bool $styled = false): void
6568
{
66-
$styledProp = $this->style->styledProperty($prop);
69+
$styledProp = $styled ? $prop : $this->style->styledProperty($prop);
6770
$mirror = $this->reflectProperties($entity::class)[$styledProp] ?? null;
6871

6972
if ($mirror === null) {
@@ -236,6 +239,32 @@ public function extractProperties(object $entity): array
236239
return $props;
237240
}
238241

242+
/**
243+
* Enumerate persistable fields for a collection, mapping DB column names to styled property names.
244+
*
245+
* @return array<string, string> DB column name → styled property name
246+
*/
247+
public function enumerateFields(string $collectionName): array
248+
{
249+
if (isset($this->fieldCache[$collectionName])) {
250+
return $this->fieldCache[$collectionName];
251+
}
252+
253+
$class = $this->resolveClass($collectionName);
254+
$relations = $this->detectRelationProperties($class);
255+
$fields = [];
256+
257+
foreach ($this->reflectProperties($class) as $name => $prop) {
258+
if ($prop->getAttributes(NotPersistable::class) || isset($relations[$name])) {
259+
continue;
260+
}
261+
262+
$fields[$this->style->realProperty($name)] = $name;
263+
}
264+
265+
return $this->fieldCache[$collectionName] = $fields;
266+
}
267+
239268
/**
240269
* @param class-string $class
241270
*

src/Hydrators/Flat.php

Lines changed: 0 additions & 151 deletions
This file was deleted.

src/Hydrators/PrestyledAssoc.php

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Respect\Data\Hydrators;
6+
7+
use DomainException;
8+
use Respect\Data\CollectionIterator;
9+
use Respect\Data\Collections\Collection;
10+
use Respect\Data\Collections\Composite;
11+
use Respect\Data\Collections\Filtered;
12+
use Respect\Data\EntityFactory;
13+
use SplObjectStorage;
14+
15+
use function array_keys;
16+
use function explode;
17+
use function is_array;
18+
19+
/**
20+
* Hydrates associative rows whose keys are pre-styled as `specifier__styledProp`.
21+
*
22+
* This hydrator groups columns by their specifier prefix and
23+
* maps them directly to entities — no reverse iteration, boundary detection,
24+
* or entity stack needed.
25+
*/
26+
final class PrestyledAssoc extends Base
27+
{
28+
/** @var array<string, Collection> */
29+
private array $collMap = [];
30+
31+
private Collection|null $cachedCollection = null;
32+
33+
/** @return SplObjectStorage<object, Collection>|false */
34+
public function hydrate(
35+
mixed $raw,
36+
Collection $collection,
37+
EntityFactory $entityFactory,
38+
): SplObjectStorage|false {
39+
if (!$raw || !is_array($raw)) {
40+
return false;
41+
}
42+
43+
$collMap = $this->buildCollMap($collection);
44+
45+
/** @var array<string, array<string, mixed>> $grouped */
46+
$grouped = [];
47+
foreach ($raw as $alias => $value) {
48+
[$prefix, $prop] = explode('__', $alias, 2);
49+
$grouped[$prefix][$prop] = $value;
50+
}
51+
52+
/** @var SplObjectStorage<object, Collection> $entities */
53+
$entities = new SplObjectStorage();
54+
/** @var array<string, object> $instances */
55+
$instances = [];
56+
57+
foreach ($grouped as $prefix => $props) {
58+
$basePrefix = $this->resolveCompositionBase($prefix, $collMap);
59+
60+
if (!isset($instances[$basePrefix])) {
61+
$coll = $collMap[$basePrefix];
62+
$class = $this->resolveEntityClass($coll, $entityFactory, $props);
63+
$instances[$basePrefix] = $entityFactory->create($class);
64+
$entities[$instances[$basePrefix]] = $coll;
65+
}
66+
67+
$entity = $instances[$basePrefix];
68+
foreach ($props as $prop => $value) {
69+
$entityFactory->set($entity, $prop, $value, styled: true);
70+
}
71+
}
72+
73+
if ($entities->count() > 1) {
74+
$this->wireRelationships($entities, $entityFactory);
75+
}
76+
77+
return $entities;
78+
}
79+
80+
/** @return array<string, Collection> */
81+
private function buildCollMap(Collection $collection): array
82+
{
83+
if ($this->cachedCollection === $collection) {
84+
return $this->collMap;
85+
}
86+
87+
$this->collMap = [];
88+
foreach (CollectionIterator::recursive($collection) as $spec => $c) {
89+
if ($c->name === null || ($c instanceof Filtered && !$c->filters)) {
90+
continue;
91+
}
92+
93+
$this->collMap[$spec] = $c;
94+
}
95+
96+
$this->cachedCollection = $collection;
97+
98+
return $this->collMap;
99+
}
100+
101+
/**
102+
* Resolve a composition prefix back to its base entity specifier.
103+
*
104+
* Composition columns use prefixes like "post_WITH_comment" (see Composite::COMPOSITION_MARKER).
105+
* This returns "post" so properties are merged into the parent entity.
106+
*
107+
* @param array<string, Collection> $collMap
108+
*/
109+
private function resolveCompositionBase(string $prefix, array $collMap): string
110+
{
111+
if (isset($collMap[$prefix])) {
112+
return $prefix;
113+
}
114+
115+
// Look for a base specifier where this prefix is a composition alias
116+
foreach ($collMap as $spec => $coll) {
117+
if (!$coll instanceof Composite) {
118+
continue;
119+
}
120+
121+
foreach (array_keys($coll->compositions) as $compName) {
122+
if ($prefix === $spec . Composite::COMPOSITION_MARKER . $compName) {
123+
return $spec;
124+
}
125+
}
126+
}
127+
128+
throw new DomainException('Unknown column prefix "' . $prefix . '" in hydration row');
129+
}
130+
}

tests/EntityFactoryTest.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,4 +596,42 @@ public function mergeEntitiesClonesWhenBasePropertyUninitialized(): void
596596
$this->assertSame(1, $merged->id);
597597
$this->assertSame('Bob', $merged->name);
598598
}
599+
600+
#[Test]
601+
public function enumerateFieldsReturnsScalarColumnsOnly(): void
602+
{
603+
$factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\');
604+
$fields = $factory->enumerateFields('post');
605+
606+
$this->assertSame(['id' => 'id', 'title' => 'title', 'text' => 'text'], $fields);
607+
}
608+
609+
#[Test]
610+
public function enumerateFieldsExcludesNotPersistable(): void
611+
{
612+
$factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\');
613+
$fields = $factory->enumerateFields('entity_with_excluded');
614+
615+
$this->assertSame(['name' => 'name'], $fields);
616+
}
617+
618+
#[Test]
619+
public function enumerateFieldsCachesResults(): void
620+
{
621+
$factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\');
622+
$first = $factory->enumerateFields('author');
623+
$second = $factory->enumerateFields('author');
624+
625+
$this->assertSame($first, $second);
626+
}
627+
628+
#[Test]
629+
public function setWithStyledFlagSkipsConversion(): void
630+
{
631+
$factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\');
632+
$entity = $factory->create($factory->resolveClass('author'));
633+
634+
$factory->set($entity, 'name', 'Alice', styled: true);
635+
$this->assertSame('Alice', $factory->get($entity, 'name'));
636+
}
599637
}

0 commit comments

Comments
 (0)