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 .roave-backward-compatibility-check.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,7 @@
<ignored-regex>#\[BC\] CHANGED: Method getIdentifierValues\(\) of class Zenstruck\\Foundry\\Persistence\\PersistenceStrategy became final#</ignored-regex>
<ignored-regex>#"Zenstruck\\Foundry\\Test\\(CommonResetDatabase|CommonFactories)" could not be found in the located source(.*)#</ignored-regex>
<ignored-regex>#Method Zenstruck\\Foundry\\ORM\\AbstractORMPersistenceStrategy\#withoutDoctrineEvents\(\) was removed#</ignored-regex>
<ignored-regex>#Method Zenstruck\\Foundry\\Factory::create(One|Many|Range|Sequence)\(\) was removed#</ignored-regex>
<ignored-regex>#Method Zenstruck\\Foundry\\Persistence\\PersistentProxyObjectFactory::createOne\(\) was removed#</ignored-regex>
</baseline>
</roave-bc-check>
77 changes: 73 additions & 4 deletions src/Factory.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,31 @@
* @phpstan-type Parameters = array<string,mixed>
* @phpstan-type Attributes = Parameters|callable(int):Parameters
* @phpstan-type Sequence = iterable<Parameters>|callable(): iterable<Parameters>
*
* The create helpers go through __callStatic(). One block per reader: PHPStorm and PHPStan get
* the real signatures, Psalm gets plain ones because it reads the aliases above as class names
* inside a "@method" tag, and inlining them makes it drop the tags entirely. Its return types
* come from FixCreateHelpersReturnType anyway.
*
* @psalm-method static T createOne(array|callable $attributes = [])
* @psalm-method static list<T> createMany(int $number, array|callable $attributes = [])
* @psalm-method static list<T> createRange(int $min, int $max, array|callable $attributes = [])
* @psalm-method static list<T> createSequence(iterable|callable $sequence)
*
* @method static T createOne(Attributes $attributes = [])
* @method static ($number is positive-int ? non-empty-list<T> : list<T>) createMany(int $number, Attributes $attributes = [])
* @method static ($min is positive-int ? non-empty-list<T> : list<T>) createRange(int $min, int $max, Attributes $attributes = [])
* @method static list<T> createSequence(Sequence $sequence)
*
* @phpstan-method static T createOne(Attributes $attributes = [])
* @phpstan-method static ($number is positive-int ? non-empty-list<T> : list<T>) createMany(int $number, Attributes $attributes = [])
* @phpstan-method static ($min is positive-int ? non-empty-list<T> : list<T>) createRange(int $min, int $max, Attributes $attributes = [])
* @phpstan-method static list<T> createSequence(Sequence $sequence)
*/
abstract class Factory
{
private const CREATE_HELPERS = ['createOne', 'createMany', 'createRange', 'createSequence'];

/** @phpstan-var Attributes[] */
private array $attributes = [];

Expand Down Expand Up @@ -68,7 +90,7 @@ final public static function new(array|callable $attributes = []): static
*
* @return T
*/
public static function createOne(array|callable $attributes = []): mixed
private static function doCreateOne(array|callable $attributes = []): mixed
{
return static::new()->create($attributes);
}
Expand All @@ -81,7 +103,7 @@ public static function createOne(array|callable $attributes = []): mixed
* @return list<T>
* @phpstan-return ($number is positive-int ? non-empty-list<T> : list<T>)
*/
final public static function createMany(int $number, array|callable $attributes = []): array
private static function doCreateMany(int $number, array|callable $attributes = []): array
{
return static::new()->many($number)->create($attributes);
}
Expand All @@ -94,7 +116,7 @@ final public static function createMany(int $number, array|callable $attributes
* @return list<T>
* @phpstan-return ($min is positive-int ? non-empty-list<T> : list<T>)
*/
final public static function createRange(int $min, int $max, array|callable $attributes = []): array
private static function doCreateRange(int $min, int $max, array|callable $attributes = []): array
{
return static::new()->range($min, $max)->create($attributes);
}
Expand All @@ -104,11 +126,58 @@ final public static function createRange(int $min, int $max, array|callable $att
*
* @return list<T>
*/
final public static function createSequence(iterable|callable $sequence): array
private static function doCreateSequence(iterable|callable $sequence): array
{
return static::new()->sequence($sequence)->create();
}

/**
* @param array<mixed> $arguments
*/
final public static function __callStatic(string $name, array $arguments): mixed
{
if (!\in_array($name, self::CREATE_HELPERS, true)) {
throw new \BadMethodCallException(\sprintf('Call to undefined method %s::%s().', static::class, $name));
}

return static::{'do'.\ucfirst($name)}(...$arguments);
}

/**
* The create helpers are static, so calling one on an instance used to silently discard
* everything set on that instance: `Factory::new()->someState()->createMany(2)` created
* two objects without the state. Routing them through __callStatic makes the instance
* call land here instead, where the state can be honored.
*
* @param array<mixed> $arguments
*/
final public function __call(string $name, array $arguments): mixed
{
if (!\in_array($name, self::CREATE_HELPERS, true)) {
throw new \BadMethodCallException(\sprintf('Call to undefined method %s::%s().', static::class, $name));
}

trigger_deprecation('zenstruck/foundry', '2.13', 'Calling "%s()" on a factory instance is deprecated and will throw in Foundry 3. Use "%s" instead.', $name, self::instanceEquivalent($name));

return match ($name) {
'createOne' => $this->create(...$arguments),
'createMany' => $this->many($arguments[0])->create($arguments[1] ?? []),
'createRange' => $this->range($arguments[0], $arguments[1])->create($arguments[2] ?? []),
'createSequence' => $this->sequence($arguments[0])->create(),
};
}

private static function instanceEquivalent(string $name): string
{
return match ($name) {
'createOne' => 'create()',
'createMany' => 'many()->create()',
'createRange' => 'range()->create()',
'createSequence' => 'sequence()->create()',
default => throw new \LogicException(\sprintf('Unhandled create helper "%s".', $name)),
};
}

/**
* @param array|callable $attributes Property values for the object(s) to create
*
Expand Down
9 changes: 0 additions & 9 deletions src/Persistence/PersistentProxyObjectFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,6 @@ final public function create(callable|array $attributes = []): object
return proxy(parent::create($attributes)); // @phpstan-ignore function.unresolvableReturnType
}

/**
* @return T|Proxy<T>
* @phpstan-return T&Proxy<T>
*/
final public static function createOne(array|callable $attributes = []): mixed
{
return proxy(parent::createOne($attributes)); // @phpstan-ignore function.unresolvableReturnType
}

/**
* @return T|Proxy<T>
* @phpstan-return T&Proxy<T>
Expand Down
2 changes: 1 addition & 1 deletion tests/Integration/Persistence/GenericFactoryTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ public function repository_and_create_function(): void
#[Test]
public function create_many(): void
{
$models = static::factory()->createMany(3, static fn(int $i) => ['prop1' => "value{$i}"]);
$models = static::factory()->many(3)->create(static fn(int $i) => ['prop1' => "value{$i}"]);

static::factory()::repository()->assert()->count(3);

Expand Down
71 changes: 71 additions & 0 deletions tests/Unit/ObjectFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
namespace Zenstruck\Foundry\Tests\Unit;

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\IgnoreDeprecations;
use PHPUnit\Framework\Attributes\RequiresPhpunit;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Zenstruck\Foundry\Factory;
Expand Down Expand Up @@ -525,4 +527,73 @@ public function as_data_provider(): void
{
$this->markTestIncomplete();
}

/**
* @test
*
* @group legacy
* @requires PHPUnit >=11.0.0
*/
#[Test]
#[IgnoreDeprecations]
#[RequiresPhpunit('>=11.0.0')]
public function create_helpers_called_on_an_instance_keep_the_state(): void
{
$factory = SimpleObjectFactory::new()->with(['prop1' => 'from-state']);

self::assertSame('from-state', $factory->createOne()->prop1);
self::assertSame(['from-state', 'from-state'], \array_column($factory->createMany(2), 'prop1'));
self::assertSame(['from-state', 'from-state'], \array_column($factory->createRange(2, 2), 'prop1'));
self::assertSame(['from-state'], \array_column($factory->createSequence([[]]), 'prop1'));
}

/**
* @test
*
* @group legacy
* @requires PHPUnit >=11.0.0
*/
#[Test]
#[IgnoreDeprecations]
#[RequiresPhpunit('>=11.0.0')]
public function create_helpers_called_on_an_instance_are_deprecated(): void
{
$this->expectUserDeprecationMessageMatches('/Calling "createMany\(\)" on a factory instance is deprecated and will throw in Foundry 3\. Use "many\(\)->create\(\)" instead\./');

SimpleObjectFactory::new()->createMany(1);
}

/**
* @test
*/
#[Test]
public function create_helpers_still_ignore_nothing_when_called_statically(): void
{
self::assertCount(2, SimpleObjectFactory::createMany(2, ['prop1' => 'static']));
self::assertSame('static', SimpleObjectFactory::createOne(['prop1' => 'static'])->prop1);
}

/**
* @test
*/
#[Test]
public function calling_an_undefined_method_statically_throws(): void
{
$this->expectException(\BadMethodCallException::class);
$this->expectExceptionMessage(\sprintf('Call to undefined method %s::doesNotExist().', SimpleObjectFactory::class));

SimpleObjectFactory::doesNotExist(); // @phpstan-ignore staticMethod.notFound
}

/**
* @test
*/
#[Test]
public function calling_an_undefined_method_on_an_instance_throws(): void
{
$this->expectException(\BadMethodCallException::class);
$this->expectExceptionMessage(\sprintf('Call to undefined method %s::doesNotExist().', SimpleObjectFactory::class));

SimpleObjectFactory::new()->doesNotExist(); // @phpstan-ignore method.notFound
}
}
140 changes: 140 additions & 0 deletions utils/psalm/FixCreateHelpersReturnType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<?php

declare(strict_types=1);

/*
* This file is part of the zenstruck/foundry package.
*
* (c) Kevin Bond <kevinbond@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Zenstruck\Foundry\Psalm;

use PhpParser\Node\Expr;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Identifier;
use PhpParser\Node\Name;
use Psalm\Internal\Analyzer\ClassLikeAnalyzer;
use Psalm\Plugin\EventHandler\AfterExpressionAnalysisInterface;
use Psalm\Plugin\EventHandler\Event\AfterExpressionAnalysisEvent;
use Psalm\StatementsSource;
use Psalm\Type;
use Psalm\Type\Atomic\TIntRange;
use Psalm\Type\Atomic\TLiteralInt;
use Zenstruck\Foundry\Factory;
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
use Zenstruck\Foundry\Persistence\Proxy;

/**
* The create helpers are declared with "@method static" tags on Factory, and Psalm does not
* bind the class template when it resolves a pseudo method: it expands the return type with
* the declaring class as "self", so "T" stays "T:Zenstruck\Foundry\Factory as mixed".
*
* The return type is therefore rebuilt here, once the call has been analyzed. This also
* covers the proxy factories, which FixProxyFactoryMethodsReturnType cannot reach: it hooks
* AfterMethodCallAnalysisInterface, and that never fires for a magic static call.
*/
final class FixCreateHelpersReturnType implements AfterExpressionAnalysisInterface
{
public static function afterExpressionAnalysis(AfterExpressionAnalysisEvent $event): ?bool
{
$expr = $event->getExpr();

if (!$expr instanceof StaticCall
|| !$expr->name instanceof Identifier
|| !$expr->class instanceof Name
) {
return null;
}

$method = \strtolower($expr->name->name);

if (!\in_array($method, ['createone', 'createmany', 'createrange', 'createsequence'], true)) {
return null;
}

$source = $event->getStatementsSource();
$class = ClassLikeAnalyzer::getFQCLNFromNameObject($expr->class, $source->getAliases());
$codebase = $event->getCodebase();

if (!$codebase->classExtends($class, Factory::class)) {
return null;
}

$storage = $codebase->classlikes->getStorageFor($class);

if (!$storage) {
return null;
}

$isProxy = $codebase->classExtends($class, PersistentProxyObjectFactory::class);
$templateType = $storage->template_extended_params[$isProxy ? PersistentProxyObjectFactory::class : Factory::class]['T'] ?? null;

if (!$templateType) {
return null;
}

$type = $templateType->getId();

if ($isProxy) {
$proxyClass = Proxy::class;
$type = "{$type}&{$proxyClass}<{$type}>";
}

$source->getNodeTypeProvider()->setType(
$expr,
Type::parseString(self::returnType($method, $type, $expr, $source, $isProxy)),
);

return null;
}

/**
* The list helpers are declared with a conditional return type, which Psalm does not
* evaluate for a pseudo method either, so the argument is read here. Proxy factories
* never had the conditional type, FixProxyFactoryMethodsReturnType always widened them
* to a plain list, and that is kept.
*/
private static function returnType(
string $method,
string $type,
StaticCall $expr,
StatementsSource $source,
bool $isProxy,
): string {
if ('createone' === $method) {
return $type;
}

if ('createsequence' === $method || $isProxy) {
return "list<{$type}>";
}

// createMany() is driven by its first argument, createRange() by its lowest bound
$argument = $expr->getArgs()[0]->value ?? null;

return $argument && self::isPositiveInt($argument, $source)
? "non-empty-list<{$type}>"
: "list<{$type}>";
}

private static function isPositiveInt(Expr $expr, StatementsSource $source): bool
{
$type = $source->getNodeTypeProvider()->getType($expr);

if (!$type || !$type->isSingle()) {
return false;
}

$atomic = $type->getSingleAtomic();

if ($atomic instanceof TLiteralInt) {
return $atomic->value > 0;
}

return $atomic instanceof TIntRange && null !== $atomic->min_bound && $atomic->min_bound > 0;
}
}
3 changes: 3 additions & 0 deletions utils/psalm/FoundryPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,8 @@ public function __invoke(RegistrationInterface $registration, ?\SimpleXMLElement
{
\class_exists(FixProxyFactoryMethodsReturnType::class, true);
$registration->registerHooksFromClass(FixProxyFactoryMethodsReturnType::class);

\class_exists(FixCreateHelpersReturnType::class, true);
$registration->registerHooksFromClass(FixCreateHelpersReturnType::class);
}
}
Loading