Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/Collections/Composite.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

final class Composite extends Collection
{
public const string COMPOSITION_MARKER = '_WITH_';

/** @param array<string, list<string>> $compositions */
public function __construct(
string $name,
Expand Down
33 changes: 31 additions & 2 deletions src/EntityFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ class EntityFactory
/** @var array<string, array<string, true>> */
private array $relationCache = [];

/** @var array<string, array<string, string>> */
private array $fieldCache = [];

public function __construct(
public readonly Styles\Stylable $style = new Styles\Standard(),
private readonly string $entityNamespace = '\\',
Expand All @@ -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) {
Expand Down Expand Up @@ -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<string, string> 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
*
Expand Down
151 changes: 0 additions & 151 deletions src/Hydrators/Flat.php

This file was deleted.

130 changes: 130 additions & 0 deletions src/Hydrators/PrestyledAssoc.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<?php

declare(strict_types=1);

namespace Respect\Data\Hydrators;

use DomainException;
use Respect\Data\CollectionIterator;
use Respect\Data\Collections\Collection;
use Respect\Data\Collections\Composite;
use Respect\Data\Collections\Filtered;
use Respect\Data\EntityFactory;
use SplObjectStorage;

use function array_keys;
use function explode;
use function is_array;

/**
* Hydrates associative rows whose keys are pre-styled as `specifier__styledProp`.
*
* This hydrator groups columns by their specifier prefix and
* maps them directly to entities — no reverse iteration, boundary detection,
* or entity stack needed.
*/
final class PrestyledAssoc extends Base
{
/** @var array<string, Collection> */
private array $collMap = [];

private Collection|null $cachedCollection = null;

/** @return SplObjectStorage<object, Collection>|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<string, array<string, mixed>> $grouped */
$grouped = [];
foreach ($raw as $alias => $value) {
[$prefix, $prop] = explode('__', $alias, 2);
$grouped[$prefix][$prop] = $value;
}

/** @var SplObjectStorage<object, Collection> $entities */
$entities = new SplObjectStorage();
/** @var array<string, object> $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<string, Collection> */
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<string, Collection> $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');
}
}
38 changes: 38 additions & 0 deletions tests/EntityFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
}
}
Loading
Loading