From f539533771bd97d74e915fe818ea758d864d85cb Mon Sep 17 00:00:00 2001 From: Dennis Ong Date: Thu, 27 Aug 2020 17:16:50 +1000 Subject: [PATCH 1/3] Add support for generating indexes (including composite indexes) in migrations --- src/Blueprint.php | 4 +- src/Generators/MigrationGenerator.php | 703 +++++++++--------- src/Lexers/ModelLexer.php | 8 + src/Models/Index.php | 25 + src/Models/Model.php | 11 + tests/Feature/BlueprintTest.php | 32 - .../Generator/MigrationGeneratorTest.php | 1 + tests/fixtures/drafts/indexes.yaml | 16 + tests/fixtures/migrations/indexes.php | 43 ++ 9 files changed, 463 insertions(+), 380 deletions(-) create mode 100644 src/Models/Index.php create mode 100644 tests/fixtures/drafts/indexes.yaml create mode 100644 tests/fixtures/migrations/indexes.php diff --git a/src/Blueprint.php b/src/Blueprint.php index c5383017..c40ab00a 100644 --- a/src/Blueprint.php +++ b/src/Blueprint.php @@ -29,8 +29,8 @@ public static function appPath() return str_replace('\\', '/', config('blueprint.app_path')); } - public function parse($content, $strip_dashes = true) - { + public function parse($content, $strip_dashes = false) + { $content = str_replace(["\r\n", "\r"], "\n", $content); if ($strip_dashes) { diff --git a/src/Generators/MigrationGenerator.php b/src/Generators/MigrationGenerator.php index f93ac779..70850d53 100644 --- a/src/Generators/MigrationGenerator.php +++ b/src/Generators/MigrationGenerator.php @@ -1,346 +1,357 @@ - "->onDelete('cascade')", - 'restrict' => "->onDelete('restrict')", - 'null' => "->onDelete('set null')", - 'no_action' => "->onDelete('no action')", - ]; - - const UNSIGNABLE_TYPES = [ - 'bigInteger', - 'decimal', - 'integer', - 'mediumInteger', - 'smallInteger', - 'tinyInteger', - ]; - - /** @var \Illuminate\Contracts\Filesystem\Filesystem */ - private $files; - - public function __construct($files) - { - $this->files = $files; - } - - public function output(Tree $tree, $overwrite = false): array - { - $output = []; - - $created_pivot_tables = []; - - $stub = $this->files->stub('migration.stub'); - - $sequential_timestamp = \Carbon\Carbon::now()->subSeconds(count($tree->models())); - - /** @var \Blueprint\Models\Model $model */ - foreach ($tree->models() as $model) { - $path = $this->getPath($model, $sequential_timestamp->addSecond(), $overwrite); - $action = $this->files->exists($path) ? 'updated' : 'created'; - $this->files->put($path, $this->populateStub($stub, $model)); - - $output[$action][] = $path; - - if (! empty($model->pivotTables())) { - foreach ($model->pivotTables() as $pivotSegments) { - $pivotTable = $this->getPivotTableName($pivotSegments); - $created_pivot_tables[$pivotTable] = $pivotSegments; - } - } - } - - foreach ($created_pivot_tables as $pivotTable => $pivotSegments) { - $path = $this->getPivotTablePath($pivotTable, $sequential_timestamp, $overwrite); - $action = $this->files->exists($path) ? 'updated' : 'created'; - $this->files->put($path, $this->populatePivotStub($stub, $pivotSegments)); - $created_pivot_tables[] = $pivotTable; - $output[$action][] = $path; - } - - return $output; - } - - public function types(): array - { - return ['migrations']; - } - - protected function populateStub(string $stub, Model $model) - { - $stub = str_replace('{{ class }}', $this->getClassName($model), $stub); - $stub = str_replace('{{ table }}', $model->tableName(), $stub); - $stub = str_replace('{{ definition }}', $this->buildDefinition($model), $stub); - - return $stub; - } - - protected function populatePivotStub(string $stub, array $segments) - { - $stub = str_replace('{{ class }}', $this->getPivotClassName($segments), $stub); - $stub = str_replace('{{ table }}', $this->getPivotTableName($segments), $stub); - $stub = str_replace('{{ definition }}', $this->buildPivotTableDefinition($segments), $stub); - - return $stub; - } - - protected function buildDefinition(Model $model) - { - $definition = ''; - - /** @var \Blueprint\Models\Column $column */ - foreach ($model->columns() as $column) { - $dataType = $column->dataType(); - - if ($column->name() === 'id' && $dataType === 'id') { - $dataType = 'bigIncrements'; - } elseif ($dataType === 'id') { - $dataType = 'unsignedBigInteger'; - } - - if (in_array($dataType, self::UNSIGNABLE_TYPES) && in_array('unsigned', $column->modifiers())) { - $dataType = 'unsigned'.ucfirst($dataType); - } - - if (in_array($dataType, self::NULLABLE_TYPES) && $column->isNullable()) { - $dataType = 'nullable'.ucfirst($dataType); - } - - $column_definition = self::INDENT; - if ($dataType === 'bigIncrements' && $this->isLaravel7orNewer()) { - $column_definition .= '$table->id('; - } elseif ($dataType === 'rememberToken') { - $column_definition .= '$table->rememberToken('; - } else { - $column_definition .= '$table->'.$dataType."('{$column->name()}'"; - } - - if (! empty($column->attributes()) && ! in_array($column->dataType(), ['id', 'uuid'])) { - $column_definition .= ', '; - if (in_array($column->dataType(), ['set', 'enum'])) { - $column_definition .= json_encode($column->attributes()); - } else { - $column_definition .= implode(', ', $column->attributes()); - } - } - $column_definition .= ')'; - - $modifiers = $column->modifiers(); - - $foreign = ''; - $foreign_modifier = $column->isForeignKey(); - - if ($this->shouldAddForeignKeyConstraint($column)) { - $foreign = $this->buildForeignKey( - $column->name(), - $foreign_modifier === 'foreign' ? null : $foreign_modifier, - $column->dataType(), - $column->attributes(), - $column->modifiers() - ); - - if ($column->dataType() === 'id' && $this->isLaravel7orNewer()) { - $column_definition = $foreign; - $foreign = ''; - } - - // TODO: unset the proper modifier - $modifiers = collect($modifiers)->reject(function ($modifier) { - return (is_array($modifier) && key($modifier) === 'foreign') - || (is_array($modifier) && key($modifier) === 'onDelete') - || $modifier === 'foreign' - || ($modifier === 'nullable' && $this->isLaravel7orNewer()); - }); - } - - foreach ($modifiers as $modifier) { - if (is_array($modifier)) { - $column_definition .= '->'.key($modifier).'('.current($modifier).')'; - } elseif ($modifier === 'unsigned' && Str::startsWith($dataType, 'unsigned')) { - continue; - } elseif ($modifier === 'nullable' && Str::startsWith($dataType, 'nullable')) { - continue; - } else { - $column_definition .= '->'.$modifier.'()'; - } - } - - $column_definition .= ';'.PHP_EOL; - if (! empty($foreign)) { - $column_definition .= $foreign.';'.PHP_EOL; - } - - $definition .= $column_definition; - } - - if ($model->usesSoftDeletes()) { - $definition .= self::INDENT.'$table->'.$model->softDeletesDataType().'();'.PHP_EOL; - } - - if ($model->morphTo()) { - $definition .= self::INDENT.sprintf('$table->unsignedBigInteger(\'%s\');', Str::lower($model->morphTo().'_id')).PHP_EOL; - $definition .= self::INDENT.sprintf('$table->string(\'%s\');', Str::lower($model->morphTo().'_type')).PHP_EOL; - } - - if ($model->usesTimestamps()) { - $definition .= self::INDENT.'$table->'.$model->timestampsDataType().'();'.PHP_EOL; - } - - return trim($definition); - } - - protected function buildPivotTableDefinition(array $segments) - { - $definition = ''; - - foreach ($segments as $segment) { - $column = Str::before(Str::lower($segment), ':'); - $references = 'id'; - $on = Str::plural($column); - $foreign = Str::singular($column).'_'.$references; - - if (! $this->isLaravel7orNewer()) { - $definition .= self::INDENT.'$table->unsignedBigInteger(\''.$foreign.'\');'.PHP_EOL; - } - - if (config('blueprint.use_constraints')) { - $definition .= $this->buildForeignKey($foreign, $on, 'id').';'.PHP_EOL; - } elseif ($this->isLaravel7orNewer()) { - $definition .= self::INDENT.'$table->foreignId(\''.$foreign.'\');'.PHP_EOL; - } - } - - return trim($definition); - } - - protected function buildForeignKey(string $column_name, ?string $on, string $type, array $attributes = [], array $modifiers = []) - { - if (is_null($on)) { - $table = Str::plural(Str::beforeLast($column_name, '_')); - $column = Str::afterLast($column_name, '_'); - } elseif (Str::contains($on, '.')) { - [$table, $column] = explode('.', $on); - $table = Str::snake($table); - } else { - $table = Str::plural($on); - $column = Str::afterLast($column_name, '_'); - } - - if ($type === 'id' && ! empty($attributes)) { - $table = Str::lower(Str::plural($attributes[0])); - } - - $on_delete_clause = collect($modifiers)->firstWhere('onDelete'); - $on_delete_clause = $on_delete_clause ? $on_delete_clause['onDelete'] : config('blueprint.on_delete', 'cascade'); - $on_delete_suffix = self::ON_DELETE_CLAUSES[$on_delete_clause]; - - if ($this->isLaravel7orNewer() && $type === 'id') { - $prefix = in_array('nullable', $modifiers) - ? '$table->foreignId'."('{$column_name}')->nullable()" - : '$table->foreignId'."('{$column_name}')"; - - if ($on_delete_clause === 'cascade') { - $on_delete_suffix = '->cascadeOnDelete()'; - } - if ($column_name === Str::singular($table).'_'.$column) { - return self::INDENT."{$prefix}->constrained(){$on_delete_suffix}"; - } - if ($column === 'id') { - return self::INDENT."{$prefix}->constrained('{$table}'){$on_delete_suffix}"; - } - - return self::INDENT."{$prefix}->constrained('{$table}', '{$column}'){$on_delete_suffix}"; - } - - return self::INDENT.'$table->foreign'."('{$column_name}')->references('{$column}')->on('{$table}'){$on_delete_suffix}"; - } - - protected function getClassName(Model $model) - { - return 'Create'.Str::studly($model->tableName()).'Table'; - } - - protected function getPath(Model $model, Carbon $timestamp, $overwrite = false) - { - return $this->getTablePath($model->tableName(), $timestamp, $overwrite); - } - - protected function getPivotTablePath($tableName, Carbon $timestamp, $overwrite = false) - { - return $this->getTablePath($tableName, $timestamp, $overwrite); - } - - protected function getTablePath($tableName, Carbon $timestamp, $overwrite = false) - { - $dir = 'database/migrations/'; - $name = '_create_'.$tableName.'_table.php'; - - $file = $overwrite ? collect($this->files->files($dir))->first(function ($file) use ($tableName) { - return str_contains($file, $tableName); - }) : false; - - return $file ? (string) $file : $dir.$timestamp->format('Y_m_d_His').$name; - } - - protected function isLaravel7orNewer() - { - return version_compare(App::version(), '7.0.0', '>='); - } - - protected function getPivotClassName(array $segments) - { - return 'Create'.Str::studly($this->getPivotTableName($segments)).'Table'; - } - - protected function getPivotTableName(array $segments) - { - $isCustom = collect($segments) - ->filter(function ($segment) { - return Str::contains($segment, ':'); - })->first(); - - if ($isCustom) { - $table = Str::after($isCustom, ':'); - - return $table; - } - - $segments = array_map(function ($name) { - return Str::snake($name); - }, $segments); - sort($segments); - - return strtolower(implode('_', $segments)); - } - - private function shouldAddForeignKeyConstraint(\Blueprint\Models\Column $column) - { - if ($column->name() === 'id') { - return false; - } - - if ($column->isForeignKey()) { - return true; - } - - return in_array($column->dataType(), ['id', 'uuid']) && config('blueprint.use_constraints'); - } -} + "->onDelete('cascade')", + 'restrict' => "->onDelete('restrict')", + 'null' => "->onDelete('set null')", + 'no_action' => "->onDelete('no action')", + ]; + + const UNSIGNABLE_TYPES = [ + 'bigInteger', + 'decimal', + 'integer', + 'mediumInteger', + 'smallInteger', + 'tinyInteger', + ]; + + /** @var \Illuminate\Contracts\Filesystem\Filesystem */ + private $files; + + public function __construct($files) + { + $this->files = $files; + } + + public function output(Tree $tree, $overwrite = false): array + { + $output = []; + + $created_pivot_tables = []; + + $stub = $this->files->stub('migration.stub'); + + $sequential_timestamp = \Carbon\Carbon::now()->subSeconds(count($tree->models())); + + /** @var \Blueprint\Models\Model $model */ + foreach ($tree->models() as $model) { + $path = $this->getPath($model, $sequential_timestamp->addSecond(), $overwrite); + $action = $this->files->exists($path) ? 'updated' : 'created'; + $this->files->put($path, $this->populateStub($stub, $model)); + + $output[$action][] = $path; + + if (! empty($model->pivotTables())) { + foreach ($model->pivotTables() as $pivotSegments) { + $pivotTable = $this->getPivotTableName($pivotSegments); + $created_pivot_tables[$pivotTable] = $pivotSegments; + } + } + } + + foreach ($created_pivot_tables as $pivotTable => $pivotSegments) { + $path = $this->getPivotTablePath($pivotTable, $sequential_timestamp, $overwrite); + $action = $this->files->exists($path) ? 'updated' : 'created'; + $this->files->put($path, $this->populatePivotStub($stub, $pivotSegments)); + $created_pivot_tables[] = $pivotTable; + $output[$action][] = $path; + } + + return $output; + } + + public function types(): array + { + return ['migrations']; + } + + protected function populateStub(string $stub, Model $model) + { + $stub = str_replace('{{ class }}', $this->getClassName($model), $stub); + $stub = str_replace('{{ table }}', $model->tableName(), $stub); + $stub = str_replace('{{ definition }}', $this->buildDefinition($model), $stub); + + return $stub; + } + + protected function populatePivotStub(string $stub, array $segments) + { + $stub = str_replace('{{ class }}', $this->getPivotClassName($segments), $stub); + $stub = str_replace('{{ table }}', $this->getPivotTableName($segments), $stub); + $stub = str_replace('{{ definition }}', $this->buildPivotTableDefinition($segments), $stub); + + return $stub; + } + + protected function buildDefinition(Model $model) + { + $definition = ''; + + /** @var \Blueprint\Models\Column $column */ + foreach ($model->columns() as $column) { + $dataType = $column->dataType(); + + if ($column->name() === 'id' && $dataType === 'id') { + $dataType = 'bigIncrements'; + } elseif ($dataType === 'id') { + $dataType = 'unsignedBigInteger'; + } + + if (in_array($dataType, self::UNSIGNABLE_TYPES) && in_array('unsigned', $column->modifiers())) { + $dataType = 'unsigned'.ucfirst($dataType); + } + + if (in_array($dataType, self::NULLABLE_TYPES) && $column->isNullable()) { + $dataType = 'nullable'.ucfirst($dataType); + } + + $column_definition = self::INDENT; + if ($dataType === 'bigIncrements' && $this->isLaravel7orNewer()) { + $column_definition .= '$table->id('; + } elseif ($dataType === 'rememberToken') { + $column_definition .= '$table->rememberToken('; + } else { + $column_definition .= '$table->'.$dataType."('{$column->name()}'"; + } + + if (! empty($column->attributes()) && ! in_array($column->dataType(), ['id', 'uuid'])) { + $column_definition .= ', '; + if (in_array($column->dataType(), ['set', 'enum'])) { + $column_definition .= json_encode($column->attributes()); + } else { + $column_definition .= implode(', ', $column->attributes()); + } + } + $column_definition .= ')'; + + $modifiers = $column->modifiers(); + + $foreign = ''; + $foreign_modifier = $column->isForeignKey(); + + if ($this->shouldAddForeignKeyConstraint($column)) { + $foreign = $this->buildForeignKey( + $column->name(), + $foreign_modifier === 'foreign' ? null : $foreign_modifier, + $column->dataType(), + $column->attributes(), + $column->modifiers() + ); + + if ($column->dataType() === 'id' && $this->isLaravel7orNewer()) { + $column_definition = $foreign; + $foreign = ''; + } + + // TODO: unset the proper modifier + $modifiers = collect($modifiers)->reject(function ($modifier) { + return (is_array($modifier) && key($modifier) === 'foreign') + || (is_array($modifier) && key($modifier) === 'onDelete') + || $modifier === 'foreign' + || ($modifier === 'nullable' && $this->isLaravel7orNewer()); + }); + } + + foreach ($modifiers as $modifier) { + if (is_array($modifier)) { + $column_definition .= '->'.key($modifier).'('.current($modifier).')'; + } elseif ($modifier === 'unsigned' && Str::startsWith($dataType, 'unsigned')) { + continue; + } elseif ($modifier === 'nullable' && Str::startsWith($dataType, 'nullable')) { + continue; + } else { + $column_definition .= '->'.$modifier.'()'; + } + } + + $column_definition .= ';'.PHP_EOL; + if (! empty($foreign)) { + $column_definition .= $foreign.';'.PHP_EOL; + } + + $definition .= $column_definition; + } + + if ($model->usesSoftDeletes()) { + $definition .= self::INDENT.'$table->'.$model->softDeletesDataType().'();'.PHP_EOL; + } + + if ($model->morphTo()) { + $definition .= self::INDENT.sprintf('$table->unsignedBigInteger(\'%s\');', Str::lower($model->morphTo().'_id')).PHP_EOL; + $definition .= self::INDENT.sprintf('$table->string(\'%s\');', Str::lower($model->morphTo().'_type')).PHP_EOL; + } + + foreach ($model->indexes() as $index) { + $index_definition = self::INDENT; + $index_definition .= '$table->'.$index->type(); + if (count($index->columnNames()) > 1 ) { + $index_definition .= "(['".implode("', '", $index->columnNames())."']);".PHP_EOL; + } + else { + $index_definition .= "('{$index->columnNames()[0]}');".PHP_EOL; + } + $definition .= $index_definition; + } + if ($model->usesTimestamps()) { + $definition .= self::INDENT.'$table->'.$model->timestampsDataType().'();'.PHP_EOL; + } + + return trim($definition); + } + + protected function buildPivotTableDefinition(array $segments) + { + $definition = ''; + + foreach ($segments as $segment) { + $column = Str::before(Str::lower($segment), ':'); + $references = 'id'; + $on = Str::plural($column); + $foreign = Str::singular($column).'_'.$references; + + if (! $this->isLaravel7orNewer()) { + $definition .= self::INDENT.'$table->unsignedBigInteger(\''.$foreign.'\');'.PHP_EOL; + } + + if (config('blueprint.use_constraints')) { + $definition .= $this->buildForeignKey($foreign, $on, 'id').';'.PHP_EOL; + } elseif ($this->isLaravel7orNewer()) { + $definition .= self::INDENT.'$table->foreignId(\''.$foreign.'\');'.PHP_EOL; + } + } + + return trim($definition); + } + + protected function buildForeignKey(string $column_name, ?string $on, string $type, array $attributes = [], array $modifiers = []) + { + if (is_null($on)) { + $table = Str::plural(Str::beforeLast($column_name, '_')); + $column = Str::afterLast($column_name, '_'); + } elseif (Str::contains($on, '.')) { + [$table, $column] = explode('.', $on); + $table = Str::snake($table); + } else { + $table = Str::plural($on); + $column = Str::afterLast($column_name, '_'); + } + + if ($type === 'id' && ! empty($attributes)) { + $table = Str::lower(Str::plural($attributes[0])); + } + + $on_delete_clause = collect($modifiers)->firstWhere('onDelete'); + $on_delete_clause = $on_delete_clause ? $on_delete_clause['onDelete'] : config('blueprint.on_delete', 'cascade'); + $on_delete_suffix = self::ON_DELETE_CLAUSES[$on_delete_clause]; + + if ($this->isLaravel7orNewer() && $type === 'id') { + $prefix = in_array('nullable', $modifiers) + ? '$table->foreignId'."('{$column_name}')->nullable()" + : '$table->foreignId'."('{$column_name}')"; + + if ($on_delete_clause === 'cascade') { + $on_delete_suffix = '->cascadeOnDelete()'; + } + if ($column_name === Str::singular($table).'_'.$column) { + return self::INDENT."{$prefix}->constrained(){$on_delete_suffix}"; + } + if ($column === 'id') { + return self::INDENT."{$prefix}->constrained('{$table}'){$on_delete_suffix}"; + } + + return self::INDENT."{$prefix}->constrained('{$table}', '{$column}'){$on_delete_suffix}"; + } + + return self::INDENT.'$table->foreign'."('{$column_name}')->references('{$column}')->on('{$table}'){$on_delete_suffix}"; + } + + protected function getClassName(Model $model) + { + return 'Create'.Str::studly($model->tableName()).'Table'; + } + + protected function getPath(Model $model, Carbon $timestamp, $overwrite = false) + { + return $this->getTablePath($model->tableName(), $timestamp, $overwrite); + } + + protected function getPivotTablePath($tableName, Carbon $timestamp, $overwrite = false) + { + return $this->getTablePath($tableName, $timestamp, $overwrite); + } + + protected function getTablePath($tableName, Carbon $timestamp, $overwrite = false) + { + $dir = 'database/migrations/'; + $name = '_create_'.$tableName.'_table.php'; + + $file = $overwrite ? collect($this->files->files($dir))->first(function ($file) use ($tableName) { + return str_contains($file, $tableName); + }) : false; + + return $file ? (string) $file : $dir.$timestamp->format('Y_m_d_His').$name; + } + + protected function isLaravel7orNewer() + { + return version_compare(App::version(), '7.0.0', '>='); + } + + protected function getPivotClassName(array $segments) + { + return 'Create'.Str::studly($this->getPivotTableName($segments)).'Table'; + } + + protected function getPivotTableName(array $segments) + { + $isCustom = collect($segments) + ->filter(function ($segment) { + return Str::contains($segment, ':'); + })->first(); + + if ($isCustom) { + $table = Str::after($isCustom, ':'); + + return $table; + } + + $segments = array_map(function ($name) { + return Str::snake($name); + }, $segments); + sort($segments); + + return strtolower(implode('_', $segments)); + } + + private function shouldAddForeignKeyConstraint(\Blueprint\Models\Column $column) + { + if ($column->name() === 'id') { + return false; + } + + if ($column->isForeignKey()) { + return true; + } + + return in_array($column->dataType(), ['id', 'uuid']) && config('blueprint.use_constraints'); + } +} diff --git a/src/Lexers/ModelLexer.php b/src/Lexers/ModelLexer.php index 60de58c3..c2caba9e 100644 --- a/src/Lexers/ModelLexer.php +++ b/src/Lexers/ModelLexer.php @@ -4,6 +4,7 @@ use Blueprint\Contracts\Lexer; use Blueprint\Models\Column; +use Blueprint\Models\Index; use Blueprint\Models\Model; use Illuminate\Support\Str; @@ -165,6 +166,13 @@ private function buildModel(string $name, array $columns) unset($columns['relationships']); } + if (isset($columns['indexes'])) { + foreach ($columns['indexes'] as $index) { + $model->addIndex(new Index(key($index), array_map('trim', explode(',', current($index))))); + } + unset($columns['indexes']); + } + if (!isset($columns['id']) && $model->usesPrimaryKey()) { $column = $this->buildColumn('id', 'id'); $model->addColumn($column); diff --git a/src/Models/Index.php b/src/Models/Index.php new file mode 100644 index 00000000..b480ef28 --- /dev/null +++ b/src/Models/Index.php @@ -0,0 +1,25 @@ +type = $type; + $this->columnNames = $columnNames; + } + + public function type() + { + return $this->type; + } + + public function columnNames() + { + return $this->columnNames; + } +} diff --git a/src/Models/Model.php b/src/Models/Model.php index d40f3210..36936289 100644 --- a/src/Models/Model.php +++ b/src/Models/Model.php @@ -15,6 +15,7 @@ class Model private $columns = []; private $relationships = []; private $pivotTables = []; + private $indexes = []; /** * @param $name @@ -159,6 +160,16 @@ public function addPivotTable(string $reference) $this->pivotTables[] = $segments; } + public function indexes(): array + { + return $this->indexes; + } + + public function addIndex(Index $index) + { + $this->indexes[] = $index; + } + public function pivotTables(): array { return $this->pivotTables; diff --git a/tests/Feature/BlueprintTest.php b/tests/Feature/BlueprintTest.php index 74fa2b3d..cd34ed7e 100644 --- a/tests/Feature/BlueprintTest.php +++ b/tests/Feature/BlueprintTest.php @@ -278,38 +278,6 @@ public function it_parses_the_readme_example_with_different_platform_eols() $this->assertEquals($expected, $this->subject->parse($definition_windows_eol)); } - /** - * @test - */ - public function it_parses_yaml_with_dashed_syntax() - { - $definition = $this->fixture('drafts/readme-example-dashes.yaml'); - - $expected = [ - 'models' => [ - 'Post' => [ - 'title' => 'string:400', - 'content' => 'longtext', - ], - ], - 'controllers' => [ - 'Post' => [ - 'index' => [ - 'query' => 'all:posts', - 'render' => 'post.index with:posts', - ], - 'store' => [ - 'validate' => 'title, content', - 'save' => 'post', - 'redirect' => 'post.index', - ], - ], - ], - ]; - - $this->assertEquals($expected, $this->subject->parse($definition)); - } - /** * @test */ diff --git a/tests/Feature/Generator/MigrationGeneratorTest.php b/tests/Feature/Generator/MigrationGeneratorTest.php index 32d974b9..15ff2725 100644 --- a/tests/Feature/Generator/MigrationGeneratorTest.php +++ b/tests/Feature/Generator/MigrationGeneratorTest.php @@ -746,6 +746,7 @@ public function modelTreeDataProvider() ['drafts/soft-deletes.yaml', 'database/migrations/timestamp_create_comments_table.php', 'migrations/soft-deletes.php'], ['drafts/with-timezones.yaml', 'database/migrations/timestamp_create_comments_table.php', 'migrations/with-timezones.php'], ['drafts/relationships.yaml', 'database/migrations/timestamp_create_comments_table.php', 'migrations/relationships.php'], + ['drafts/indexes.yaml', 'database/migrations/timestamp_create_posts_table.php', 'migrations/indexes.php'], ['drafts/unconventional.yaml', 'database/migrations/timestamp_create_teams_table.php', 'migrations/unconventional.php'], ['drafts/optimize.yaml', 'database/migrations/timestamp_create_optimizes_table.php', 'migrations/optimize.php'], ['drafts/model-key-constraints.yaml', 'database/migrations/timestamp_create_orders_table.php', 'migrations/model-key-constraints.php'], diff --git a/tests/fixtures/drafts/indexes.yaml b/tests/fixtures/drafts/indexes.yaml new file mode 100644 index 00000000..262514c9 --- /dev/null +++ b/tests/fixtures/drafts/indexes.yaml @@ -0,0 +1,16 @@ +models: + Post: + id: unsignedInteger + title: string + parent_post_id: id + author_id: id + published_at: timestamp nullable + word_count: integer unsigned + location: geometry + indexes: + - primary: id + - index: author_id + - index: author_id, published_at + - unique: title + - unique: title, parent_post_id + - spatialIndex: location diff --git a/tests/fixtures/migrations/indexes.php b/tests/fixtures/migrations/indexes.php new file mode 100644 index 00000000..ee6ca0b3 --- /dev/null +++ b/tests/fixtures/migrations/indexes.php @@ -0,0 +1,43 @@ +unsignedInteger('id'); + $table->string('title'); + $table->unsignedBigInteger('parent_post_id'); + $table->unsignedBigInteger('author_id'); + $table->timestamp('published_at')->nullable(); + $table->unsignedInteger('word_count'); + $table->geometry('location'); + $table->primary('id'); + $table->index('author_id'); + $table->index(['author_id', 'published_at']); + $table->unique('title'); + $table->unique(['title', 'parent_post_id']); + $table->spatialIndex('location'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('posts'); + } +} From 9004af2e1a4522fec51a68a2a831fcb17902b1a0 Mon Sep 17 00:00:00 2001 From: Jason McCreary Date: Fri, 28 Aug 2020 11:53:31 -0400 Subject: [PATCH 2/3] Avoid changing builder parameter with pre-parsing --- src/Blueprint.php | 4 +- src/Builder.php | 5 +- src/Generators/MigrationGenerator.php | 10 ++-- src/Models/Index.php | 10 ++-- tests/Feature/BlueprintTest.php | 32 +++++++++++ .../Generator/MigrationGeneratorTest.php | 4 +- tests/Unit/BuilderTest.php | 56 ++++++++++++++++++- 7 files changed, 104 insertions(+), 17 deletions(-) diff --git a/src/Blueprint.php b/src/Blueprint.php index c40ab00a..c5383017 100644 --- a/src/Blueprint.php +++ b/src/Blueprint.php @@ -29,8 +29,8 @@ public static function appPath() return str_replace('\\', '/', config('blueprint.app_path')); } - public function parse($content, $strip_dashes = false) - { + public function parse($content, $strip_dashes = true) + { $content = str_replace(["\r\n", "\r"], "\n", $content); if ($strip_dashes) { diff --git a/src/Builder.php b/src/Builder.php index 4a1739c0..b73aa708 100644 --- a/src/Builder.php +++ b/src/Builder.php @@ -13,7 +13,10 @@ public function execute(Blueprint $blueprint, Filesystem $files, string $draft, $cache = $blueprint->parse($files->get('.blueprint')); } - $tokens = $blueprint->parse($files->get($draft)); + $contents = $files->get($draft); + $using_indexes = preg_match('/^\s+indexes:\R/m', $contents) !== 1; + + $tokens = $blueprint->parse($contents, $using_indexes); $tokens['cache'] = $cache['models'] ?? []; $registry = $blueprint->analyze($tokens); diff --git a/src/Generators/MigrationGenerator.php b/src/Generators/MigrationGenerator.php index 70850d53..3f3171a7 100644 --- a/src/Generators/MigrationGenerator.php +++ b/src/Generators/MigrationGenerator.php @@ -203,13 +203,13 @@ protected function buildDefinition(Model $model) foreach ($model->indexes() as $index) { $index_definition = self::INDENT; $index_definition .= '$table->'.$index->type(); - if (count($index->columnNames()) > 1 ) { - $index_definition .= "(['".implode("', '", $index->columnNames())."']);".PHP_EOL; + if (count($index->columns()) > 1 ) { + $index_definition .= "(['".implode("', '", $index->columns())."']);".PHP_EOL; } else { - $index_definition .= "('{$index->columnNames()[0]}');".PHP_EOL; + $index_definition .= "('{$index->columns()[0]}');".PHP_EOL; } - $definition .= $index_definition; + $definition .= $index_definition; } if ($model->usesTimestamps()) { $definition .= self::INDENT.'$table->'.$model->timestampsDataType().'();'.PHP_EOL; @@ -303,7 +303,7 @@ protected function getTablePath($tableName, Carbon $timestamp, $overwrite = fals { $dir = 'database/migrations/'; $name = '_create_'.$tableName.'_table.php'; - + $file = $overwrite ? collect($this->files->files($dir))->first(function ($file) use ($tableName) { return str_contains($file, $tableName); }) : false; diff --git a/src/Models/Index.php b/src/Models/Index.php index b480ef28..07d6d0e5 100644 --- a/src/Models/Index.php +++ b/src/Models/Index.php @@ -5,12 +5,12 @@ class Index { private $type; - private $columnNames; + private $columns; - public function __construct(string $type, array $columnNames = []) + public function __construct(string $type, array $columns = []) { $this->type = $type; - $this->columnNames = $columnNames; + $this->columns = $columns; } public function type() @@ -18,8 +18,8 @@ public function type() return $this->type; } - public function columnNames() + public function columns() { - return $this->columnNames; + return $this->columns; } } diff --git a/tests/Feature/BlueprintTest.php b/tests/Feature/BlueprintTest.php index cd34ed7e..74fa2b3d 100644 --- a/tests/Feature/BlueprintTest.php +++ b/tests/Feature/BlueprintTest.php @@ -278,6 +278,38 @@ public function it_parses_the_readme_example_with_different_platform_eols() $this->assertEquals($expected, $this->subject->parse($definition_windows_eol)); } + /** + * @test + */ + public function it_parses_yaml_with_dashed_syntax() + { + $definition = $this->fixture('drafts/readme-example-dashes.yaml'); + + $expected = [ + 'models' => [ + 'Post' => [ + 'title' => 'string:400', + 'content' => 'longtext', + ], + ], + 'controllers' => [ + 'Post' => [ + 'index' => [ + 'query' => 'all:posts', + 'render' => 'post.index with:posts', + ], + 'store' => [ + 'validate' => 'title, content', + 'save' => 'post', + 'redirect' => 'post.index', + ], + ], + ], + ]; + + $this->assertEquals($expected, $this->subject->parse($definition)); + } + /** * @test */ diff --git a/tests/Feature/Generator/MigrationGeneratorTest.php b/tests/Feature/Generator/MigrationGeneratorTest.php index 7ecf5855..e367f973 100644 --- a/tests/Feature/Generator/MigrationGeneratorTest.php +++ b/tests/Feature/Generator/MigrationGeneratorTest.php @@ -70,7 +70,7 @@ public function output_writes_migration_for_model_tree($definition, $path, $migr $this->files->expects('put') ->with($timestamp_path, $this->fixture($migration)); - $tokens = $this->blueprint->parse($this->fixture($definition)); + $tokens = $this->blueprint->parse($this->fixture($definition), $definition !== 'drafts/indexes.yaml'); $tree = $this->blueprint->analyze($tokens); $this->assertEquals(['created' => [$timestamp_path]], $this->subject->output($tree)); @@ -103,7 +103,7 @@ public function output_updates_migration_for_model_tree($definition, $path, $mig $this->files->expects('put') ->with($yesterday_path, $this->fixture($migration)); - $tokens = $this->blueprint->parse($this->fixture($definition)); + $tokens = $this->blueprint->parse($this->fixture($definition), $definition !== 'drafts/indexes.yaml'); $tree = $this->blueprint->analyze($tokens); $this->assertEquals(['updated' => [$yesterday_path]], $this->subject->output($tree, true)); diff --git a/tests/Unit/BuilderTest.php b/tests/Unit/BuilderTest.php index 2fbd0170..86cd280e 100644 --- a/tests/Unit/BuilderTest.php +++ b/tests/Unit/BuilderTest.php @@ -24,7 +24,7 @@ public function execute_builds_draft_content() $blueprint = \Mockery::mock(Blueprint::class); $blueprint->expects('parse') - ->with($draft) + ->with($draft, true) ->andReturn($tokens); $blueprint->expects('analyze') ->with($tokens + ['cache' => []]) @@ -72,7 +72,7 @@ public function execute_uses_cache_and_remembers_models() $blueprint = \Mockery::mock(Blueprint::class); $blueprint->expects('parse') - ->with($draft) + ->with($draft, true) ->andReturn($tokens); $blueprint->expects('parse') ->with('cached blueprint content') @@ -108,4 +108,56 @@ public function execute_uses_cache_and_remembers_models() $this->assertSame($generated, $actual); } + + /** + * @test + */ + public function execute_calls_builder_without_stripping_dashes_for_draft_file_with_indexes_defined() + { + $draft = 'models:'; + $draft .= PHP_EOL . ' Post:'; + $draft .= PHP_EOL . ' indexes:'; + $draft .= PHP_EOL . ' - index: author_id'; + $draft .= PHP_EOL . ' - index: author_id, published_at'; + + $tokens = [ + 'models' => [1, 2, 3] + ]; + $registry = new Tree(['registry']); + $only = []; + $skip = []; + $generated = ['created' => [1, 2], 'updated' => [3]]; + + $blueprint = \Mockery::mock(Blueprint::class); + $blueprint->expects('parse') + ->with($draft, false) + ->andReturn($tokens); + $blueprint->expects('analyze') + ->with($tokens + ['cache' => []]) + ->andReturn($registry); + $blueprint->expects('generate') + ->with($registry, $only, $skip, false) + ->andReturn($generated); + $blueprint->expects('dump') + ->with([ + 'created' => [1, 2], + 'updated' => [3], + 'models' => [1, 2, 3] + ]) + ->andReturn('cacheable blueprint content'); + + $file = \Mockery::mock(Filesystem::class); + $file->expects('get') + ->with('draft.yaml') + ->andReturn($draft); + $file->expects('exists') + ->with('.blueprint') + ->andReturnFalse(); + $file->expects('put') + ->with('.blueprint', 'cacheable blueprint content'); + + $actual = (new Builder)->execute($blueprint, $file, 'draft.yaml'); + + $this->assertSame($generated, $actual); + } } From 3c81351d7395087bde01f0cd57c6f8745c616aa9 Mon Sep 17 00:00:00 2001 From: Jason McCreary Date: Fri, 28 Aug 2020 12:21:28 -0400 Subject: [PATCH 3/3] Apply fixes from StyleCI (#354) --- src/Generators/MigrationGenerator.php | 713 +++++++++++++------------- 1 file changed, 356 insertions(+), 357 deletions(-) diff --git a/src/Generators/MigrationGenerator.php b/src/Generators/MigrationGenerator.php index 3f3171a7..52d7d35d 100644 --- a/src/Generators/MigrationGenerator.php +++ b/src/Generators/MigrationGenerator.php @@ -1,357 +1,356 @@ - "->onDelete('cascade')", - 'restrict' => "->onDelete('restrict')", - 'null' => "->onDelete('set null')", - 'no_action' => "->onDelete('no action')", - ]; - - const UNSIGNABLE_TYPES = [ - 'bigInteger', - 'decimal', - 'integer', - 'mediumInteger', - 'smallInteger', - 'tinyInteger', - ]; - - /** @var \Illuminate\Contracts\Filesystem\Filesystem */ - private $files; - - public function __construct($files) - { - $this->files = $files; - } - - public function output(Tree $tree, $overwrite = false): array - { - $output = []; - - $created_pivot_tables = []; - - $stub = $this->files->stub('migration.stub'); - - $sequential_timestamp = \Carbon\Carbon::now()->subSeconds(count($tree->models())); - - /** @var \Blueprint\Models\Model $model */ - foreach ($tree->models() as $model) { - $path = $this->getPath($model, $sequential_timestamp->addSecond(), $overwrite); - $action = $this->files->exists($path) ? 'updated' : 'created'; - $this->files->put($path, $this->populateStub($stub, $model)); - - $output[$action][] = $path; - - if (! empty($model->pivotTables())) { - foreach ($model->pivotTables() as $pivotSegments) { - $pivotTable = $this->getPivotTableName($pivotSegments); - $created_pivot_tables[$pivotTable] = $pivotSegments; - } - } - } - - foreach ($created_pivot_tables as $pivotTable => $pivotSegments) { - $path = $this->getPivotTablePath($pivotTable, $sequential_timestamp, $overwrite); - $action = $this->files->exists($path) ? 'updated' : 'created'; - $this->files->put($path, $this->populatePivotStub($stub, $pivotSegments)); - $created_pivot_tables[] = $pivotTable; - $output[$action][] = $path; - } - - return $output; - } - - public function types(): array - { - return ['migrations']; - } - - protected function populateStub(string $stub, Model $model) - { - $stub = str_replace('{{ class }}', $this->getClassName($model), $stub); - $stub = str_replace('{{ table }}', $model->tableName(), $stub); - $stub = str_replace('{{ definition }}', $this->buildDefinition($model), $stub); - - return $stub; - } - - protected function populatePivotStub(string $stub, array $segments) - { - $stub = str_replace('{{ class }}', $this->getPivotClassName($segments), $stub); - $stub = str_replace('{{ table }}', $this->getPivotTableName($segments), $stub); - $stub = str_replace('{{ definition }}', $this->buildPivotTableDefinition($segments), $stub); - - return $stub; - } - - protected function buildDefinition(Model $model) - { - $definition = ''; - - /** @var \Blueprint\Models\Column $column */ - foreach ($model->columns() as $column) { - $dataType = $column->dataType(); - - if ($column->name() === 'id' && $dataType === 'id') { - $dataType = 'bigIncrements'; - } elseif ($dataType === 'id') { - $dataType = 'unsignedBigInteger'; - } - - if (in_array($dataType, self::UNSIGNABLE_TYPES) && in_array('unsigned', $column->modifiers())) { - $dataType = 'unsigned'.ucfirst($dataType); - } - - if (in_array($dataType, self::NULLABLE_TYPES) && $column->isNullable()) { - $dataType = 'nullable'.ucfirst($dataType); - } - - $column_definition = self::INDENT; - if ($dataType === 'bigIncrements' && $this->isLaravel7orNewer()) { - $column_definition .= '$table->id('; - } elseif ($dataType === 'rememberToken') { - $column_definition .= '$table->rememberToken('; - } else { - $column_definition .= '$table->'.$dataType."('{$column->name()}'"; - } - - if (! empty($column->attributes()) && ! in_array($column->dataType(), ['id', 'uuid'])) { - $column_definition .= ', '; - if (in_array($column->dataType(), ['set', 'enum'])) { - $column_definition .= json_encode($column->attributes()); - } else { - $column_definition .= implode(', ', $column->attributes()); - } - } - $column_definition .= ')'; - - $modifiers = $column->modifiers(); - - $foreign = ''; - $foreign_modifier = $column->isForeignKey(); - - if ($this->shouldAddForeignKeyConstraint($column)) { - $foreign = $this->buildForeignKey( - $column->name(), - $foreign_modifier === 'foreign' ? null : $foreign_modifier, - $column->dataType(), - $column->attributes(), - $column->modifiers() - ); - - if ($column->dataType() === 'id' && $this->isLaravel7orNewer()) { - $column_definition = $foreign; - $foreign = ''; - } - - // TODO: unset the proper modifier - $modifiers = collect($modifiers)->reject(function ($modifier) { - return (is_array($modifier) && key($modifier) === 'foreign') - || (is_array($modifier) && key($modifier) === 'onDelete') - || $modifier === 'foreign' - || ($modifier === 'nullable' && $this->isLaravel7orNewer()); - }); - } - - foreach ($modifiers as $modifier) { - if (is_array($modifier)) { - $column_definition .= '->'.key($modifier).'('.current($modifier).')'; - } elseif ($modifier === 'unsigned' && Str::startsWith($dataType, 'unsigned')) { - continue; - } elseif ($modifier === 'nullable' && Str::startsWith($dataType, 'nullable')) { - continue; - } else { - $column_definition .= '->'.$modifier.'()'; - } - } - - $column_definition .= ';'.PHP_EOL; - if (! empty($foreign)) { - $column_definition .= $foreign.';'.PHP_EOL; - } - - $definition .= $column_definition; - } - - if ($model->usesSoftDeletes()) { - $definition .= self::INDENT.'$table->'.$model->softDeletesDataType().'();'.PHP_EOL; - } - - if ($model->morphTo()) { - $definition .= self::INDENT.sprintf('$table->unsignedBigInteger(\'%s\');', Str::lower($model->morphTo().'_id')).PHP_EOL; - $definition .= self::INDENT.sprintf('$table->string(\'%s\');', Str::lower($model->morphTo().'_type')).PHP_EOL; - } - - foreach ($model->indexes() as $index) { - $index_definition = self::INDENT; - $index_definition .= '$table->'.$index->type(); - if (count($index->columns()) > 1 ) { - $index_definition .= "(['".implode("', '", $index->columns())."']);".PHP_EOL; - } - else { - $index_definition .= "('{$index->columns()[0]}');".PHP_EOL; - } - $definition .= $index_definition; - } - if ($model->usesTimestamps()) { - $definition .= self::INDENT.'$table->'.$model->timestampsDataType().'();'.PHP_EOL; - } - - return trim($definition); - } - - protected function buildPivotTableDefinition(array $segments) - { - $definition = ''; - - foreach ($segments as $segment) { - $column = Str::before(Str::lower($segment), ':'); - $references = 'id'; - $on = Str::plural($column); - $foreign = Str::singular($column).'_'.$references; - - if (! $this->isLaravel7orNewer()) { - $definition .= self::INDENT.'$table->unsignedBigInteger(\''.$foreign.'\');'.PHP_EOL; - } - - if (config('blueprint.use_constraints')) { - $definition .= $this->buildForeignKey($foreign, $on, 'id').';'.PHP_EOL; - } elseif ($this->isLaravel7orNewer()) { - $definition .= self::INDENT.'$table->foreignId(\''.$foreign.'\');'.PHP_EOL; - } - } - - return trim($definition); - } - - protected function buildForeignKey(string $column_name, ?string $on, string $type, array $attributes = [], array $modifiers = []) - { - if (is_null($on)) { - $table = Str::plural(Str::beforeLast($column_name, '_')); - $column = Str::afterLast($column_name, '_'); - } elseif (Str::contains($on, '.')) { - [$table, $column] = explode('.', $on); - $table = Str::snake($table); - } else { - $table = Str::plural($on); - $column = Str::afterLast($column_name, '_'); - } - - if ($type === 'id' && ! empty($attributes)) { - $table = Str::lower(Str::plural($attributes[0])); - } - - $on_delete_clause = collect($modifiers)->firstWhere('onDelete'); - $on_delete_clause = $on_delete_clause ? $on_delete_clause['onDelete'] : config('blueprint.on_delete', 'cascade'); - $on_delete_suffix = self::ON_DELETE_CLAUSES[$on_delete_clause]; - - if ($this->isLaravel7orNewer() && $type === 'id') { - $prefix = in_array('nullable', $modifiers) - ? '$table->foreignId'."('{$column_name}')->nullable()" - : '$table->foreignId'."('{$column_name}')"; - - if ($on_delete_clause === 'cascade') { - $on_delete_suffix = '->cascadeOnDelete()'; - } - if ($column_name === Str::singular($table).'_'.$column) { - return self::INDENT."{$prefix}->constrained(){$on_delete_suffix}"; - } - if ($column === 'id') { - return self::INDENT."{$prefix}->constrained('{$table}'){$on_delete_suffix}"; - } - - return self::INDENT."{$prefix}->constrained('{$table}', '{$column}'){$on_delete_suffix}"; - } - - return self::INDENT.'$table->foreign'."('{$column_name}')->references('{$column}')->on('{$table}'){$on_delete_suffix}"; - } - - protected function getClassName(Model $model) - { - return 'Create'.Str::studly($model->tableName()).'Table'; - } - - protected function getPath(Model $model, Carbon $timestamp, $overwrite = false) - { - return $this->getTablePath($model->tableName(), $timestamp, $overwrite); - } - - protected function getPivotTablePath($tableName, Carbon $timestamp, $overwrite = false) - { - return $this->getTablePath($tableName, $timestamp, $overwrite); - } - - protected function getTablePath($tableName, Carbon $timestamp, $overwrite = false) - { - $dir = 'database/migrations/'; - $name = '_create_'.$tableName.'_table.php'; - - $file = $overwrite ? collect($this->files->files($dir))->first(function ($file) use ($tableName) { - return str_contains($file, $tableName); - }) : false; - - return $file ? (string) $file : $dir.$timestamp->format('Y_m_d_His').$name; - } - - protected function isLaravel7orNewer() - { - return version_compare(App::version(), '7.0.0', '>='); - } - - protected function getPivotClassName(array $segments) - { - return 'Create'.Str::studly($this->getPivotTableName($segments)).'Table'; - } - - protected function getPivotTableName(array $segments) - { - $isCustom = collect($segments) - ->filter(function ($segment) { - return Str::contains($segment, ':'); - })->first(); - - if ($isCustom) { - $table = Str::after($isCustom, ':'); - - return $table; - } - - $segments = array_map(function ($name) { - return Str::snake($name); - }, $segments); - sort($segments); - - return strtolower(implode('_', $segments)); - } - - private function shouldAddForeignKeyConstraint(\Blueprint\Models\Column $column) - { - if ($column->name() === 'id') { - return false; - } - - if ($column->isForeignKey()) { - return true; - } - - return in_array($column->dataType(), ['id', 'uuid']) && config('blueprint.use_constraints'); - } -} + "->onDelete('cascade')", + 'restrict' => "->onDelete('restrict')", + 'null' => "->onDelete('set null')", + 'no_action' => "->onDelete('no action')", + ]; + + const UNSIGNABLE_TYPES = [ + 'bigInteger', + 'decimal', + 'integer', + 'mediumInteger', + 'smallInteger', + 'tinyInteger', + ]; + + /** @var \Illuminate\Contracts\Filesystem\Filesystem */ + private $files; + + public function __construct($files) + { + $this->files = $files; + } + + public function output(Tree $tree, $overwrite = false): array + { + $output = []; + + $created_pivot_tables = []; + + $stub = $this->files->stub('migration.stub'); + + $sequential_timestamp = \Carbon\Carbon::now()->subSeconds(count($tree->models())); + + /** @var \Blueprint\Models\Model $model */ + foreach ($tree->models() as $model) { + $path = $this->getPath($model, $sequential_timestamp->addSecond(), $overwrite); + $action = $this->files->exists($path) ? 'updated' : 'created'; + $this->files->put($path, $this->populateStub($stub, $model)); + + $output[$action][] = $path; + + if (! empty($model->pivotTables())) { + foreach ($model->pivotTables() as $pivotSegments) { + $pivotTable = $this->getPivotTableName($pivotSegments); + $created_pivot_tables[$pivotTable] = $pivotSegments; + } + } + } + + foreach ($created_pivot_tables as $pivotTable => $pivotSegments) { + $path = $this->getPivotTablePath($pivotTable, $sequential_timestamp, $overwrite); + $action = $this->files->exists($path) ? 'updated' : 'created'; + $this->files->put($path, $this->populatePivotStub($stub, $pivotSegments)); + $created_pivot_tables[] = $pivotTable; + $output[$action][] = $path; + } + + return $output; + } + + public function types(): array + { + return ['migrations']; + } + + protected function populateStub(string $stub, Model $model) + { + $stub = str_replace('{{ class }}', $this->getClassName($model), $stub); + $stub = str_replace('{{ table }}', $model->tableName(), $stub); + $stub = str_replace('{{ definition }}', $this->buildDefinition($model), $stub); + + return $stub; + } + + protected function populatePivotStub(string $stub, array $segments) + { + $stub = str_replace('{{ class }}', $this->getPivotClassName($segments), $stub); + $stub = str_replace('{{ table }}', $this->getPivotTableName($segments), $stub); + $stub = str_replace('{{ definition }}', $this->buildPivotTableDefinition($segments), $stub); + + return $stub; + } + + protected function buildDefinition(Model $model) + { + $definition = ''; + + /** @var \Blueprint\Models\Column $column */ + foreach ($model->columns() as $column) { + $dataType = $column->dataType(); + + if ($column->name() === 'id' && $dataType === 'id') { + $dataType = 'bigIncrements'; + } elseif ($dataType === 'id') { + $dataType = 'unsignedBigInteger'; + } + + if (in_array($dataType, self::UNSIGNABLE_TYPES) && in_array('unsigned', $column->modifiers())) { + $dataType = 'unsigned'.ucfirst($dataType); + } + + if (in_array($dataType, self::NULLABLE_TYPES) && $column->isNullable()) { + $dataType = 'nullable'.ucfirst($dataType); + } + + $column_definition = self::INDENT; + if ($dataType === 'bigIncrements' && $this->isLaravel7orNewer()) { + $column_definition .= '$table->id('; + } elseif ($dataType === 'rememberToken') { + $column_definition .= '$table->rememberToken('; + } else { + $column_definition .= '$table->'.$dataType."('{$column->name()}'"; + } + + if (! empty($column->attributes()) && ! in_array($column->dataType(), ['id', 'uuid'])) { + $column_definition .= ', '; + if (in_array($column->dataType(), ['set', 'enum'])) { + $column_definition .= json_encode($column->attributes()); + } else { + $column_definition .= implode(', ', $column->attributes()); + } + } + $column_definition .= ')'; + + $modifiers = $column->modifiers(); + + $foreign = ''; + $foreign_modifier = $column->isForeignKey(); + + if ($this->shouldAddForeignKeyConstraint($column)) { + $foreign = $this->buildForeignKey( + $column->name(), + $foreign_modifier === 'foreign' ? null : $foreign_modifier, + $column->dataType(), + $column->attributes(), + $column->modifiers() + ); + + if ($column->dataType() === 'id' && $this->isLaravel7orNewer()) { + $column_definition = $foreign; + $foreign = ''; + } + + // TODO: unset the proper modifier + $modifiers = collect($modifiers)->reject(function ($modifier) { + return (is_array($modifier) && key($modifier) === 'foreign') + || (is_array($modifier) && key($modifier) === 'onDelete') + || $modifier === 'foreign' + || ($modifier === 'nullable' && $this->isLaravel7orNewer()); + }); + } + + foreach ($modifiers as $modifier) { + if (is_array($modifier)) { + $column_definition .= '->'.key($modifier).'('.current($modifier).')'; + } elseif ($modifier === 'unsigned' && Str::startsWith($dataType, 'unsigned')) { + continue; + } elseif ($modifier === 'nullable' && Str::startsWith($dataType, 'nullable')) { + continue; + } else { + $column_definition .= '->'.$modifier.'()'; + } + } + + $column_definition .= ';'.PHP_EOL; + if (! empty($foreign)) { + $column_definition .= $foreign.';'.PHP_EOL; + } + + $definition .= $column_definition; + } + + if ($model->usesSoftDeletes()) { + $definition .= self::INDENT.'$table->'.$model->softDeletesDataType().'();'.PHP_EOL; + } + + if ($model->morphTo()) { + $definition .= self::INDENT.sprintf('$table->unsignedBigInteger(\'%s\');', Str::lower($model->morphTo().'_id')).PHP_EOL; + $definition .= self::INDENT.sprintf('$table->string(\'%s\');', Str::lower($model->morphTo().'_type')).PHP_EOL; + } + + foreach ($model->indexes() as $index) { + $index_definition = self::INDENT; + $index_definition .= '$table->'.$index->type(); + if (count($index->columns()) > 1) { + $index_definition .= "(['".implode("', '", $index->columns())."']);".PHP_EOL; + } else { + $index_definition .= "('{$index->columns()[0]}');".PHP_EOL; + } + $definition .= $index_definition; + } + if ($model->usesTimestamps()) { + $definition .= self::INDENT.'$table->'.$model->timestampsDataType().'();'.PHP_EOL; + } + + return trim($definition); + } + + protected function buildPivotTableDefinition(array $segments) + { + $definition = ''; + + foreach ($segments as $segment) { + $column = Str::before(Str::lower($segment), ':'); + $references = 'id'; + $on = Str::plural($column); + $foreign = Str::singular($column).'_'.$references; + + if (! $this->isLaravel7orNewer()) { + $definition .= self::INDENT.'$table->unsignedBigInteger(\''.$foreign.'\');'.PHP_EOL; + } + + if (config('blueprint.use_constraints')) { + $definition .= $this->buildForeignKey($foreign, $on, 'id').';'.PHP_EOL; + } elseif ($this->isLaravel7orNewer()) { + $definition .= self::INDENT.'$table->foreignId(\''.$foreign.'\');'.PHP_EOL; + } + } + + return trim($definition); + } + + protected function buildForeignKey(string $column_name, ?string $on, string $type, array $attributes = [], array $modifiers = []) + { + if (is_null($on)) { + $table = Str::plural(Str::beforeLast($column_name, '_')); + $column = Str::afterLast($column_name, '_'); + } elseif (Str::contains($on, '.')) { + [$table, $column] = explode('.', $on); + $table = Str::snake($table); + } else { + $table = Str::plural($on); + $column = Str::afterLast($column_name, '_'); + } + + if ($type === 'id' && ! empty($attributes)) { + $table = Str::lower(Str::plural($attributes[0])); + } + + $on_delete_clause = collect($modifiers)->firstWhere('onDelete'); + $on_delete_clause = $on_delete_clause ? $on_delete_clause['onDelete'] : config('blueprint.on_delete', 'cascade'); + $on_delete_suffix = self::ON_DELETE_CLAUSES[$on_delete_clause]; + + if ($this->isLaravel7orNewer() && $type === 'id') { + $prefix = in_array('nullable', $modifiers) + ? '$table->foreignId'."('{$column_name}')->nullable()" + : '$table->foreignId'."('{$column_name}')"; + + if ($on_delete_clause === 'cascade') { + $on_delete_suffix = '->cascadeOnDelete()'; + } + if ($column_name === Str::singular($table).'_'.$column) { + return self::INDENT."{$prefix}->constrained(){$on_delete_suffix}"; + } + if ($column === 'id') { + return self::INDENT."{$prefix}->constrained('{$table}'){$on_delete_suffix}"; + } + + return self::INDENT."{$prefix}->constrained('{$table}', '{$column}'){$on_delete_suffix}"; + } + + return self::INDENT.'$table->foreign'."('{$column_name}')->references('{$column}')->on('{$table}'){$on_delete_suffix}"; + } + + protected function getClassName(Model $model) + { + return 'Create'.Str::studly($model->tableName()).'Table'; + } + + protected function getPath(Model $model, Carbon $timestamp, $overwrite = false) + { + return $this->getTablePath($model->tableName(), $timestamp, $overwrite); + } + + protected function getPivotTablePath($tableName, Carbon $timestamp, $overwrite = false) + { + return $this->getTablePath($tableName, $timestamp, $overwrite); + } + + protected function getTablePath($tableName, Carbon $timestamp, $overwrite = false) + { + $dir = 'database/migrations/'; + $name = '_create_'.$tableName.'_table.php'; + + $file = $overwrite ? collect($this->files->files($dir))->first(function ($file) use ($tableName) { + return str_contains($file, $tableName); + }) : false; + + return $file ? (string) $file : $dir.$timestamp->format('Y_m_d_His').$name; + } + + protected function isLaravel7orNewer() + { + return version_compare(App::version(), '7.0.0', '>='); + } + + protected function getPivotClassName(array $segments) + { + return 'Create'.Str::studly($this->getPivotTableName($segments)).'Table'; + } + + protected function getPivotTableName(array $segments) + { + $isCustom = collect($segments) + ->filter(function ($segment) { + return Str::contains($segment, ':'); + })->first(); + + if ($isCustom) { + $table = Str::after($isCustom, ':'); + + return $table; + } + + $segments = array_map(function ($name) { + return Str::snake($name); + }, $segments); + sort($segments); + + return strtolower(implode('_', $segments)); + } + + private function shouldAddForeignKeyConstraint(\Blueprint\Models\Column $column) + { + if ($column->name() === 'id') { + return false; + } + + if ($column->isForeignKey()) { + return true; + } + + return in_array($column->dataType(), ['id', 'uuid']) && config('blueprint.use_constraints'); + } +}