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
38 changes: 38 additions & 0 deletions src/core/src/Database/Eloquent/Attributes/UseFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace Hypervel\Database\Eloquent\Attributes;

use Attribute;

/**
* Declare the factory class for a model using an attribute.
*
* When placed on a model class that uses the HasFactory trait, the specified
* factory will be used when calling the model's factory() method.
*
* @example
* ```php
* #[UseFactory(PostFactory::class)]
* class Post extends Model
* {
* use HasFactory;
* }
*
* // Now Post::factory() will use PostFactory
* ```
*/
#[Attribute(Attribute::TARGET_CLASS)]
class UseFactory
{
/**
* Create a new attribute instance.
*
* @param class-string<\Hypervel\Database\Eloquent\Factories\Factory> $class
*/
public function __construct(
public string $class,
) {
}
}
62 changes: 47 additions & 15 deletions src/core/src/Database/Eloquent/Factories/Factory.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,23 @@ abstract class Factory
/**
* The default model name resolver.
*
* @var callable(self): class-string<TModel>
* @deprecated use $modelNameResolvers instead
*
* @var null|callable(self): class-string<TModel>
*/
protected static $modelNameResolver;

/**
* The per-class model name resolvers.
*
* @var array<class-string, callable(self): class-string<TModel>>
*/
protected static array $modelNameResolvers = [];

/**
* The factory name resolver.
*
* @var callable(class-string<Model>): class-string<Factory>
* @var null|callable(class-string<Model>): class-string<Factory>
*/
protected static $factoryNameResolver;

Expand Down Expand Up @@ -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<TModel>
*/
public function modelName(): string
Expand All @@ -719,33 +735,38 @@ 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);
}

/**
* 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<TModel> $callback
*/
public static function guessModelNamesUsing(callable $callback): void
{
static::$modelNameResolver = $callback;
static::$modelNameResolvers[static::class] = $callback;
}

/**
Expand Down Expand Up @@ -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.
*
Expand Down
48 changes: 45 additions & 3 deletions src/core/src/Database/Eloquent/Factories/HasFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

namespace Hypervel\Database\Eloquent\Factories;

use Hypervel\Database\Eloquent\Attributes\UseFactory;
use ReflectionClass;

/**
* @template TFactory of Factory
*/
Expand All @@ -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;
}
}
154 changes: 154 additions & 0 deletions tests/Core/Database/Eloquent/Factories/FactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -46,6 +47,7 @@ protected function migrateFreshUsing(): array
protected function tearDown(): void
{
m::close();
Factory::flushState();

parent::tearDown();
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 [];
}
}