From a6898b7072d9037be4032953c8a0f93016a40278 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 31 Dec 2025 02:49:30 +0000 Subject: [PATCH 1/2] Add UseFactory attribute and per-class model name resolvers Port Laravel's #[UseFactory] attribute for declarative factory binding. Also port per-class $modelNameResolvers to fix race conditions in concurrent environments when using guessModelNamesUsing(). --- .../Eloquent/Attributes/UseFactory.php | 38 +++++ .../Database/Eloquent/Factories/Factory.php | 62 +++++-- .../Eloquent/Factories/HasFactory.php | 48 +++++- .../Eloquent/Factories/FactoryTest.php | 154 ++++++++++++++++++ 4 files changed, 284 insertions(+), 18 deletions(-) create mode 100644 src/core/src/Database/Eloquent/Attributes/UseFactory.php diff --git a/src/core/src/Database/Eloquent/Attributes/UseFactory.php b/src/core/src/Database/Eloquent/Attributes/UseFactory.php new file mode 100644 index 000000000..0d53d5f0e --- /dev/null +++ b/src/core/src/Database/Eloquent/Attributes/UseFactory.php @@ -0,0 +1,38 @@ + $class + */ + public function __construct( + public string $class, + ) { + } +} diff --git a/src/core/src/Database/Eloquent/Factories/Factory.php b/src/core/src/Database/Eloquent/Factories/Factory.php index d3484fda4..da0d42c95 100644 --- a/src/core/src/Database/Eloquent/Factories/Factory.php +++ b/src/core/src/Database/Eloquent/Factories/Factory.php @@ -96,14 +96,23 @@ abstract class Factory /** * The default model name resolver. * - * @var callable(self): class-string + * @deprecated use $modelNameResolvers instead + * + * @var null|callable(self): class-string */ protected static $modelNameResolver; + /** + * The per-class model name resolvers. + * + * @var array> + */ + protected static array $modelNameResolvers = []; + /** * The factory name resolver. * - * @var callable(class-string): class-string + * @var null|callable(class-string): class-string */ protected static $factoryNameResolver; @@ -711,6 +720,13 @@ public function newModel(array $attributes = []): Model /** * Get the name of the model that is generated by the factory. * + * Resolution order: + * 1. Explicit $model property on the factory + * 2. Per-class resolver for this specific factory class + * 3. Per-class resolver for base Factory class (global fallback) + * 4. Deprecated single $modelNameResolver (backwards compatibility) + * 5. Convention-based resolution + * * @return class-string */ public function modelName(): string @@ -719,21 +735,24 @@ public function modelName(): string return $this->model; } - $resolver = static::$modelNameResolver ?? function (self $factory) { - $namespacedFactoryBasename = Str::replaceLast( - 'Factory', - '', - Str::replaceFirst(static::$namespace, '', get_class($factory)) - ); + $resolver = static::$modelNameResolvers[static::class] + ?? static::$modelNameResolvers[self::class] + ?? static::$modelNameResolver + ?? function (self $factory) { + $namespacedFactoryBasename = Str::replaceLast( + 'Factory', + '', + Str::replaceFirst(static::$namespace, '', get_class($factory)) + ); - $factoryBasename = Str::replaceLast('Factory', '', class_basename($factory)); + $factoryBasename = Str::replaceLast('Factory', '', class_basename($factory)); - $appNamespace = static::appNamespace(); + $appNamespace = static::appNamespace(); - return class_exists($appNamespace . 'Models\\' . $namespacedFactoryBasename) - ? $appNamespace . 'Models\\' . $namespacedFactoryBasename - : $appNamespace . $factoryBasename; - }; + return class_exists($appNamespace . 'Models\\' . $namespacedFactoryBasename) + ? $appNamespace . 'Models\\' . $namespacedFactoryBasename + : $appNamespace . $factoryBasename; + }; return $resolver($this); } @@ -741,11 +760,13 @@ public function modelName(): string /** * Specify the callback that should be invoked to guess model names based on factory names. * + * Uses per-factory-class resolvers to avoid race conditions in concurrent environments. + * * @param callable(self): class-string $callback */ public static function guessModelNamesUsing(callable $callback): void { - static::$modelNameResolver = $callback; + static::$modelNameResolvers[static::class] = $callback; } /** @@ -837,6 +858,17 @@ protected static function appNamespace() } } + /** + * Flush the factory's global state. + */ + public static function flushState(): void + { + static::$modelNameResolver = null; + static::$modelNameResolvers = []; + static::$factoryNameResolver = null; + static::$namespace = 'Database\Factories\\'; + } + /** * Proxy dynamic factory methods onto their proper methods. * diff --git a/src/core/src/Database/Eloquent/Factories/HasFactory.php b/src/core/src/Database/Eloquent/Factories/HasFactory.php index 479d8d118..0a188a9a4 100644 --- a/src/core/src/Database/Eloquent/Factories/HasFactory.php +++ b/src/core/src/Database/Eloquent/Factories/HasFactory.php @@ -4,6 +4,9 @@ namespace Hypervel\Database\Eloquent\Factories; +use Hypervel\Database\Eloquent\Attributes\UseFactory; +use ReflectionClass; + /** * @template TFactory of Factory */ @@ -18,11 +21,50 @@ trait HasFactory */ public static function factory(array|callable|int|null $count = null, array|callable $state = []): Factory { - $factory = isset(static::$factory) - ? static::$factory::new() - : Factory::factoryForModel(static::class); + $factory = static::newFactory() ?? Factory::factoryForModel(static::class); return $factory->count(is_numeric($count) ? $count : null) ->state(is_callable($count) || is_array($count) ? $count : $state); } + + /** + * Create a new factory instance for the model. + * + * Resolution order: + * 1. Static $factory property on the model + * 2. #[UseFactory] attribute on the model class + * + * @return null|TFactory + */ + protected static function newFactory(): ?Factory + { + if (isset(static::$factory)) { + return static::$factory::new(); + } + + return static::getUseFactoryAttribute(); + } + + /** + * Get the factory from the UseFactory class attribute. + * + * @return null|TFactory + */ + protected static function getUseFactoryAttribute(): ?Factory + { + $attributes = (new ReflectionClass(static::class)) + ->getAttributes(UseFactory::class); + + if ($attributes !== []) { + $useFactory = $attributes[0]->newInstance(); + + $factory = $useFactory->class::new(); + + $factory->guessModelNamesUsing(fn () => static::class); + + return $factory; + } + + return null; + } } diff --git a/tests/Core/Database/Eloquent/Factories/FactoryTest.php b/tests/Core/Database/Eloquent/Factories/FactoryTest.php index d63b66e42..3fea068dd 100644 --- a/tests/Core/Database/Eloquent/Factories/FactoryTest.php +++ b/tests/Core/Database/Eloquent/Factories/FactoryTest.php @@ -7,6 +7,7 @@ use BadMethodCallException; use Carbon\Carbon; use Hyperf\Database\Model\SoftDeletes; +use Hypervel\Database\Eloquent\Attributes\UseFactory; use Hypervel\Database\Eloquent\Collection; use Hypervel\Database\Eloquent\Factories\CrossJoinSequence; use Hypervel\Database\Eloquent\Factories\Factory; @@ -46,6 +47,7 @@ protected function migrateFreshUsing(): array protected function tearDown(): void { m::close(); + Factory::flushState(); parent::tearDown(); } @@ -761,6 +763,91 @@ public function testCanDisableRelationships() $this->assertNull($post->user_id); } + + public function testUseFactoryAttributeResolvesFactory() + { + $factory = FactoryTestModelWithUseFactory::factory(); + + $this->assertInstanceOf(FactoryTestModelWithUseFactoryFactory::class, $factory); + } + + public function testUseFactoryAttributeResolvesCorrectModelName() + { + $factory = FactoryTestModelWithUseFactory::factory(); + + $this->assertSame(FactoryTestModelWithUseFactory::class, $factory->modelName()); + } + + public function testUseFactoryAttributeWorksWithCount() + { + $models = FactoryTestModelWithUseFactory::factory(3)->make(); + + $this->assertCount(3, $models); + $this->assertInstanceOf(FactoryTestModelWithUseFactory::class, $models->first()); + } + + public function testStaticFactoryPropertyTakesPrecedenceOverUseFactoryAttribute() + { + $factory = FactoryTestModelWithStaticFactoryAndAttribute::factory(); + + // Should use the static $factory property, not the attribute + $this->assertInstanceOf(FactoryTestModelWithStaticFactory::class, $factory); + } + + public function testModelWithoutUseFactoryFallsBackToConvention() + { + Factory::guessFactoryNamesUsing(fn ($model) => $model . 'Factory'); + + $factory = FactoryTestUser::factory(); + + $this->assertInstanceOf(FactoryTestUserFactory::class, $factory); + } + + public function testPerClassModelNameResolverIsolation() + { + // Set up per-class resolvers for different factories + FactoryTestUserFactory::guessModelNamesUsing(fn () => 'ResolvedUserModel'); + FactoryTestPostFactory::guessModelNamesUsing(fn () => 'ResolvedPostModel'); + + // Create factories without explicit $model property + $factoryWithoutModel = new FactoryTestFactoryWithoutModel(); + + // The factory-specific resolver should be isolated + // FactoryTestFactoryWithoutModel has no resolver set, so it should use default convention + // We need to set a resolver for it specifically + FactoryTestFactoryWithoutModel::guessModelNamesUsing(fn () => 'ResolvedFactoryWithoutModel'); + + $this->assertSame('ResolvedFactoryWithoutModel', $factoryWithoutModel->modelName()); + } + + public function testPerClassResolversDoNotInterfere() + { + // Each factory class maintains its own resolver + FactoryTestUserFactory::guessModelNamesUsing(fn () => 'UserModelResolved'); + + // Create a user factory instance + $userFactory = FactoryTestUserFactory::new(); + + // The user factory should use its specific resolver + $this->assertSame(FactoryTestUser::class, $userFactory->modelName()); + + // But if we set a resolver for a factory without a $model property... + FactoryTestFactoryWithoutModel::guessModelNamesUsing(fn () => 'FactoryWithoutModelResolved'); + + $factoryWithoutModel = new FactoryTestFactoryWithoutModel(); + $this->assertSame('FactoryWithoutModelResolved', $factoryWithoutModel->modelName()); + } + + public function testFlushStateResetsAllResolvers() + { + FactoryTestUserFactory::guessModelNamesUsing(fn () => 'CustomModel'); + Factory::useNamespace('Custom\Namespace\\'); + + Factory::flushState(); + + // After flush, namespace should be reset + $this->assertSame('Database\Factories\\', Factory::$namespace); + } } class FactoryTestUserFactory extends Factory @@ -899,3 +986,70 @@ public function users() return $this->belongsToMany(FactoryTestUser::class, 'role_user', 'role_id', 'user_id')->withPivot('admin'); } } + +// UseFactory attribute test fixtures + +class FactoryTestModelWithUseFactoryFactory extends Factory +{ + public function definition() + { + return [ + 'name' => $this->faker->name(), + ]; + } +} + +#[UseFactory(FactoryTestModelWithUseFactoryFactory::class)] +class FactoryTestModelWithUseFactory extends Model +{ + use HasFactory; + + protected ?string $table = 'users'; + + protected array $fillable = ['name']; +} + +// Factory for testing static $factory property precedence +class FactoryTestModelWithStaticFactory extends Factory +{ + protected $model = FactoryTestModelWithStaticFactoryAndAttribute::class; + + public function definition() + { + return [ + 'name' => $this->faker->name(), + ]; + } +} + +// Alternative factory for the attribute (should NOT be used) +class FactoryTestAlternativeFactory extends Factory +{ + public function definition() + { + return [ + 'name' => 'alternative', + ]; + } +} + +#[UseFactory(FactoryTestAlternativeFactory::class)] +class FactoryTestModelWithStaticFactoryAndAttribute extends Model +{ + use HasFactory; + + protected static string $factory = FactoryTestModelWithStaticFactory::class; + + protected ?string $table = 'users'; + + protected array $fillable = ['name']; +} + +// Factory without explicit $model property for testing resolver isolation +class FactoryTestFactoryWithoutModel extends Factory +{ + public function definition() + { + return []; + } +} From f7bbb716f7fad47369babfe31a793d243b4f8548 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 5 Jan 2026 11:21:38 +0000 Subject: [PATCH 2/2] Remove deprecated $modelNameResolver property The per-class $modelNameResolvers array fully replaces the single global resolver. No backwards compatibility needed as this is internal. --- .../src/Database/Eloquent/Factories/Factory.php | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/core/src/Database/Eloquent/Factories/Factory.php b/src/core/src/Database/Eloquent/Factories/Factory.php index 0146917f3..959fe449f 100644 --- a/src/core/src/Database/Eloquent/Factories/Factory.php +++ b/src/core/src/Database/Eloquent/Factories/Factory.php @@ -93,15 +93,6 @@ abstract class Factory */ public static $namespace = 'Database\Factories\\'; - /** - * The default model name resolver. - * - * @deprecated use $modelNameResolvers instead - * - * @var null|(callable(self): class-string) - */ - protected static $modelNameResolver; - /** * The per-class model name resolvers. * @@ -724,8 +715,7 @@ public function newModel(array $attributes = []): Model * 1. Explicit $model property on the factory * 2. Per-class resolver for this specific factory class * 3. Per-class resolver for base Factory class (global fallback) - * 4. Deprecated single $modelNameResolver (backwards compatibility) - * 5. Convention-based resolution + * 4. Convention-based resolution * * @return class-string */ @@ -737,7 +727,6 @@ public function modelName(): string $resolver = static::$modelNameResolvers[static::class] ?? static::$modelNameResolvers[self::class] - ?? static::$modelNameResolver ?? function (self $factory) { $namespacedFactoryBasename = Str::replaceLast( 'Factory', @@ -863,7 +852,6 @@ protected static function appNamespace() */ public static function flushState(): void { - static::$modelNameResolver = null; static::$modelNameResolvers = []; static::$factoryNameResolver = null; static::$namespace = 'Database\Factories\\';