mirror of
https://github.com/codeigniter4/CodeIgniter4.git
synced 2025-02-20 11:44:28 +08:00
618 lines
17 KiB
PHP
618 lines
17 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* This file is part of CodeIgniter 4 framework.
|
|
*
|
|
* (c) CodeIgniter Foundation <admin@codeigniter.com>
|
|
*
|
|
* For the full copyright and license information, please view
|
|
* the LICENSE file that was distributed with this source code.
|
|
*/
|
|
|
|
namespace CodeIgniter\Test;
|
|
|
|
use Closure;
|
|
use CodeIgniter\Exceptions\FrameworkException;
|
|
use CodeIgniter\Exceptions\InvalidArgumentException;
|
|
use CodeIgniter\Exceptions\RuntimeException;
|
|
use CodeIgniter\I18n\Time;
|
|
use CodeIgniter\Model;
|
|
use Config\App;
|
|
use Faker\Factory;
|
|
use Faker\Generator;
|
|
use InvalidArgumentException as BaseInvalidArgumentException;
|
|
|
|
/**
|
|
* Fabricator
|
|
*
|
|
* Bridge class for using Faker to create example data based on
|
|
* model specifications.
|
|
*
|
|
* @see \CodeIgniter\Test\FabricatorTest
|
|
*/
|
|
class Fabricator
|
|
{
|
|
/**
|
|
* Array of counts for fabricated items
|
|
*
|
|
* @var array
|
|
*/
|
|
protected static $tableCounts = [];
|
|
|
|
/**
|
|
* Locale-specific Faker instance
|
|
*
|
|
* @var Generator
|
|
*/
|
|
protected $faker;
|
|
|
|
/**
|
|
* Model instance (can be non-framework if it follows framework design)
|
|
*
|
|
* @var Model|object
|
|
*/
|
|
protected $model;
|
|
|
|
/**
|
|
* Locale used to initialize Faker
|
|
*
|
|
* @var string
|
|
*/
|
|
protected $locale;
|
|
|
|
/**
|
|
* Map of properties and their formatter to use
|
|
*
|
|
* @var array|null
|
|
*/
|
|
protected $formatters;
|
|
|
|
/**
|
|
* Date fields present in the model
|
|
*
|
|
* @var array
|
|
*/
|
|
protected $dateFields = [];
|
|
|
|
/**
|
|
* Array of data to add or override faked versions
|
|
*
|
|
* @var array
|
|
*/
|
|
protected $overrides = [];
|
|
|
|
/**
|
|
* Array of single-use data to override faked versions
|
|
*
|
|
* @var array|null
|
|
*/
|
|
protected $tempOverrides;
|
|
|
|
/**
|
|
* Fields to be modified before applying any formatter.
|
|
*
|
|
* @var array{
|
|
* unique: array<non-empty-string, array{reset: bool, maxRetries: int}>,
|
|
* optional: array<non-empty-string, array{weight: float, default: mixed}>,
|
|
* valid: array<non-empty-string, array{validator: Closure(mixed): bool|null, maxRetries: int}>
|
|
* }
|
|
*/
|
|
private array $modifiedFields = ['unique' => [], 'optional' => [], 'valid' => []];
|
|
|
|
/**
|
|
* Default formatter to use when nothing is detected
|
|
*
|
|
* @var string
|
|
*/
|
|
public $defaultFormatter = 'word';
|
|
|
|
/**
|
|
* Store the model instance and initialize Faker to the locale.
|
|
*
|
|
* @param object|string $model Instance or classname of the model to use
|
|
* @param array|null $formatters Array of property => formatter
|
|
* @param string|null $locale Locale for Faker provider
|
|
*
|
|
* @throws InvalidArgumentException
|
|
*/
|
|
public function __construct($model, ?array $formatters = null, ?string $locale = null)
|
|
{
|
|
if (is_string($model)) {
|
|
// Create a new model instance
|
|
$model = model($model, false);
|
|
}
|
|
|
|
if (! is_object($model)) {
|
|
throw new InvalidArgumentException(lang('Fabricator.invalidModel'));
|
|
}
|
|
|
|
$this->model = $model;
|
|
|
|
// If no locale was specified then use the App default
|
|
if ($locale === null) {
|
|
$locale = config(App::class)->defaultLocale;
|
|
}
|
|
|
|
// There is no easy way to retrieve the locale from Faker so we will store it
|
|
$this->locale = $locale;
|
|
|
|
// Create the locale-specific Generator
|
|
$this->faker = Factory::create($this->locale);
|
|
|
|
// Determine eligible date fields
|
|
foreach (['createdField', 'updatedField', 'deletedField'] as $field) {
|
|
if (isset($this->model->{$field})) {
|
|
$this->dateFields[] = $this->model->{$field};
|
|
}
|
|
}
|
|
|
|
// Set the formatters
|
|
$this->setFormatters($formatters);
|
|
}
|
|
|
|
/**
|
|
* Reset internal counts
|
|
*/
|
|
public static function resetCounts()
|
|
{
|
|
self::$tableCounts = [];
|
|
}
|
|
|
|
/**
|
|
* Get the count for a specific table
|
|
*
|
|
* @param string $table Name of the target table
|
|
*/
|
|
public static function getCount(string $table): int
|
|
{
|
|
return ! isset(self::$tableCounts[$table]) ? 0 : self::$tableCounts[$table];
|
|
}
|
|
|
|
/**
|
|
* Set the count for a specific table
|
|
*
|
|
* @param string $table Name of the target table
|
|
* @param int $count Count value
|
|
*
|
|
* @return int The new count value
|
|
*/
|
|
public static function setCount(string $table, int $count): int
|
|
{
|
|
self::$tableCounts[$table] = $count;
|
|
|
|
return $count;
|
|
}
|
|
|
|
/**
|
|
* Increment the count for a table
|
|
*
|
|
* @param string $table Name of the target table
|
|
*
|
|
* @return int The new count value
|
|
*/
|
|
public static function upCount(string $table): int
|
|
{
|
|
return self::setCount($table, self::getCount($table) + 1);
|
|
}
|
|
|
|
/**
|
|
* Decrement the count for a table
|
|
*
|
|
* @param string $table Name of the target table
|
|
*
|
|
* @return int The new count value
|
|
*/
|
|
public static function downCount(string $table): int
|
|
{
|
|
return self::setCount($table, self::getCount($table) - 1);
|
|
}
|
|
|
|
/**
|
|
* Returns the model instance
|
|
*
|
|
* @return object Framework or compatible model
|
|
*/
|
|
public function getModel()
|
|
{
|
|
return $this->model;
|
|
}
|
|
|
|
/**
|
|
* Returns the locale
|
|
*/
|
|
public function getLocale(): string
|
|
{
|
|
return $this->locale;
|
|
}
|
|
|
|
/**
|
|
* Returns the Faker generator
|
|
*/
|
|
public function getFaker(): Generator
|
|
{
|
|
return $this->faker;
|
|
}
|
|
|
|
/**
|
|
* Return and reset tempOverrides
|
|
*/
|
|
public function getOverrides(): array
|
|
{
|
|
$overrides = $this->tempOverrides ?? $this->overrides;
|
|
|
|
$this->tempOverrides = $this->overrides;
|
|
|
|
return $overrides;
|
|
}
|
|
|
|
/**
|
|
* Set the overrides, once or persistent
|
|
*
|
|
* @param array $overrides Array of [field => value]
|
|
* @param bool $persist Whether these overrides should persist through the next operation
|
|
*/
|
|
public function setOverrides(array $overrides = [], $persist = true): self
|
|
{
|
|
if ($persist) {
|
|
$this->overrides = $overrides;
|
|
}
|
|
|
|
$this->tempOverrides = $overrides;
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Set a field to be unique.
|
|
*
|
|
* @param bool $reset If set to true, resets the list of existing values
|
|
* @param int $maxRetries Maximum number of retries to find a unique value,
|
|
* After which an OverflowException is thrown.
|
|
*/
|
|
public function setUnique(string $field, bool $reset = false, int $maxRetries = 10000): static
|
|
{
|
|
$this->modifiedFields['unique'][$field] = compact('reset', 'maxRetries');
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Set a field to be optional.
|
|
*
|
|
* @param float $weight A probability between 0 and 1, 0 means that we always get the default value.
|
|
*/
|
|
public function setOptional(string $field, float $weight = 0.5, mixed $default = null): static
|
|
{
|
|
$this->modifiedFields['optional'][$field] = compact('weight', 'default');
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Set a field to be valid using a callback.
|
|
*
|
|
* @param Closure(mixed): bool|null $validator A function returning true for valid values
|
|
* @param int $maxRetries Maximum number of retries to find a valid value,
|
|
* After which an OverflowException is thrown.
|
|
*/
|
|
public function setValid(string $field, ?Closure $validator = null, int $maxRetries = 10000): static
|
|
{
|
|
$this->modifiedFields['valid'][$field] = compact('validator', 'maxRetries');
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Returns the current formatters
|
|
*/
|
|
public function getFormatters(): ?array
|
|
{
|
|
return $this->formatters;
|
|
}
|
|
|
|
/**
|
|
* Set the formatters to use. Will attempt to autodetect if none are available.
|
|
*
|
|
* @param array|null $formatters Array of [field => formatter], or null to detect
|
|
*/
|
|
public function setFormatters(?array $formatters = null): self
|
|
{
|
|
if ($formatters !== null) {
|
|
$this->formatters = $formatters;
|
|
} elseif (method_exists($this->model, 'fake')) {
|
|
$this->formatters = null;
|
|
} else {
|
|
$this->detectFormatters();
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Try to identify the appropriate Faker formatter for each field.
|
|
*/
|
|
protected function detectFormatters(): self
|
|
{
|
|
$this->formatters = [];
|
|
|
|
if (isset($this->model->allowedFields)) {
|
|
foreach ($this->model->allowedFields as $field) {
|
|
$this->formatters[$field] = $this->guessFormatter($field);
|
|
}
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Guess at the correct formatter to match a field name.
|
|
*
|
|
* @param string $field Name of the field
|
|
*
|
|
* @return string Name of the formatter
|
|
*/
|
|
protected function guessFormatter($field): string
|
|
{
|
|
// First check for a Faker formatter of the same name - covers things like "email"
|
|
try {
|
|
$this->faker->getFormatter($field);
|
|
|
|
return $field;
|
|
} catch (BaseInvalidArgumentException) {
|
|
// No match, keep going
|
|
}
|
|
|
|
// Next look for known model fields
|
|
if (in_array($field, $this->dateFields, true)) {
|
|
switch ($this->model->dateFormat) {
|
|
case 'datetime':
|
|
case 'date':
|
|
return 'date';
|
|
|
|
case 'int':
|
|
return 'unixTime';
|
|
}
|
|
} elseif ($field === $this->model->primaryKey) {
|
|
return 'numberBetween';
|
|
}
|
|
|
|
// Check some common partials
|
|
foreach (['email', 'name', 'title', 'text', 'date', 'url'] as $term) {
|
|
if (str_contains(strtolower($field), strtolower($term))) {
|
|
return $term;
|
|
}
|
|
}
|
|
|
|
if (str_contains(strtolower($field), 'phone')) {
|
|
return 'phoneNumber';
|
|
}
|
|
|
|
// Nothing left, use the default
|
|
return $this->defaultFormatter;
|
|
}
|
|
|
|
/**
|
|
* Generate new entities with faked data
|
|
*
|
|
* @param int|null $count Optional number to create a collection
|
|
*
|
|
* @return array|object An array or object (based on returnType), or an array of returnTypes
|
|
*/
|
|
public function make(?int $count = null)
|
|
{
|
|
// If a singleton was requested then go straight to it
|
|
if ($count === null) {
|
|
return $this->model->returnType === 'array'
|
|
? $this->makeArray()
|
|
: $this->makeObject();
|
|
}
|
|
|
|
$return = [];
|
|
|
|
for ($i = 0; $i < $count; $i++) {
|
|
$return[] = $this->model->returnType === 'array'
|
|
? $this->makeArray()
|
|
: $this->makeObject();
|
|
}
|
|
|
|
return $return;
|
|
}
|
|
|
|
/**
|
|
* Generate an array of faked data
|
|
*
|
|
* @return array An array of faked data
|
|
*
|
|
* @throws RuntimeException
|
|
*/
|
|
public function makeArray()
|
|
{
|
|
if ($this->formatters !== null) {
|
|
$result = [];
|
|
|
|
foreach ($this->formatters as $field => $formatter) {
|
|
$faker = $this->faker;
|
|
|
|
if (isset($this->modifiedFields['unique'][$field])) {
|
|
$faker = $faker->unique(
|
|
$this->modifiedFields['unique'][$field]['reset'],
|
|
$this->modifiedFields['unique'][$field]['maxRetries'],
|
|
);
|
|
}
|
|
|
|
if (isset($this->modifiedFields['optional'][$field])) {
|
|
$faker = $faker->optional(
|
|
$this->modifiedFields['optional'][$field]['weight'],
|
|
$this->modifiedFields['optional'][$field]['default'],
|
|
);
|
|
}
|
|
|
|
if (isset($this->modifiedFields['valid'][$field])) {
|
|
$faker = $faker->valid(
|
|
$this->modifiedFields['valid'][$field]['validator'],
|
|
$this->modifiedFields['valid'][$field]['maxRetries'],
|
|
);
|
|
}
|
|
|
|
$result[$field] = $faker->format($formatter);
|
|
}
|
|
}
|
|
// If no formatters were defined then look for a model fake() method
|
|
elseif (method_exists($this->model, 'fake')) {
|
|
$result = $this->model->fake($this->faker);
|
|
|
|
$result = is_object($result) && method_exists($result, 'toArray')
|
|
// This should cover entities
|
|
? $result->toArray()
|
|
// Try to cast it
|
|
: (array) $result;
|
|
}
|
|
// Nothing left to do but give up
|
|
else {
|
|
throw new RuntimeException(lang('Fabricator.missingFormatters'));
|
|
}
|
|
|
|
// Replace overridden fields
|
|
return array_merge($result, $this->getOverrides());
|
|
}
|
|
|
|
/**
|
|
* Generate an object of faked data
|
|
*
|
|
* @param string|null $className Class name of the object to create; null to use model default
|
|
*
|
|
* @return object An instance of the class with faked data
|
|
*
|
|
* @throws RuntimeException
|
|
*/
|
|
public function makeObject(?string $className = null): object
|
|
{
|
|
if ($className === null) {
|
|
if ($this->model->returnType === 'object' || $this->model->returnType === 'array') {
|
|
$className = 'stdClass';
|
|
} else {
|
|
$className = $this->model->returnType;
|
|
}
|
|
}
|
|
|
|
// If using the model's fake() method then check it for the correct return type
|
|
if ($this->formatters === null && method_exists($this->model, 'fake')) {
|
|
$result = $this->model->fake($this->faker);
|
|
|
|
if ($result instanceof $className) {
|
|
// Set overrides manually
|
|
foreach ($this->getOverrides() as $key => $value) {
|
|
$result->{$key} = $value;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
}
|
|
|
|
// Get the array values and apply them to the object
|
|
$array = $this->makeArray();
|
|
$object = new $className();
|
|
|
|
// Check for the entity method
|
|
if (method_exists($object, 'fill')) {
|
|
$object->fill($array);
|
|
} else {
|
|
foreach ($array as $key => $value) {
|
|
$object->{$key} = $value;
|
|
}
|
|
}
|
|
|
|
return $object;
|
|
}
|
|
|
|
/**
|
|
* Generate new entities from the database
|
|
*
|
|
* @param int|null $count Optional number to create a collection
|
|
* @param bool $mock Whether to execute or mock the insertion
|
|
*
|
|
* @return array|object An array or object (based on returnType), or an array of returnTypes
|
|
*
|
|
* @throws FrameworkException
|
|
*/
|
|
public function create(?int $count = null, bool $mock = false)
|
|
{
|
|
// Intercept mock requests
|
|
if ($mock) {
|
|
return $this->createMock($count);
|
|
}
|
|
|
|
$ids = [];
|
|
|
|
// Iterate over new entities and insert each one, storing insert IDs
|
|
foreach ($this->make($count ?? 1) as $result) {
|
|
if ($id = $this->model->insert($result, true)) {
|
|
$ids[] = $id;
|
|
self::upCount($this->model->table);
|
|
|
|
continue;
|
|
}
|
|
|
|
throw FrameworkException::forFabricatorCreateFailed($this->model->table, implode(' ', $this->model->errors() ?? []));
|
|
}
|
|
|
|
// If the model defines a "withDeleted" method for handling soft deletes then use it
|
|
if (method_exists($this->model, 'withDeleted')) {
|
|
$this->model->withDeleted();
|
|
}
|
|
|
|
return $this->model->find($count === null ? reset($ids) : $ids);
|
|
}
|
|
|
|
/**
|
|
* Generate new database entities without actually inserting them
|
|
*
|
|
* @param int|null $count Optional number to create a collection
|
|
*
|
|
* @return array|object An array or object (based on returnType), or an array of returnTypes
|
|
*/
|
|
protected function createMock(?int $count = null)
|
|
{
|
|
$datetime = match ($this->model->dateFormat) {
|
|
'datetime' => date('Y-m-d H:i:s'),
|
|
'date' => date('Y-m-d'),
|
|
default => Time::now()->getTimestamp(),
|
|
};
|
|
|
|
// Determine which fields we will need
|
|
$fields = [];
|
|
|
|
if ($this->model->useTimestamps) {
|
|
$fields[$this->model->createdField] = $datetime;
|
|
$fields[$this->model->updatedField] = $datetime;
|
|
}
|
|
|
|
if ($this->model->useSoftDeletes) {
|
|
$fields[$this->model->deletedField] = null;
|
|
}
|
|
|
|
// Iterate over new entities and add the necessary fields
|
|
$return = [];
|
|
|
|
foreach ($this->make($count ?? 1) as $i => $result) {
|
|
// Set the ID
|
|
$fields[$this->model->primaryKey] = $i;
|
|
|
|
// Merge fields
|
|
if (is_array($result)) {
|
|
$result = array_merge($result, $fields);
|
|
} else {
|
|
foreach ($fields as $key => $value) {
|
|
$result->{$key} = $value;
|
|
}
|
|
}
|
|
|
|
$return[] = $result;
|
|
}
|
|
|
|
return $count === null ? reset($return) : $return;
|
|
}
|
|
}
|