CodeIgniter4/system/Test/Fabricator.php

559 lines
14 KiB
PHP
Raw Normal View History

2020-05-05 19:50:06 +00:00
<?php
2020-05-05 19:50:06 +00:00
/**
2020-10-24 16:38:41 +08:00
* This file is part of the CodeIgniter 4 framework.
2020-05-05 19:50:06 +00:00
*
2020-10-24 16:38:41 +08:00
* (c) CodeIgniter Foundation <admin@codeigniter.com>
2020-05-05 19:50:06 +00:00
*
2020-10-24 16:38:41 +08:00
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
2020-05-05 19:50:06 +00:00
*/
namespace CodeIgniter\Test;
use CodeIgniter\Exceptions\FrameworkException;
2021-05-24 20:39:50 +02:00
use CodeIgniter\Model;
2020-05-05 19:50:06 +00:00
use Faker\Factory;
use Faker\Generator;
2020-10-04 00:27:56 +07:00
use InvalidArgumentException;
use RuntimeException;
2020-05-05 19:50:06 +00:00
/**
* Fabricator
*
* Bridge class for using Faker to create example data based on
* model specifications.
*/
class Fabricator
{
2021-06-04 22:51:52 +08:00
/**
* 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;
/**
* Default formatter to use when nothing is detected
*
* @var string
*/
public $defaultFormatter = 'word';
/**
* Store the model instance and initialize Faker to the locale.
*
2021-06-11 23:46:56 +08:00
* @param object|string $model Instance or classname of the model to use
2021-06-04 22:51:52 +08:00
* @param array|null $formatters Array of property => formatter
* @param string|null $locale Locale for Faker provider
*
* @throws InvalidArgumentException
*/
2021-07-09 23:13:08 +08:00
public function __construct($model, ?array $formatters = null, ?string $locale = null)
2021-06-04 22:51:52 +08:00
{
2021-06-07 19:06:26 +08:00
if (is_string($model)) {
2021-06-04 22:51:52 +08:00
// Create a new model instance
$model = model($model, false);
}
2021-06-07 19:06:26 +08:00
if (! is_object($model)) {
2021-06-04 22:51:52 +08:00
throw new InvalidArgumentException(lang('Fabricator.invalidModel'));
}
$this->model = $model;
// If no locale was specified then use the App default
if ($locale === null) {
2021-06-04 22:51:52 +08:00
$locale = config('App')->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
2021-06-07 19:06:26 +08:00
foreach (['createdField', 'updatedField', 'deletedField'] as $field) {
if (! empty($this->model->{$field})) {
$this->dateFields[] = $this->model->{$field};
2021-06-04 22:51:52 +08:00
}
}
// 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
*
* @return int
2021-06-04 22:51:52 +08:00
*/
public static function getCount(string $table): int
{
return empty(self::$tableCounts[$table]) ? 0 : self::$tableCounts[$table];
}
/**
* Set the count for a specific table
*
2021-06-08 12:11:23 +08:00
* @param string $table Name of the target table
* @param int $count Count value
2021-06-04 22:51:52 +08:00
*
2021-06-08 12:11:23 +08:00
* @return int The new count value
2021-06-04 22:51:52 +08:00
*/
public static function setCount(string $table, int $count): int
{
self::$tableCounts[$table] = $count;
2021-06-04 22:51:52 +08:00
return $count;
}
/**
* Increment the count for a table
*
* @param string $table Name of the target table
*
2021-06-08 12:11:23 +08:00
* @return int The new count value
2021-06-04 22:51:52 +08:00
*/
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
*
2021-06-08 12:11:23 +08:00
* @return int The new count value
2021-06-04 22:51:52 +08:00
*/
public static function downCount(string $table): int
{
return self::setCount($table, self::getCount($table) - 1);
}
/**
* Returns the model instance
*
2021-06-08 12:11:23 +08:00
* @return object Framework or compatible model
2021-06-04 22:51:52 +08:00
*/
public function getModel()
{
return $this->model;
}
/**
* Returns the locale
*
* @return string
*/
public function getLocale(): string
{
return $this->locale;
}
/**
* Returns the Faker generator
*
* @return Generator
*/
public function getFaker(): Generator
{
return $this->faker;
}
/**
* Return and reset tempOverrides
*
* @return array
*/
public function getOverrides(): array
{
$overrides = $this->tempOverrides ?? $this->overrides;
$this->tempOverrides = $this->overrides;
return $overrides;
}
/**
* Set the overrides, once or persistent
*
2021-06-08 12:11:23 +08:00
* @param array $overrides Array of [field => value]
* @param bool $persist Whether these overrides should persist through the next operation
2021-06-04 22:51:52 +08:00
*
* @return $this
*/
public function setOverrides(array $overrides = [], $persist = true): self
{
2021-06-07 19:06:26 +08:00
if ($persist) {
2021-06-04 22:51:52 +08:00
$this->overrides = $overrides;
}
$this->tempOverrides = $overrides;
return $this;
}
/**
* Returns the current formatters
*
* @return array|null
*/
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
*
* @return $this
*/
2021-07-09 23:13:08 +08:00
public function setFormatters(?array $formatters = null): self
2021-06-04 22:51:52 +08:00
{
if ($formatters !== null) {
2021-06-04 22:51:52 +08:00
$this->formatters = $formatters;
2021-06-07 19:06:26 +08:00
} elseif (method_exists($this->model, 'fake')) {
2021-06-04 22:51:52 +08:00
$this->formatters = null;
2021-06-07 19:06:26 +08:00
} else {
2021-06-07 03:26:46 +02:00
$this->detectFormatters();
2021-06-04 22:51:52 +08:00
}
return $this;
}
/**
* Try to identify the appropriate Faker formatter for each field.
*
* @return $this
*/
protected function detectFormatters(): self
{
$this->formatters = [];
2021-06-07 19:06:26 +08:00
if (! empty($this->model->allowedFields)) {
foreach ($this->model->allowedFields as $field) {
2021-06-04 22:51:52 +08:00
$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
*
2021-06-08 12:11:23 +08:00
* @return string Name of the formatter
2021-06-04 22:51:52 +08:00
*/
protected function guessFormatter($field): string
{
// First check for a Faker formatter of the same name - covers things like "email"
2021-06-07 19:06:26 +08:00
try {
2021-06-04 22:51:52 +08:00
$this->faker->getFormatter($field);
2021-06-04 22:51:52 +08:00
return $field;
2021-06-07 19:06:26 +08:00
} catch (InvalidArgumentException $e) {
2021-06-04 22:51:52 +08:00
// No match, keep going
}
// Next look for known model fields
2021-06-07 19:06:26 +08:00
if (in_array($field, $this->dateFields, true)) {
switch ($this->model->dateFormat) {
2021-06-04 22:51:52 +08:00
case 'datetime':
case 'date':
return 'date';
2021-06-04 22:51:52 +08:00
case 'int':
return 'unixTime';
}
2021-06-07 19:06:26 +08:00
} elseif ($field === $this->model->primaryKey) {
2021-06-04 22:51:52 +08:00
return 'numberBetween';
}
// Check some common partials
2021-06-07 19:06:26 +08:00
foreach (['email', 'name', 'title', 'text', 'date', 'url'] as $term) {
if (stripos($field, $term) !== false) {
2021-06-04 22:51:52 +08:00
return $term;
}
}
2021-06-07 19:06:26 +08:00
if (stripos($field, 'phone') !== false) {
2021-06-04 22:51:52 +08:00
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
2021-06-04 22:51:52 +08:00
*
2021-06-08 12:11:23 +08:00
* @return array|object An array or object (based on returnType), or an array of returnTypes
2021-06-04 22:51:52 +08:00
*/
2021-07-09 23:13:08 +08:00
public function make(?int $count = null)
2021-06-04 22:51:52 +08:00
{
// If a singleton was requested then go straight to it
if ($count === null) {
2021-06-04 22:51:52 +08:00
return $this->model->returnType === 'array'
? $this->makeArray()
: $this->makeObject();
}
$return = [];
2021-06-07 19:06:26 +08:00
for ($i = 0; $i < $count; $i++) {
2021-06-04 22:51:52 +08:00
$return[] = $this->model->returnType === 'array'
? $this->makeArray()
: $this->makeObject();
}
return $return;
}
/**
* Generate an array of faked data
*
* @throws RuntimeException
*
* @return array An array of faked data
2021-06-04 22:51:52 +08:00
*/
public function makeArray()
{
if ($this->formatters !== null) {
2021-06-04 22:51:52 +08:00
$result = [];
2021-06-07 19:06:26 +08:00
foreach ($this->formatters as $field => $formatter) {
2021-06-04 22:51:52 +08:00
$result[$field] = $this->faker->{$formatter};
}
}
// If no formatters were defined then look for a model fake() method
2021-06-07 19:06:26 +08:00
elseif (method_exists($this->model, 'fake')) {
2021-06-04 22:51:52 +08:00
$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
2021-06-07 19:06:26 +08:00
else {
2021-06-04 22:51:52 +08:00
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
*
* @throws RuntimeException
*
* @return object An instance of the class with faked data
2021-06-04 22:51:52 +08:00
*/
2021-07-09 23:13:08 +08:00
public function makeObject(?string $className = null): object
2021-06-04 22:51:52 +08:00
{
if ($className === null) {
2021-06-07 19:06:26 +08:00
if ($this->model->returnType === 'object' || $this->model->returnType === 'array') {
2021-06-04 22:51:52 +08:00
$className = 'stdClass';
2021-06-07 19:06:26 +08:00
} else {
2021-06-04 22:51:52 +08:00
$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')) {
2021-06-04 22:51:52 +08:00
$result = $this->model->fake($this->faker);
2021-06-07 19:06:26 +08:00
if ($result instanceof $className) {
2021-06-04 22:51:52 +08:00
// Set overrides manually
2021-06-07 19:06:26 +08:00
foreach ($this->getOverrides() as $key => $value) {
2021-06-04 22:51:52 +08:00
$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
2021-06-07 19:06:26 +08:00
if (method_exists($object, 'fill')) {
2021-06-04 22:51:52 +08:00
$object->fill($array);
2021-06-07 19:06:26 +08:00
} else {
foreach ($array as $key => $value) {
2021-06-04 22:51:52 +08:00
$object->{$key} = $value;
}
}
return $object;
}
/**
* Generate new entities from the database
*
* @param int|null $count Optional number to create a collection
2021-06-08 12:11:23 +08:00
* @param bool $mock Whether to execute or mock the insertion
2021-06-04 22:51:52 +08:00
*
* @throws FrameworkException
*
* @return array|object An array or object (based on returnType), or an array of returnTypes
2021-06-04 22:51:52 +08:00
*/
2021-07-09 23:13:08 +08:00
public function create(?int $count = null, bool $mock = false)
2021-06-04 22:51:52 +08:00
{
// Intercept mock requests
2021-06-07 19:06:26 +08:00
if ($mock) {
2021-06-04 22:51:52 +08:00
return $this->createMock($count);
}
$ids = [];
// Iterate over new entities and insert each one, storing insert IDs
2021-06-07 19:06:26 +08:00
foreach ($this->make($count ?? 1) as $result) {
if ($id = $this->model->insert($result, true)) {
2021-06-04 22:51:52 +08:00
$ids[] = $id;
self::upCount($this->model->table);
2021-06-04 22:51:52 +08:00
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
2021-06-07 19:06:26 +08:00
if (method_exists($this->model, 'withDeleted')) {
2021-06-04 22:51:52 +08:00
$this->model->withDeleted();
}
return $this->model->find($count === null ? reset($ids) : $ids);
2021-06-04 22:51:52 +08:00
}
/**
* Generate new database entities without actually inserting them
*
* @param int|null $count Optional number to create a collection
2021-06-04 22:51:52 +08:00
*
2021-06-08 12:11:23 +08:00
* @return array|object An array or object (based on returnType), or an array of returnTypes
2021-06-04 22:51:52 +08:00
*/
2021-07-09 23:13:08 +08:00
protected function createMock(?int $count = null)
2021-06-04 22:51:52 +08:00
{
2021-06-07 19:06:26 +08:00
switch ($this->model->dateFormat) {
2021-06-04 22:51:52 +08:00
case 'datetime':
$datetime = date('Y-m-d H:i:s');
2021-06-07 03:26:46 +02:00
break;
2021-06-04 22:51:52 +08:00
case 'date':
$datetime = date('Y-m-d');
2021-06-07 03:26:46 +02:00
break;
2021-06-04 22:51:52 +08:00
default:
$datetime = time();
}
// Determine which fields we will need
$fields = [];
2021-06-07 19:06:26 +08:00
if (! empty($this->model->useTimestamps)) {
2021-06-04 22:51:52 +08:00
$fields[$this->model->createdField] = $datetime; // @phpstan-ignore-line
$fields[$this->model->updatedField] = $datetime; // @phpstan-ignore-line
}
2021-06-07 19:06:26 +08:00
if (! empty($this->model->useSoftDeletes)) {
2021-06-04 22:51:52 +08:00
$fields[$this->model->deletedField] = null; // @phpstan-ignore-line
}
// Iterate over new entities and add the necessary fields
$return = [];
2021-06-07 19:06:26 +08:00
foreach ($this->make($count ?? 1) as $i => $result) {
2021-06-04 22:51:52 +08:00
// Set the ID
$fields[$this->model->primaryKey] = $i;
// Merge fields
2021-06-07 19:06:26 +08:00
if (is_array($result)) {
2021-06-04 22:51:52 +08:00
$result = array_merge($result, $fields);
2021-06-07 19:06:26 +08:00
} else {
foreach ($fields as $key => $value) {
2021-06-04 22:51:52 +08:00
$result->{$key} = $value;
}
}
$return[] = $result;
}
return $count === null ? reset($return) : $return;
2021-06-04 22:51:52 +08:00
}
2020-05-05 19:50:06 +00:00
}