Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: skip readonly properties on entities when generating factories #798

Merged
merged 5 commits into from
Feb 1, 2025
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
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ MONGO_URL="mongodb://127.0.0.1:27018/dbName?compressors=disabled&gssapiServi
USE_DAMA_DOCTRINE_TEST_BUNDLE="0"
USE_FOUNDRY_PHPUNIT_EXTENSION="0"
PHPUNIT_VERSION="9" # allowed values: 9, 10, 11
APP_ENV=test
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"symfony/deprecation-contracts": "^2.2|^3.0",
"symfony/framework-bundle": "^6.4|^7.0",
"symfony/property-access": "^6.4|^7.0",
"symfony/property-info": "^6.4|^7.0",
"symfony/var-exporter": "^6.4.9|~7.0.9|^7.1.2",
"zenstruck/assert": "^1.4"
},
Expand Down
2 changes: 2 additions & 0 deletions src/Maker/Factory/FactoryGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public function __construct(
private \Traversable $defaultPropertiesGuessers,
private FactoryClassMap $factoryClassMap,
private NamespaceGuesser $namespaceGuesser,
private bool $forceProperties = false
) {
}

Expand Down Expand Up @@ -150,6 +151,7 @@ private function createMakeFactoryData(Generator $generator, string $class, Make
$this->staticAnalysisTool(),
$persisted ?? false,
$makeFactoryQuery->addPhpDoc(),
$this->forceProperties
);
}

Expand Down
25 changes: 25 additions & 0 deletions src/Maker/Factory/MakeFactoryData.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Doctrine\ORM\EntityRepository;
use Symfony\Bundle\MakerBundle\Str;
use Symfony\Bundle\MakerBundle\Util\ClassNameDetails;
use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor;
use Zenstruck\Foundry\ObjectFactory;
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
use Zenstruck\Foundry\Persistence\Proxy;
Expand All @@ -29,6 +30,8 @@ final class MakeFactoryData
public const STATIC_ANALYSIS_TOOL_PHPSTAN = 'phpstan';
public const STATIC_ANALYSIS_TOOL_PSALM = 'psalm';

private static ReflectionExtractor|null $propertyInfo = null;

