diff --git a/composer.json b/composer.json index f6c4216..267def6 100644 --- a/composer.json +++ b/composer.json @@ -5,15 +5,13 @@ "keywords": ["laravel", "eloquent", "database", "faker"], "license": "MIT", "require": { - "php": ">=8.1", - "illuminate/database": "^10", - "fakerphp/faker": "^1", - "doctrine/dbal": "^3" + "illuminate/database": "^11", + "fakerphp/faker": "^1" }, "require-dev": { "guidocella/laravel-multilingual": "*", - "laravel/laravel": "^10", - "phpunit/phpunit": "^10" + "laravel/laravel": "^11", + "phpunit/phpunit": "^11" }, "autoload": { "psr-4": { diff --git a/src/ColumnTypeGuesser.php b/src/ColumnTypeGuesser.php index dcabbc3..4e85d57 100644 --- a/src/ColumnTypeGuesser.php +++ b/src/ColumnTypeGuesser.php @@ -3,8 +3,9 @@ namespace GuidoCella\EloquentPopulator; use Closure; -use Doctrine\DBAL\Schema\Column; use Faker\Generator; +use Illuminate\Support\Arr; +use Illuminate\Support\Str; class ColumnTypeGuesser { @@ -15,24 +16,32 @@ public function __construct(Generator $generator) $this->generator = $generator; } - public function guessFormat(Column $column, string $tableName): ?Closure + public function guessFormat(array $column): ?Closure { - switch ($column->getType()->getName()) { + switch ($column['type_name']) { case 'smallint': - return fn () => rand(0, 65535); + return fn () => rand(0, 32767); + case 'int': case 'integer': return fn () => rand(0, 2147483647); case 'bigint': - return fn () => rand(0, intval('18446744073709551615')); + return fn () => rand(0, 9223372036854775807); case 'float': - return fn () => rand(0, 4294967295) / rand(1, 4294967295); + case 'double': + return fn () => $this->generator->randomFloat(); case 'decimal': - $maxDigits = $column->getPrecision(); - $maxDecimalDigits = $column->getScale(); + case 'numeric': + // The precision is unavailable with SQLite. + if ($column['type_name'] === 'numeric') { + $maxDigits = 3; + $maxDecimalDigits = 1; + } else { + [$maxDigits, $maxDecimalDigits] = explode(',', Str::before(Str::after($column['type'], '('), ')')); + } $max = 10 ** ($maxDigits - $maxDecimalDigits); @@ -47,8 +56,12 @@ public function guessFormat(Column $column, string $tableName): ?Closure return $value; }; - case 'string': - $size = $column->getLength() ?: 60; + case 'varchar': + case 'char': + $size = Str::before(Str::after($column['type'], '('), ')'); + if ($size === 'varchar') { // SQLite + $size = 5; + } // If Faker's text() $maxNbChars argument is greater than 99, // the text it generates can have new lines which are ignored by non-textarea inputs @@ -57,46 +70,44 @@ public function guessFormat(Column $column, string $tableName): ?Closure $size = 99; } - return function () use ($size, $column, $tableName) { + return function () use ($size) { if ($size >= 5) { return $this->generator->text($size); } - $columnName = "$tableName.{$column->getName()}"; - - throw new \InvalidArgumentException( - "$columnName is a string shorter than 5 characters," - ." but Faker's text() can only generate text of at least 5 characters.".PHP_EOL - ."Please specify a more accurate formatter for $columnName." - ); - - // Of course we could just use str_random($size) here, - // but for the CHAR columns for which I got this error - // I found that it was better to specify a more precise formatter anyway, - // e.g. $faker->countryCode for sender_country. + return Str::random($size); }; case 'text': return fn () => $this->generator->text(); - case 'guid': + case 'uuid': return fn () => $this->generator->uuid(); case 'date': case 'datetime': case 'datetimetz': + case 'timestamp': return fn () => $this->generator->datetime(); case 'time': return fn () => $this->generator->time(); - case 'boolean': - return fn () => $this->generator->boolean(); - // Unfortunately Doctrine maps all TINYINT to BooleanType. + case 'tinyint': + if ($column['type'] === 'tinyint(1)') { + return fn () => $this->generator->boolean(); + } + + return fn () => rand(0, 127); + case 'json': case 'json_array': + case 'longtext': // MariaDB return fn () => json_encode([$this->generator->word() => $this->generator->word()]); + case 'enum': + return fn () => Arr::random(explode(',', str_replace("'", '', substr($column['type'], 5, -1)))); + default: return null; } diff --git a/src/ModelPopulator.php b/src/ModelPopulator.php index 35fefc0..18dacbb 100644 --- a/src/ModelPopulator.php +++ b/src/ModelPopulator.php @@ -3,14 +3,12 @@ namespace GuidoCella\EloquentPopulator; use Faker\Generator; -use Faker\Guesser\Name; use GuidoCella\Multilingual\Translatable; use Illuminate\Container\Container; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Arr; -use Illuminate\Support\Str; class ModelPopulator { @@ -18,67 +16,16 @@ class ModelPopulator protected Generator $generator; - /** - * @var \Doctrine\DBAL\Schema\Column[] - */ protected array $columns = []; public function __construct(string $modelClass) { $this->model = new $modelClass(); $this->generator = Container::getInstance()->make(Generator::class); - $this->setColumns(); - } - - protected function setColumns(): void - { - $schema = $this->model->getConnection()->getDoctrineSchemaManager(); - $platform = $schema->getDatabasePlatform(); - - // Prevent a DBALException if the table contains an enum. - $platform->registerDoctrineTypeMapping('enum', 'string'); - - [$table, $database] = $this->getTableAndDatabase(); - - $this->columns = $this->model->getConnection()->getDoctrineConnection()->fetchAllAssociative( - $platform->getListTableColumnsSQL($table, $database) + $this->columns = array_filter( + $this->model->getConnection()->getSchemaBuilder()->getColumns($this->model->getTable()), + fn ($column) => $column['generation'] === null, // filter out virtual columns ); - - $this->rejectVirtualColumns(); - - $columns = $this->columns; - $this->columns = (fn () => $this->_getPortableTableColumnList($table, $database, $columns))->call($schema); - - $this->unquoteColumnNames($platform->getIdentifierQuoteCharacter()); - } - - protected function getTableAndDatabase(): array - { - $table = $this->model->getConnection()->getTablePrefix().$this->model->getTable(); - - if (strpos($table, '.')) { - [$database, $table] = explode('.', $table); - } - - return [$table, $database ?? null]; - } - - // For MySql and MariaDB - protected function rejectVirtualColumns() - { - $this->columns = array_filter($this->columns, fn ($column) => !isset($column['Extra']) || !Str::contains($column['Extra'], 'VIRTUAL')); - } - - /** - * Unquote column names that have been quoted by Doctrine because they are reserved keywords. - */ - protected function unquoteColumnNames(string $quoteCharacter): void - { - foreach ($this->columns as $columnName => $columnData) { - if (Str::startsWith($columnName, $quoteCharacter)) { - $this->columns[substr($columnName, 1, -1)] = Arr::pull($this->columns, $columnName); - } - } } public function guessFormatters(): array @@ -88,41 +35,35 @@ public function guessFormatters(): array $nameGuesser = new Name($this->generator); $columnTypeGuesser = new ColumnTypeGuesser($this->generator); - foreach ($this->columns as $columnName => $column) { - if ($columnName === $this->model->getKeyName() && $column->getAutoincrement()) { + foreach ($this->columns as $column) { + if ($column['name'] === $this->model->getKeyName() && $column['auto_increment']) { continue; } if ( method_exists($this->model, 'getDeletedAtColumn') - && $columnName === $this->model->getDeletedAtColumn() + && $column['name'] === $this->model->getDeletedAtColumn() ) { continue; } if ( !Populator::$seeding - && in_array($columnName, [$this->model->getCreatedAtColumn(), $this->model->getUpdatedAtColumn()]) + && in_array($column['name'], [$this->model->getCreatedAtColumn(), $this->model->getUpdatedAtColumn()]) ) { continue; } - $formatter = $nameGuesser->guessFormat( - $columnName, - $column->getLength() - ) ?? $columnTypeGuesser->guessFormat( - $column, - $this->model->getTable() - ); + $formatter = $nameGuesser->guessFormat($column) ?? $columnTypeGuesser->guessFormat($column); if (!$formatter) { continue; } - if ($column->getNotnull() || !Populator::$seeding) { - $formatters[$columnName] = $formatter; + if (!$column['nullable'] || !Populator::$seeding) { + $formatters[$column['name']] = $formatter; } else { - $formatters[$columnName] = fn () => rand(0, 1) ? $formatter() : null; + $formatters[$column['name']] = fn () => rand(0, 1) ? $formatter() : null; } } @@ -190,11 +131,12 @@ protected function associateBelongsTo(array &$formatters, BelongsTo $relation): { $relatedClass = get_class($relation->getRelated()); $foreignKey = $relation->getForeignKeyName(); + $foreignKeyColumn = Arr::first($this->columns, fn ($column) => $column['name'] === $foreignKey); // Ignore dynamic relations in which the foreign key is selected with a subquery. // https://reinink.ca/articles/dynamic-relationships-in-laravel-using-subqueries // (superseded by Has One Of Many relationships) - if (!isset($this->columns[$foreignKey])) { + if (!$foreignKeyColumn) { return; } @@ -214,7 +156,7 @@ protected function associateBelongsTo(array &$formatters, BelongsTo $relation): $relatedFactory = $relatedFactoryClass::new(); - if ($this->columns[$foreignKey]->getNotnull() || !Populator::$seeding) { + if (!$foreignKeyColumn['nullable'] || !Populator::$seeding) { $formatters[$foreignKey] = $relatedFactory; } else { $formatters[$foreignKey] = fn () => rand(0, 1) ? $relatedFactory : null; diff --git a/src/Name.php b/src/Name.php new file mode 100644 index 0000000..5393bce --- /dev/null +++ b/src/Name.php @@ -0,0 +1,130 @@ +generator = $generator; + } + + public function guessFormat(array $column): ?Closure + { + $generator = $this->generator; + $name = strtolower($column['name']); + $size = Str::before(Str::after($column['type'], '('), ')'); + if ($size === 'varchar') { // SQLite + $size = null; + } + + if (preg_match('/^is[_A-Z]/', $name)) { + return fn () => $generator->boolean(); + } + + if (preg_match('/(_a|A)t$/', $name)) { + return fn () => $generator->dateTime(); + } + + switch (str_replace('_', '', $name)) { + case 'firstname': + return fn () => $generator->firstName(); + + case 'lastname': + return fn () => $generator->lastName(); + + case 'username': + case 'login': + return fn () => $generator->userName(); + + case 'email': + case 'emailaddress': + return fn () => $generator->email(); + + case 'phonenumber': + case 'phone': + case 'telephone': + case 'telnumber': + return fn () => $generator->phoneNumber(); + + case 'address': + return fn () => $generator->address(); + + case 'city': + case 'town': + return fn () => $generator->city(); + + case 'streetaddress': + return fn () => $generator->streetAddress(); + + case 'postcode': + case 'zipcode': + return fn () => $generator->postcode(); + + case 'state': + return fn () => $generator->state(); + + case 'county': + if ($this->generator->locale === 'en_US') { + return fn () => sprintf('%s County', $generator->city()); + } + + return fn () => $generator->state(); + + case 'country': + switch ($size) { + case 2: + return fn () => $generator->countryCode(); + + case 3: + return fn () => $generator->countryISOAlpha3(); + + case 5: + case 6: + return fn () => $generator->locale(); + + default: + return fn () => $generator->country(); + } + + break; + + case 'locale': + return fn () => $generator->locale(); + + case 'currency': + case 'currencycode': + return fn () => $generator->currencyCode(); + + case 'url': + case 'website': + return fn () => $generator->url(); + + case 'company': + case 'companyname': + case 'employer': + return fn () => $generator->company(); + + case 'title': + if ($size && $size <= 10) { + return fn () => $generator->title(); + } + + return fn () => $generator->sentence(); + + case 'body': + case 'summary': + case 'article': + case 'description': + return fn () => $generator->text(); + } + + return null; + } +} diff --git a/tests/Migrations/2014_10_12_000000_create_users_table.php b/tests/Migrations/2014_10_12_000000_create_users_table.php index 07c7a04..560f383 100644 --- a/tests/Migrations/2014_10_12_000000_create_users_table.php +++ b/tests/Migrations/2014_10_12_000000_create_users_table.php @@ -14,22 +14,29 @@ public function up() Schema::create('users', function (Blueprint $table) { $table->increments('id'); $table->string('email')->nullable(); - $table->smallInteger('smallint')->nullable(); $table->integer('integer'); $table->bigInteger('bigint'); $table->decimal('decimal', 6, 4); $table->float('float'); $table->string('string'); + $table->char('char', 2); $table->text('text'); + $table->json('json'); $table->date('date'); $table->dateTime('datetime'); + $table->dateTimeTz('datetimetz'); $table->time('time'); $table->timestamp('timestamp'); $table->boolean('boolean'); + $table->uuid('uuid'); $table->unsignedInteger('company_id')->nullable(); $table->unsignedInteger('friend_id')->nullable(); + + $table->integer('virtual')->virtualAs('`integer` + 1'); + + $table->enum('enum', ['foo', 'bar']); }); } } diff --git a/tests/Models/User.php b/tests/Models/User.php index 1784080..474d388 100644 --- a/tests/Models/User.php +++ b/tests/Models/User.php @@ -4,8 +4,6 @@ use Illuminate\Database\Eloquent\Model; -// use GuidoCella\Multilingual\Translatable; - class User extends Model { public function company() diff --git a/tests/PopulatorTest.php b/tests/PopulatorTest.php index 51102ff..856664a 100644 --- a/tests/PopulatorTest.php +++ b/tests/PopulatorTest.php @@ -3,6 +3,7 @@ namespace GuidoCella\EloquentPopulator; use Closure; +use DateTime; use GuidoCella\EloquentPopulator\Factories\CompanyFactory; use GuidoCella\EloquentPopulator\Models\Company; use GuidoCella\EloquentPopulator\Models\User; @@ -17,14 +18,22 @@ public function testColumnTypeGuesser() $this->assertIsInt($user['bigint']); $this->assertIsFloat($user['decimal']); $this->assertIsFloat($user['float']); - $this->assertTrue(is_string($user['string']) && strlen($user['string'])); - $this->assertTrue(is_string($user['text']) && strlen($user['text'])); - $this->assertInstanceOf(\DateTime::class, $user['date']); - $this->assertInstanceOf(\DateTime::class, $user['datetime']); - $this->assertInstanceOf(\DateTime::class, $user['timestamp']); + $this->assertIsString($user['string']); + $this->assertIsString($user['char']); + $this->assertIsString($user['text']); + $this->assertInstanceOf(DateTime::class, $user['date']); + $this->assertInstanceOf(DateTime::class, $user['datetime']); + $this->assertInstanceOf(DateTime::class, $user['timestamp']); $this->assertMatchesRegularExpression('/\d\d:\d\d:\d\d/', $user['time']); $this->assertIsBool($user['boolean']); - // DATETIME-TZ, JSON and UUID are not supported by SQLite, so there's no point in testing them. + $this->assertFalse(isset($user['virtual'])); + + if (config('database.default') === 'mariadb') { + $this->assertIsString($user['json']); + $this->assertIsString($user['uuid']); + $this->assertInstanceOf(DateTime::class, $user['datetimetz']); + $this->assertContains($user['enum'], ['foo', 'bar']); + } } public function testColumnNameGuesser() @@ -52,8 +61,8 @@ public function testTranslatableColumns() $this->assertIsArray($name); $this->assertArrayHasKey('en', $name); $this->assertArrayHasKey('es', $name); - $this->assertGreaterThan(1, strlen($name['en'])); - $this->assertGreaterThan(1, strlen($name['es'])); + $this->assertIsString($name['en']); + $this->assertIsString($name['es']); } } // vim: nolinebreak diff --git a/tests/PopulatorTestCase.php b/tests/PopulatorTestCase.php index bf1dd43..ef334fa 100644 --- a/tests/PopulatorTestCase.php +++ b/tests/PopulatorTestCase.php @@ -5,6 +5,7 @@ use Illuminate\Contracts\Console\Kernel; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Foundation\Testing\TestCase; +use Illuminate\Support\Facades\Schema; abstract class PopulatorTestCase extends TestCase { @@ -24,6 +25,9 @@ protected function setUp(): void $this->app['config']->set([ 'database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:', + // 'database.default' => 'mariadb', + // 'database.connections.mariadb.host' => 'localhost', + // 'database.connections.mariadb.database' => 'populator', 'multilingual.locales' => ['en', 'es'], ]); @@ -34,12 +38,14 @@ protected function setUp(): void protected function migrate() { + Schema::dropAllTables(); + $migrator = $this->app['migrator']; foreach ($migrator->getMigrationFiles(__DIR__.'/Migrations') as $file) { require_once $file; - ($migrator->resolve($migrator->getMigrationName($file)))->up(); + $migrator->resolve($migrator->getMigrationName($file))->up(); } } }