Skip to content
Open
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
3 changes: 2 additions & 1 deletion packages/auth/tests/OAuthTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
use Tempest\Auth\OAuth\OAuthUser;
use Tempest\Container\Container;
use Tempest\Container\GenericContainer;
use Tempest\Mapper\MapperCache;
use Tempest\Mapper\MapperConfig;
use Tempest\Mapper\Mappers\ArrayToObjectMapper;
use Tempest\Mapper\ObjectFactory;
Expand All @@ -37,7 +38,7 @@ final class OAuthTest extends TestCase
}

private ObjectFactory $factory {
get => $this->factory ??= new ObjectFactory(new MapperConfig([ArrayToObjectMapper::class]), $this->container);
get => $this->factory ??= new ObjectFactory(new MapperConfig([ArrayToObjectMapper::class]), $this->container, new MapperCache());
}

#[Before]
Expand Down
5 changes: 5 additions & 0 deletions packages/database/src/Builder/ModelInspector.php
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,11 @@ public function hasPrimaryKey(): bool
}

public function getPrimaryKeyProperty(): ?PropertyReflector
{
return $this->memoize('primary_key_property', $this->resolvePrimaryKeyProperty(...));
}

private function resolvePrimaryKeyProperty(): ?PropertyReflector
{
if (! $this->isObjectModel()) {
return null;
Expand Down
35 changes: 35 additions & 0 deletions packages/mapper/src/MapperCache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace Tempest\Mapper;

use Tempest\Container\Resettable;
use Tempest\Container\Singleton;

/**
* Caches the mapper instances that were resolved for a given mapping context.
*
* Scoped to a container rather than a static. Mappers therefore cannot leak between containers, and
* resetting keeps workers from holding on to mappers built from dependencies that were unregistered.
*/
#[Singleton]
final class MapperCache implements Resettable
{
/** @var array<string, \Tempest\Mapper\Mapper[]> */
private array $mappers = [];

/**
* @param callable(): \Tempest\Mapper\Mapper[] $resolve
* @return \Tempest\Mapper\Mapper[]
*/
public function resolve(Context $context, callable $resolve): array
{
return $this->mappers[$context->name] ??= $resolve();
}

public function reset(): void
{
$this->mappers = [];
}
}
68 changes: 46 additions & 22 deletions packages/mapper/src/Mappers/ArrayToObjectMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -135,45 +135,69 @@ private function resolveObject(mixed $objectOrClass): object

private function setParentRelations(object $parent, ClassReflector $parentClass): void
{
foreach ($parentClass->getPublicProperties() as $property) {
if (! $property->isInitialized($parent)) {
continue;
}
static $plans = [];

if ($property->isVirtual()) {
continue;
}
$key = $parentClass->getName();

$type = $property->getIterableType() ?? $property->getType();
$plans[$key] ??= array_filter(array_map(
function (PropertyReflector $property): ?array {
if ($property->isVirtual()) {
return null;
}

if (! $type->isClass()) {
$type = $property->getIterableType() ?? $property->getType();

if (! $type->isClass()) {
return null;
}

return [$property, $type->asClass()];
},
$parentClass->getPublicProperties(),
));

foreach ($plans[$key] as [$property, $childClass]) {
if (! $property->isInitialized($parent)) {
continue;
}

$child = $property->getValue($parent);

if ($child === null) {
if ($child === null || $child === []) {
continue;
}

$this->setChildParentRelation($parent, $child, $type->asClass());
$this->setChildParentRelation($parent, $child, $childClass);
}
}

private function setChildParentRelation(object $parent, mixed $child, ClassReflector $childClass): void
{
foreach ($childClass->getPublicProperties() as $childProperty) {
if ($childProperty->isVirtual()) {
continue;
}
static $plans = [];

if ($childProperty->getType()->equals($parent::class)) {
$valueToSet = $parent;
} elseif ($childProperty->getIterableType()?->equals($parent::class)) {
$valueToSet = [$parent];
} else {
continue;
}
$key = $childClass->getName() . '|' . $parent::class;

$plans[$key] ??= array_filter(array_map(
function (PropertyReflector $childProperty) use ($parent): ?array {
if ($childProperty->isVirtual()) {
return null;
}

if ($childProperty->getType()->equals($parent::class)) {
return [$childProperty, false];
}

if ($childProperty->getIterableType()?->equals($parent::class)) {
return [$childProperty, true];
}

return null;
},
$childClass->getPublicProperties(),
));

foreach ($plans[$key] as [$childProperty, $wrapInArray]) {
$valueToSet = $wrapInArray ? [$parent] : $parent;

if (is_array($child)) {
foreach ($child as $childItem) {
Expand Down
36 changes: 18 additions & 18 deletions packages/mapper/src/ObjectFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,15 @@ final class ObjectFactory
private Context|UnitEnum|string|null $context = null;

/** @var \Tempest\Mapper\Mapper[] */
private array $mappers;
private array $mappers {
get => $this->resolveMappers();
}

public function __construct(
private readonly MapperConfig $config,
private readonly Container $container,
) {
$this->mappers = $this->resolveMappers();
}
private readonly MapperCache $cache,
) {}

/**
* Sets the target class for mapping operations.
Expand Down Expand Up @@ -112,13 +113,9 @@ public function collection(): self
*/
public function in(Context|UnitEnum|string|null $context): self
{
$clone = clone($this, [
return clone($this, [
'context' => $context,
]);

$clone->mappers = $clone->resolveMappers();

return $clone;
}

/**
Expand Down Expand Up @@ -369,20 +366,23 @@ private function mapWith(mixed $mapper, mixed $from, mixed $to): mixed
}

/**
* We cache mapper instances within the factory so that we prevent mappers being resolved on every mapping call.
* Whenever a mapping context changes, we'll have to re-resolve the mapper classes with the new context.
* Mapper instances are cached per context in {@see \Tempest\Mapper\MapperCache}, so that they are not resolved
* from the container on every mapping call. Whenever a mapping context changes, we'll have to re-resolve the
* mapper classes with the new context.
*/
private function resolveMappers(): array
{
/** @var Mapper[] $mappers */
$mappers = [];

$context = MappingContext::from($this->context);

foreach ($this->config->mappers as $mapperClass) {
$mappers[] = $this->container->get($mapperClass, context: $context);
}
return $this->cache->resolve($context, function () use ($context): array {
/** @var Mapper[] $mappers */
$mappers = [];

foreach ($this->config->mappers as $mapperClass) {
$mappers[] = $this->container->get($mapperClass, context: $context);
}

return $mappers;
return $mappers;
});
}
}
4 changes: 3 additions & 1 deletion packages/reflection/src/ClassReflector.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ public function getInterfaces(): array
/** @return PropertyReflector[] */
public function getPublicProperties(): array
{
return array_map(
static $cache = [];

return $cache[$this->reflectionClass->getName()] ??= array_map(
fn (PHPReflectionProperty $property) => new PropertyReflector($property),
$this->reflectionClass->getProperties(PHPReflectionProperty::IS_PUBLIC),
);
Expand Down
18 changes: 18 additions & 0 deletions packages/reflection/tests/ClassReflectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use ReflectionObject;
use Tempest\Reflection\ClassReflector;
use Tempest\Reflection\PropertyReflector;
use Tempest\Reflection\Tests\Fixtures\ChildWithRecursiveAttribute;
use Tempest\Reflection\Tests\Fixtures\ClassWithInterfaceWithRecursiveAttribute;
use Tempest\Reflection\Tests\Fixtures\RecursiveAttribute;
Expand All @@ -19,6 +21,22 @@
*/
final class ClassReflectorTest extends TestCase
{
#[Test]
public function public_properties_are_specific_to_the_reflected_object(): void
{
$first = new ClassReflector(new ReflectionObject((object) ['first' => 1]));
$second = new ClassReflector(new ReflectionObject((object) ['second' => 2]));

$this->assertSame(['first'], array_map(
fn (PropertyReflector $property) => $property->getName(),
$first->getPublicProperties(),
));
$this->assertSame(['second'], array_map(

Check failure on line 34 in packages/reflection/tests/ClassReflectorTest.php

View workflow job for this annotation

GitHub Actions / Run tests: reflection - PHP 8.5 - prefer-stable

Failed asserting that two arrays are identical.

Check failure on line 34 in packages/reflection/tests/ClassReflectorTest.php

View workflow job for this annotation

GitHub Actions / Run tests: PHP 8.5 - postgres - prefer-stable - ubuntu-latest

Failed asserting that two arrays are identical.

Check failure on line 34 in packages/reflection/tests/ClassReflectorTest.php

View workflow job for this annotation

GitHub Actions / Run tests: PHP 8.5 - sqlite - prefer-lowest - ubuntu-latest

Failed asserting that two arrays are identical.

Check failure on line 34 in packages/reflection/tests/ClassReflectorTest.php

View workflow job for this annotation

GitHub Actions / Run tests: PHP 8.5 - sqlite - prefer-stable - ubuntu-latest

Failed asserting that two arrays are identical.

Check failure on line 34 in packages/reflection/tests/ClassReflectorTest.php

View workflow job for this annotation

GitHub Actions / Run tests: PHP 8.5 - mysql - prefer-stable - ubuntu-latest

Failed asserting that two arrays are identical.

Check failure on line 34 in packages/reflection/tests/ClassReflectorTest.php

View workflow job for this annotation

GitHub Actions / Run tests: reflection - PHP 8.5 - prefer-lowest

Failed asserting that two arrays are identical.

Check failure on line 34 in packages/reflection/tests/ClassReflectorTest.php

View workflow job for this annotation

GitHub Actions / Run tests: PHP 8.5 - sqlite - prefer-stable - windows-latest

Failed asserting that two arrays are identical.
fn (PropertyReflector $property) => $property->getName(),
$second->getPublicProperties(),
));
}

#[Test]
public function getting_underlying_reflection_class(): void
{
Expand Down
22 changes: 22 additions & 0 deletions tests/Integration/Mapper/Fixtures/ContextDialectCaster.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Tests\Tempest\Integration\Mapper\Fixtures;

use Tempest\Database\DatabaseContext;
use Tempest\Discovery\SkipDiscovery;
use Tempest\Mapper\Caster;

#[SkipDiscovery]
final readonly class ContextDialectCaster implements Caster
{
public function __construct(
private DatabaseContext $context,
) {}

public function cast(mixed $input): string
{
return $this->context->dialect->name;
}
}
15 changes: 15 additions & 0 deletions tests/Integration/Mapper/Fixtures/ObjectWithContextDialect.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Tests\Tempest\Integration\Mapper\Fixtures;

use Tempest\Mapper\CastWith;

final readonly class ObjectWithContextDialect
{
public function __construct(
#[CastWith(ContextDialectCaster::class)]
public string $dialect,
) {}
}
91 changes: 91 additions & 0 deletions tests/Integration/Mapper/MapperCacheTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

declare(strict_types=1);

namespace Tests\Tempest\Integration\Mapper;

use PHPUnit\Framework\Attributes\Test;
use ReflectionProperty;
use Tempest\Container\GenericContainer;
use Tempest\Mapper\MapperCache;
use Tempest\Mapper\MapperConfig;
use Tempest\Mapper\Mappers\ArrayToObjectMapper;
use Tempest\Mapper\MappingContext;
use Tempest\Mapper\ObjectFactory;
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;

/**
* @internal
*/
final class MapperCacheTest extends FrameworkIntegrationTestCase
{
#[Test]
public function resolves_mappers_once_per_context(): void
{
$cache = new MapperCache();
$resolved = 0;

$resolve = function () use (&$resolved): array {
$resolved++;

return [$this->container->get(ArrayToObjectMapper::class, context: MappingContext::default())];
};

$first = $cache->resolve(new MappingContext('default'), $resolve);
$second = $cache->resolve(new MappingContext('default'), $resolve);

$this->assertSame(1, $resolved);
$this->assertSame($first, $second);

$cache->resolve(new MappingContext('other'), $resolve);

$this->assertSame(2, $resolved);
}

#[Test]
public function is_reset_between_worker_requests(): void
{
$cache = $this->container->get(MapperCache::class);
$resolved = 0;

$resolve = function () use (&$resolved): array {
$resolved++;

return [$this->container->get(ArrayToObjectMapper::class, context: MappingContext::default())];
};

$cache->resolve(new MappingContext('default'), $resolve);
$cache->resolve(new MappingContext('default'), $resolve);

$this->assertSame(1, $resolved);

$this->container->reset();

// A reset clears the cache without dropping the singleton. The mappers must therefore be
// re-resolved through the same instance.
$this->assertSame($cache, $this->container->get(MapperCache::class));

$cache->resolve(new MappingContext('default'), $resolve);

$this->assertSame(2, $resolved);
}

#[Test]
public function mappers_are_not_shared_between_containers(): void
{
$config = new MapperConfig([ArrayToObjectMapper::class]);

$factory = fn (GenericContainer $container) => new ObjectFactory($config, $container, new MapperCache());

$mappersOf = function (ObjectFactory $factory): array {
$reflection = new ReflectionProperty(ObjectFactory::class, 'mappers');

return $reflection->getValue($factory);
};

$a = $mappersOf($factory(new GenericContainer()));
$b = $mappersOf($factory(new GenericContainer()));

$this->assertNotSame($a[0], $b[0]);
}
}
Loading
Loading