From 5f61292fc7012fc9ac4867cfd922a7e22709c45f Mon Sep 17 00:00:00 2001 From: Mohammed S Shurrab Date: Thu, 30 Jul 2020 22:31:16 +0300 Subject: [PATCH 1/4] lighter trace command to increase the reusability and testability of the trace methods --- src/Commands/TraceCommand.php | 240 ++------------------------------- src/Tracer.php | 246 ++++++++++++++++++++++++++++++++++ 2 files changed, 258 insertions(+), 228 deletions(-) create mode 100644 src/Tracer.php diff --git a/src/Commands/TraceCommand.php b/src/Commands/TraceCommand.php index 84dc7e50..b55805ad 100644 --- a/src/Commands/TraceCommand.php +++ b/src/Commands/TraceCommand.php @@ -4,6 +4,7 @@ use Blueprint\Blueprint; use Blueprint\EnumType; +use Blueprint\Tracer; use Doctrine\DBAL\Types\Type; use Illuminate\Console\Command; use Illuminate\Database\Eloquent\Model; @@ -29,15 +30,19 @@ class TraceCommand extends Command /** @var Filesystem $files */ protected $files; + /** @var Tracer */ + private $tracer; + /** * @param Filesystem $files - * @param \Illuminate\Contracts\View\Factory $view + * @param Tracer $tracer */ - public function __construct(Filesystem $files) + public function __construct(Filesystem $files,Tracer $tracer) { parent::__construct(); $this->files = $files; + $this->tracer = $tracer; } /** @@ -47,236 +52,15 @@ public function __construct(Filesystem $files) */ public function handle() { - $definitions = []; - foreach ($this->appClasses() as $class) { - $model = $this->loadModel($class); - if (is_null($model)) { - continue; - } - - $definitions[$this->relativeClassName($model)] = $this->translateColumns($this->mapColumns($this->extractColumns($model))); - } + $blueprint = resolve(Blueprint::class); + $definitions = $this->tracer->execute($blueprint,$this->files); if (empty($definitions)) { $this->error('No models found'); - - return; - } - - $blueprint = new Blueprint(); - - $cache = []; - if ($this->files->exists('.blueprint')) { - $cache = $blueprint->parse($this->files->get('.blueprint')); - } - - $cache['models'] = $definitions; - - $this->files->put('.blueprint', $blueprint->dump($cache)); - - $this->info('Traced ' . count($definitions) . ' ' . Str::plural('model', count($definitions))); - } - - private function appClasses() - { - $dir = Blueprint::appPath(); - - if (config('blueprint.models_namespace')) { - $dir .= '/' . str_replace('\\', '/', config('blueprint.models_namespace')); - } - - if (!$this->files->exists($dir)) { - return []; - } - - return array_map(function (\SplFIleInfo $file) { - return str_replace( - [Blueprint::appPath() . '/', '/'], - [config('blueprint.namespace') . '\\', '\\'], - $file->getPath() . '/' . $file->getBasename('.php') - ); - }, $this->files->allFiles($dir)); - } - - private function loadModel(string $class) - { - if (!class_exists($class)) { - return null; - } - - $reflectionClass = new \ReflectionClass($class); - if ( - !$reflectionClass->isSubclassOf(\Illuminate\Database\Eloquent\Model::class) || - (class_exists('Jenssegers\Mongodb\Eloquent\Model') && - $reflectionClass->isSubclassOf('Jenssegers\Mongodb\Eloquent\Model')) - ) { - return null; - } - - return $this->laravel->make($class); - } - - private function extractColumns(Model $model) - { - $table = $model->getConnection()->getTablePrefix() . $model->getTable(); - $schema = $model->getConnection()->getDoctrineSchemaManager(); - - if (!Type::hasType('enum')) { - Type::addType('enum', EnumType::class); - $databasePlatform = $schema->getDatabasePlatform(); - $databasePlatform->registerDoctrineTypeMapping('enum', 'enum'); - } - - $database = null; - if (strpos($table, '.')) { - [$database, $table] = explode('.', $table); - } - - $columns = $schema->listTableColumns($table, $database); - - $uses_enums = collect($columns)->contains(function ($column) { - return $column->getType() instanceof \Blueprint\EnumType; - }); - - if ($uses_enums) { - $definitions = $model->getConnection()->getDoctrineConnection()->fetchAll($schema->getDatabasePlatform()->getListTableColumnsSQL($table, $database)); - - collect($columns)->filter(function ($column) { - return $column->getType() instanceof \Blueprint\EnumType; - })->each(function (&$column, $key) use ($definitions) { - $definition = collect($definitions)->where('Field', $key)->first(); - - $column->options = \Blueprint\EnumType::extractOptions($definition['Type']); - }); - } - - return $columns; - } - - /** - * @param \Doctrine\DBAL\Schema\Column[] $columns - */ - private function mapColumns($columns) - { - return collect($columns) - ->map([self::class, 'columns']) - ->toArray(); - } - - public static function columns(\Doctrine\DBAL\Schema\Column $column, string $key) - { - $attributes = []; - - $type = self::translations($column->getType()->getName()); - - if (in_array($type, ['decimal', 'float'])) { - if ($column->getPrecision()) { - $type .= ':' . $column->getPrecision(); - } - if ($column->getScale()) { - $type .= ',' . $column->getScale(); - } - } elseif ($type === 'string' && $column->getLength()) { - if ($column->getLength() !== 255) { - $type .= ':' . $column->getLength(); - } - } elseif ($type === 'text') { - if ($column->getLength() > 65535) { - $type = 'longtext'; - } - } elseif ($type === 'enum' && !empty($column->options)) { - $type .= ':' . implode(',', $column->options); - } - - // TODO: guid/uuid - - $attributes[] = $type; - - if ($column->getUnsigned()) { - $attributes[] = 'unsigned'; - } - - if (!$column->getNotnull()) { - $attributes[] = 'nullable'; - } - - if ($column->getAutoincrement()) { - $attributes[] = 'autoincrement'; - } - - if (!is_null($column->getDefault())) { - $attributes[] = 'default:' . $column->getDefault(); - } - - return implode(' ', $attributes); - } - - private static function translations(string $type) - { - static $mappings = [ - 'array' => 'string', - 'bigint' => 'biginteger', - 'binary' => 'binary', - 'blob' => 'binary', - 'boolean' => 'boolean', - 'date' => 'date', - 'date_immutable' => 'date', - 'dateinterval' => 'date', - 'datetime' => 'datetime', - 'datetime_immutable' => 'datetime', - 'datetimetz' => 'datetimetz', - 'datetimetz_immutable' => 'datetimetz', - 'decimal' => 'decimal', - 'enum' => 'enum', - 'float' => 'float', - 'guid' => 'string', - 'integer' => 'integer', - 'json' => 'json', - 'object' => 'string', - 'simple_array' => 'string', - 'smallint' => 'smallinteger', - 'string' => 'string', - 'text' => 'text', - 'time' => 'time', - 'time_immutable' => 'time', - ]; - - return $mappings[$type] ?? 'string'; - } - - private function translateColumns(array $columns) - { - if (isset($columns['id']) && strpos($columns['id'], 'autoincrement') !== false && strpos($columns['id'], 'integer') !== false) { - unset($columns['id']); - } - - if (isset($columns[Model::CREATED_AT]) && isset($columns[Model::UPDATED_AT])) { - if (strpos($columns[Model::CREATED_AT], 'datetimetz') !== false) { - $columns['timestampstz'] = 'timestampsTz'; - } - - unset($columns[Model::CREATED_AT]); - unset($columns[Model::UPDATED_AT]); - } - - if (isset($columns['deleted_at'])) { - if (strpos($columns['deleted_at'], 'datetimetz') !== false) { - $columns['softdeletestz'] = 'softDeletesTz'; - } - - unset($columns['deleted_at']); - } - - return $columns; - } - - private function relativeClassName($model) - { - $name = Blueprint::relativeNamespace(get_class($model)); - if (config('blueprint.models_namespace')) { - return $name; + }else{ + $this->info('Traced ' . count($definitions) . ' ' . Str::plural('model', count($definitions))); } - return ltrim(str_replace(config('blueprint.models_namespace'), '', $name), '\\'); + return 0; } } diff --git a/src/Tracer.php b/src/Tracer.php new file mode 100644 index 00000000..7567ac05 --- /dev/null +++ b/src/Tracer.php @@ -0,0 +1,246 @@ +files = $files; + + $definitions = []; + foreach ($this->appClasses() as $class) { + $model = $this->loadModel($class); + if (is_null($model)) { + continue; + } + + $definitions[$this->relativeClassName($model)] = $this->translateColumns($this->mapColumns($this->extractColumns($model))); + } + + if (empty($definitions)) { + return $definitions; + } + + $cache = []; + if ($files->exists('.blueprint')) { + $cache = $blueprint->parse($files->get('.blueprint')); + } + + $cache['models'] = $definitions; + + $files->put('.blueprint', $blueprint->dump($cache)); + + return $definitions; + } + + private function appClasses() + { + $dir = Blueprint::appPath(); + + if (config('blueprint.models_namespace')) { + $dir .= '/' . str_replace('\\', '/', config('blueprint.models_namespace')); + } + + if (!$this->files->exists($dir)) { + return []; + } + + return array_map(function (\SplFIleInfo $file) { + return str_replace( + [Blueprint::appPath() . '/', '/'], + [config('blueprint.namespace') . '\\', '\\'], + $file->getPath() . '/' . $file->getBasename('.php') + ); + }, $this->files->allFiles($dir)); + } + + private function loadModel(string $class) + { + if (!class_exists($class)) { + return null; + } + + $reflectionClass = new \ReflectionClass($class); + if ( + !$reflectionClass->isSubclassOf(\Illuminate\Database\Eloquent\Model::class) || + (class_exists('Jenssegers\Mongodb\Eloquent\Model') && + $reflectionClass->isSubclassOf('Jenssegers\Mongodb\Eloquent\Model')) + ) { + return null; + } + + return app($class); + } + + private function extractColumns(Model $model) + { + $table = $model->getConnection()->getTablePrefix() . $model->getTable(); + $schema = $model->getConnection()->getDoctrineSchemaManager(); + + if (!Type::hasType('enum')) { + Type::addType('enum', EnumType::class); + $databasePlatform = $schema->getDatabasePlatform(); + $databasePlatform->registerDoctrineTypeMapping('enum', 'enum'); + } + + $database = null; + if (strpos($table, '.')) { + [$database, $table] = explode('.', $table); + } + + $columns = $schema->listTableColumns($table, $database); + + $uses_enums = collect($columns)->contains(function ($column) { + return $column->getType() instanceof \Blueprint\EnumType; + }); + + if ($uses_enums) { + $definitions = $model->getConnection()->getDoctrineConnection()->fetchAll($schema->getDatabasePlatform()->getListTableColumnsSQL($table, $database)); + + collect($columns)->filter(function ($column) { + return $column->getType() instanceof \Blueprint\EnumType; + })->each(function (&$column, $key) use ($definitions) { + $definition = collect($definitions)->where('Field', $key)->first(); + + $column->options = \Blueprint\EnumType::extractOptions($definition['Type']); + }); + } + + return $columns; + } + + /** + * @param \Doctrine\DBAL\Schema\Column[] $columns + */ + private function mapColumns($columns) + { + return collect($columns) + ->map([self::class, 'columns']) + ->toArray(); + } + + public static function columns(\Doctrine\DBAL\Schema\Column $column, string $key) + { + $attributes = []; + + $type = self::translations($column->getType()->getName()); + + if (in_array($type, ['decimal', 'float'])) { + if ($column->getPrecision()) { + $type .= ':' . $column->getPrecision(); + } + if ($column->getScale()) { + $type .= ',' . $column->getScale(); + } + } elseif ($type === 'string' && $column->getLength()) { + if ($column->getLength() !== 255) { + $type .= ':' . $column->getLength(); + } + } elseif ($type === 'text') { + if ($column->getLength() > 65535) { + $type = 'longtext'; + } + } elseif ($type === 'enum' && !empty($column->options)) { + $type .= ':' . implode(',', $column->options); + } + + // TODO: guid/uuid + + $attributes[] = $type; + + if ($column->getUnsigned()) { + $attributes[] = 'unsigned'; + } + + if (!$column->getNotnull()) { + $attributes[] = 'nullable'; + } + + if ($column->getAutoincrement()) { + $attributes[] = 'autoincrement'; + } + + if (!is_null($column->getDefault())) { + $attributes[] = 'default:' . $column->getDefault(); + } + + return implode(' ', $attributes); + } + + private static function translations(string $type) + { + static $mappings = [ + 'array' => 'string', + 'bigint' => 'biginteger', + 'binary' => 'binary', + 'blob' => 'binary', + 'boolean' => 'boolean', + 'date' => 'date', + 'date_immutable' => 'date', + 'dateinterval' => 'date', + 'datetime' => 'datetime', + 'datetime_immutable' => 'datetime', + 'datetimetz' => 'datetimetz', + 'datetimetz_immutable' => 'datetimetz', + 'decimal' => 'decimal', + 'enum' => 'enum', + 'float' => 'float', + 'guid' => 'string', + 'integer' => 'integer', + 'json' => 'json', + 'object' => 'string', + 'simple_array' => 'string', + 'smallint' => 'smallinteger', + 'string' => 'string', + 'text' => 'text', + 'time' => 'time', + 'time_immutable' => 'time', + ]; + + return $mappings[$type] ?? 'string'; + } + + private function translateColumns(array $columns) + { + if (isset($columns['id']) && strpos($columns['id'], 'autoincrement') !== false && strpos($columns['id'], 'integer') !== false) { + unset($columns['id']); + } + + if (isset($columns[Model::CREATED_AT]) && isset($columns[Model::UPDATED_AT])) { + if (strpos($columns[Model::CREATED_AT], 'datetimetz') !== false) { + $columns['timestampstz'] = 'timestampsTz'; + } + + unset($columns[Model::CREATED_AT]); + unset($columns[Model::UPDATED_AT]); + } + + if (isset($columns['deleted_at'])) { + if (strpos($columns['deleted_at'], 'datetimetz') !== false) { + $columns['softdeletestz'] = 'softDeletesTz'; + } + + unset($columns['deleted_at']); + } + + return $columns; + } + + private function relativeClassName($model) + { + $name = Blueprint::relativeNamespace(get_class($model)); + if (config('blueprint.models_namespace')) { + return $name; + } + + return ltrim(str_replace(config('blueprint.models_namespace'), '', $name), '\\'); + } +} From 777eebbde36d3341a55b738f542c86efcc836898 Mon Sep 17 00:00:00 2001 From: Mohammed S Shurrab Date: Sat, 15 Aug 2020 11:43:48 +0300 Subject: [PATCH 2/4] Use spy in EraseCommandTest::it_calls_the_trace_command --- src/BlueprintServiceProvider.php | 2 +- tests/Feature/Commands/EraseCommandTest.php | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/BlueprintServiceProvider.php b/src/BlueprintServiceProvider.php index bf770e49..c219b7ee 100644 --- a/src/BlueprintServiceProvider.php +++ b/src/BlueprintServiceProvider.php @@ -65,7 +65,7 @@ function ($app) { $this->app->bind( 'command.blueprint.trace', function ($app) { - return new TraceCommand($app['files']); + return new TraceCommand($app['files'], app(Tracer::class)); } ); $this->app->bind( diff --git a/tests/Feature/Commands/EraseCommandTest.php b/tests/Feature/Commands/EraseCommandTest.php index 4ca07a39..0943ca9a 100644 --- a/tests/Feature/Commands/EraseCommandTest.php +++ b/tests/Feature/Commands/EraseCommandTest.php @@ -2,6 +2,8 @@ namespace Tests\Feature\Commands; +use Blueprint\Blueprint; +use Blueprint\Tracer; use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; use Tests\TestCase; @@ -77,7 +79,12 @@ public function it_calls_the_trace_command() $filesystem->expects('get')->with('.blueprint')->andReturn("other: test.php"); $filesystem->expects('put')->with('.blueprint', "other: test.php\n"); + $tracer = $this->spy(Tracer::class); + $this->artisan('blueprint:erase') ->assertExitCode(0); + + $tracer->shouldHaveReceived('execute') + ->with(resolve(Blueprint::class), $filesystem); } } From 1c631d921346cc97e20941547b68becebdd68181 Mon Sep 17 00:00:00 2001 From: Mohammed S Shurrab Date: Sat, 15 Aug 2020 11:56:08 +0300 Subject: [PATCH 3/4] test the trace command output --- tests/Feature/Commands/TraceCommandTest.php | 55 +++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 tests/Feature/Commands/TraceCommandTest.php diff --git a/tests/Feature/Commands/TraceCommandTest.php b/tests/Feature/Commands/TraceCommandTest.php new file mode 100644 index 00000000..8781e73b --- /dev/null +++ b/tests/Feature/Commands/TraceCommandTest.php @@ -0,0 +1,55 @@ +makePartial(); + $this->swap('files', $filesystem); + + $tracer = $this->mock(Tracer::class); + + $tracer->shouldReceive('execute') + ->with(resolve(Blueprint::class), $filesystem) + ->andReturn([]); + + $this->artisan('blueprint:trace') + ->assertExitCode(0) + ->expectsOutput('No models found'); + } + + /** @test */ + public function it_shows_the_number_of_traced_models() + { + $filesystem = \Mockery::mock(\Illuminate\Filesystem\Filesystem::class)->makePartial(); + $this->swap('files', $filesystem); + + $tracer = $this->mock(Tracer::class); + + $tracer->shouldReceive('execute') + ->with(resolve(Blueprint::class), $filesystem) + ->andReturn([ + "Model" => [], + "OtherModel" => [], + ]); + + $this->artisan('blueprint:trace') + ->assertExitCode(0) + ->expectsOutput('Traced 2 models'); + } +} From 1ea3273d4e5f763848e601b2e8cb98da9ea46a44 Mon Sep 17 00:00:00 2001 From: Mohammed S Shurrab Date: Sat, 15 Aug 2020 11:59:48 +0300 Subject: [PATCH 4/4] fix code style --- src/Commands/TraceCommand.php | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/Commands/TraceCommand.php b/src/Commands/TraceCommand.php index b55805ad..41ddcdcc 100644 --- a/src/Commands/TraceCommand.php +++ b/src/Commands/TraceCommand.php @@ -3,11 +3,8 @@ namespace Blueprint\Commands; use Blueprint\Blueprint; -use Blueprint\EnumType; use Blueprint\Tracer; -use Doctrine\DBAL\Types\Type; use Illuminate\Console\Command; -use Illuminate\Database\Eloquent\Model; use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Str; @@ -37,7 +34,7 @@ class TraceCommand extends Command * @param Filesystem $files * @param Tracer $tracer */ - public function __construct(Filesystem $files,Tracer $tracer) + public function __construct(Filesystem $files, Tracer $tracer) { parent::__construct(); @@ -53,11 +50,11 @@ public function __construct(Filesystem $files,Tracer $tracer) public function handle() { $blueprint = resolve(Blueprint::class); - $definitions = $this->tracer->execute($blueprint,$this->files); + $definitions = $this->tracer->execute($blueprint, $this->files); if (empty($definitions)) { $this->error('No models found'); - }else{ + } else { $this->info('Traced ' . count($definitions) . ' ' . Str::plural('model', count($definitions))); }