/** @var list<string> */
private array $uses;
/** @var array<string, string> */
Expand All @@ -43,6 +46,7 @@ public function __construct(
private string $staticAnalysisTool,
private bool $persisted,
bool $withPhpDoc,
private bool $forceProperties
) {
$this->uses = [
$this->getFactoryClass(),
Expand Down Expand Up @@ -154,6 +158,22 @@ public function addDefaultProperty(string $propertyName, string $defaultValue):
public function getDefaultProperties(): array
{
$defaultProperties = $this->defaultProperties;
$class = $this->object->getName();

/**
* If forceProperties is not set we filter out properties that can not be set because they're either readonly or have no setter.
* Useful for properties that auto generate when the entity is created and can not be changed like a createdAt property for example.
*
* We do this here because we need to get the class of the Entity which only seems to be accessible here.
*/
$defaultProperties = array_filter($defaultProperties, function (string $propertyName) use ($class): bool {
if (true === $this->forceProperties) {
return true;
}

return self::propertyInfo()->isWritable($class, $propertyName) || self::propertyInfo()->isInitializable($class, $propertyName);
}, ARRAY_FILTER_USE_KEY);

\ksort($defaultProperties);

return $defaultProperties;
Expand Down Expand Up @@ -189,4 +209,9 @@ public function addEnumDefaultProperty(string $propertyName, string $enumClass):
"self::faker()->randomElement({$enumShortClassName}::cases()),",
);
}

private static function propertyInfo(): ReflectionExtractor
{
return self::$propertyInfo ??= new ReflectionExtractor();
}
}
3 changes: 3 additions & 0 deletions src/ZenstruckFoundryBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,9 @@ public function loadExtension(array $config, ContainerConfigurator $configurator
if (!isset($bundles['DoctrineBundle']) && !isset($bundles['DoctrineMongoDBBundle'])) {
$container->removeDefinition('.zenstruck_foundry.maker.factory.doctrine_scalar_fields_default_properties_guesser');
}

$container->getDefinition('.zenstruck_foundry.maker.factory.generator')
->setArgument('$forceProperties', $config['instantiator']['always_force_properties'] ?? false);
} else {
$configurator->import('../config/command_stubs.php');
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

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

namespace App\Factory;

use Zenstruck\Foundry\ObjectFactory;
use Zenstruck\Foundry\Tests\Fixture\ObjectWithNonWriteable;

/**
* @extends ObjectFactory<ObjectWithNonWriteable>
*/
final class ObjectWithNonWriteableFactory extends ObjectFactory
{
/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services
*
* @todo inject services if required
*/
public function __construct()
{
}

public static function class(): string
{
return ObjectWithNonWriteable::class;
}

/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories
*
* @todo add your default values here
*/
protected function defaults(): array|callable
{
return [
'bar' => self::faker()->sentence(),
'baz' => self::faker()->sentence(),
'foo' => self::faker()->sentence(),
];
}

/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization
*/
protected function initialize(): static
{
return $this
// ->afterInstantiate(function(ObjectWithNonWriteable $objectWithNonWriteable): void {})
;
}
}
58 changes: 58 additions & 0 deletions tests/Fixture/Maker/expected/does_not_initialize_non_settable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

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

namespace App\Factory;

use Zenstruck\Foundry\ObjectFactory;
use Zenstruck\Foundry\Tests\Fixture\ObjectWithNonWriteable;

/**
* @extends ObjectFactory<ObjectWithNonWriteable>
*/
final class ObjectWithNonWriteableFactory extends ObjectFactory
{
/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services
*
* @todo inject services if required
*/
public function __construct()
{
}

public static function class(): string
{
return ObjectWithNonWriteable::class;
}

/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories
*
* @todo add your default values here
*/
protected function defaults(): array|callable
{
return [
'baz' => self::faker()->sentence(),
'foo' => self::faker()->sentence(),
];
}

/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization
*/
protected function initialize(): static
{
return $this
// ->afterInstantiate(function(ObjectWithNonWriteable $objectWithNonWriteable): void {})
;
}
}
41 changes: 41 additions & 0 deletions tests/Fixture/ObjectWithNonWriteable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

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

namespace Zenstruck\Foundry\Tests\Fixture;

final class ObjectWithNonWriteable
{
private string $bar;
// @phpstan-ignore-next-line We do not want assign a default value to this so the factory sets one
private string $baz;

public function __construct(
public readonly string $foo
) {
$this->bar = 'bar';
}

public function getBaz(): string
{
return $this->baz;
}

public function setBaz(string $baz): ObjectWithNonWriteable
{
$this->baz = $baz;
return $this;
}

public function getBar(): string
{
return $this->bar;
}
}
4 changes: 4 additions & 0 deletions tests/Fixture/TestKernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load
{
parent::configureContainer($c, $loader);

if ($this->getEnvironment() !== 'test') {
$loader->load(\sprintf('%s/config/%s.yaml', __DIR__, $this->getEnvironment()));
}

$c->loadFromExtension('zenstruck_foundry', [
'orm' => [
'reset' => [
Expand Down
3 changes: 3 additions & 0 deletions tests/Fixture/config/always_force.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
zenstruck_foundry:
instantiator:
always_force_properties: true # always "force set" properties
31 changes: 29 additions & 2 deletions tests/Integration/Maker/MakeFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
use Zenstruck\Foundry\Tests\Fixture\Entity\WithEmbeddableEntity;
use Zenstruck\Foundry\Tests\Fixture\Object1;
use Zenstruck\Foundry\Tests\Fixture\ObjectWithEnum;
use Zenstruck\Foundry\Tests\Fixture\ObjectWithNonWriteable;

/**
* @author Kevin Bond <[email protected]>
Expand Down Expand Up @@ -421,14 +422,40 @@ public function can_create_factory_with_default_enum(): void
$this->assertFileFromMakerSameAsExpectedFile(self::tempFile('src/Factory/ObjectWithEnumFactory.php'));
}

/**
* @test
*/
public function does_not_initialize_non_settable(): void
{
$tester = $this->makeFactoryCommandTester();

$tester->execute(['class' => ObjectWithNonWriteable::class, '--no-persistence' => true]);

$this->assertFileFromMakerSameAsExpectedFile(self::tempFile('src/Factory/ObjectWithNonWriteableFactory.php'));
}

/**
* @test
*/
public function does_force_initialization_of_non_settable_with_always_force(): void
{
$tester = $this->makeFactoryCommandTester('always_force');

$tester->execute(['class' => ObjectWithNonWriteable::class, '--no-persistence' => true]);

$this->assertFileFromMakerSameAsExpectedFile(self::tempFile('src/Factory/ObjectWithNonWriteableFactory.php'));
}

private function emulateSCAToolEnabled(string $scaToolFilePath): void
{
\mkdir(\dirname($scaToolFilePath), 0777, true);
\touch($scaToolFilePath);
}

private function makeFactoryCommandTester(): CommandTester
private function makeFactoryCommandTester(string $appEnv = 'test'): CommandTester
{
return new CommandTester((new Application(self::bootKernel()))->find('make:factory'));
return new CommandTester((new Application(self::bootKernel([
'environment' => $appEnv,
])))->find('make:factory'));
}
}