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 525255cec..959fe449f 100644 --- a/src/core/src/Database/Eloquent/Factories/Factory.php +++ b/src/core/src/Database/Eloquent/Factories/Factory.php @@ -94,11 +94,11 @@ abstract class Factory public static $namespace = 'Database\Factories\\'; /** - * The default model name resolver. + * The per-class model name resolvers. * - * @var null|(callable(self): class-string) + * @var array> */ - protected static $modelNameResolver; + protected static array $modelNameResolvers = []; /** * The factory name resolver. @@ -711,6 +711,12 @@ 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. Convention-based resolution + * * @return class-string */ public function modelName(): string @@ -719,21 +725,23 @@ 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] + ?? 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 +749,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 +847,16 @@ protected static function appNamespace() } } + /** + * Flush the factory's global state. + */ + public static function flushState(): void + { + 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 []; + } +}