Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Populate migration file with actual model fields #935

Merged
merged 46 commits into from
Nov 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
6bdd938
populate migration file with actual model fields as best as we can
mjauvin Jul 4, 2023
f59f6f1
add missing space after catch keyword
mjauvin Jul 4, 2023
d50680d
do not hardcode the primary key
mjauvin Jul 4, 2023
191b425
do not process fields if not in create mode
mjauvin Jul 4, 2023
8b9dd19
return vars
mjauvin Jul 4, 2023
934f00f
add missing quotes around the primaryKey
mjauvin Jul 4, 2023
5978677
use mediumText type for jsonable fields
mjauvin Jul 5, 2023
a940128
only generate fields in create stub if --model option is supplied
mjauvin Jul 5, 2023
ff06eab
autocreate model fields in --update mode as well if --model option su…
mjauvin Jul 5, 2023
fcdd327
jsonable use mediumText in stub files now
mjauvin Jul 5, 2023
3337df5
use boolean for checkbox/switch field types; use datetime for datepic…
mjauvin Jul 5, 2023
b95cac7
be smarted about number/range types
mjauvin Jul 5, 2023
68be02b
make sure step and min configs are set
mjauvin Jul 5, 2023
9ccbb0d
move fields mapping into a helper class
mjauvin Jul 5, 2023
3afef8f
add comments and type hinting
mjauvin Jul 5, 2023
d8e2e88
add more comments and type hinting
mjauvin Jul 5, 2023
65fd61d
add comment
mjauvin Jul 5, 2023
eb9bf9f
add unit tests for Migration helper
mjauvin Jul 5, 2023
3cf9b31
remove extra space
mjauvin Jul 5, 2023
36101b8
repeater can be ignored here, defined with the jsonable model property
mjauvin Jul 15, 2023
5fa0975
check if field is required in model's rules property
mjauvin Jul 16, 2023
c632529
Merge branch 'develop' into create-migration
mjauvin Jul 17, 2023
112e60b
use text column type for textarea
mjauvin Jul 19, 2023
24f1401
fix trsts
mjauvin Jul 19, 2023
449efbd
fix variable name
mjauvin Aug 20, 2023
adde4be
Merge branch 'develop' into create-migration
mjauvin Oct 14, 2023
b7139b8
fix coding style
mjauvin Oct 14, 2023
a66eef9
add missing space before closing twig tag
mjauvin Oct 14, 2023
5830179
move back to protected method
mjauvin Oct 15, 2023
add9450
remove Migration helper class tests
mjauvin Oct 15, 2023
b33cdc6
leave regular exception flow
mjauvin Oct 19, 2023
a7f66cc
add initial migration test and model
mjauvin Oct 21, 2023
fabd105
fix test filename
mjauvin Oct 21, 2023
27997fb
fix test plugins_path and add $rules to TestModel
mjauvin Oct 21, 2023
4b588db
use plugins_path() instead
mjauvin Oct 21, 2023
e05cea9
call artisan within the test, not setUp()
mjauvin Oct 21, 2023
c15776e
fix test method name
mjauvin Oct 21, 2023
3651826
cleanup after ourselves
mjauvin Oct 21, 2023
bdff39e
complete migration tests
mjauvin Oct 21, 2023
cc25878
simplify code logic
mjauvin Oct 21, 2023
0f436b1
check if the index have been created
mjauvin Oct 21, 2023
fd8be03
add Sortable trait and test sort_order column get added
mjauvin Oct 22, 2023
3869e49
test required field property
mjauvin Oct 22, 2023
af65980
cleanup
mjauvin Oct 22, 2023
6eb7b07
fix fields.yaml indentation
mjauvin Oct 26, 2023
614354e
require create or update option when table or model option is provided
mjauvin Oct 26, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 145 additions & 2 deletions modules/system/console/CreateMigration.php
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
<?php namespace System\Console;

use File;
use InvalidArgumentException;
use System\Classes\VersionManager;
use System\Console\BaseScaffoldCommand;
use Winter\Storm\Database\Model;
use Winter\Storm\Support\Str;
use Yaml;

Expand Down Expand Up @@ -157,8 +159,7 @@ protected function prepareVars(): array
}

if ($this->option('create') && $this->option('update')) {
$this->error('The create & update options cannot both be set at the same time');
return false;
throw new InvalidArgumentException('The create & update options cannot both be set at the same time');
}

if ($this->option('create')) {
Expand All @@ -173,6 +174,10 @@ protected function prepareVars(): array
throw new InvalidArgumentException('The table or model options are required when using the create or update options');
}

if (($table || $model) && !in_array($scaffold, ['create', 'update'])) {
throw new InvalidArgumentException('One of create or update option is required when using the model or table options');
}

$this->stubs = $this->migrationScaffolds[$scaffold];

if (!empty($this->option('for-version'))) {
Expand All @@ -192,13 +197,100 @@ protected function prepareVars(): array
'version' => $version,
];

if (!empty($model)) {
$vars['model'] = $model;
}
if (!empty($table)) {
$vars['table'] = $table;
}

return $vars;
}

/**
* Create vars for model fields mappings so they can be used in update/create stubs
*/
protected function processVars(array $vars): array
{
$vars = parent::processVars($vars);

// --model option needed below
if (empty($vars['model'])) {
return $vars;
}

$vars['fields'] = [];

$fields_path = $vars['plugin_url'] . '/models/' . $vars['lower_model'] . '/fields.yaml';
$fields = Yaml::parseFile(plugins_path($fields_path));

$modelName = $vars['plugin_namespace'] . '\\Models\\' . $vars['model'];

$vars['model'] = $model = new $modelName();

foreach (['fields', 'tabs', 'secondaryTabs'] as $type) {
if (!isset($fields[$type])) {
continue;
}
if ($type === 'fields') {
$fieldList = $fields[$type];
} else {
$fieldList = $fields[$type]['fields'];
}

foreach ($fieldList as $field => $config) {
if (str_contains($field, '@')) {
list($field, $context) = explode('@', $field);
}

$type = $config['type'] ?? 'text';

if (str_starts_with($field, '_')
|| $field === $model->getKeyName()
|| str_contains($field, '[')
|| in_array($type, ['fileupload', 'relation', 'relationmanager', 'repeater', 'section', 'hint'])
|| in_array($field, $model->purgeable ?? [])
|| $model->getRelationType($field)
) {
continue;
}

$vars['fields'][$field] = $this->mapFieldType($field, $config, $model);
}
}

foreach ($model->getRelationDefinitions() as $relationType => $definitions) {
if (in_array($relationType, ['belongsTo', 'hasOne'])) {
foreach (array_keys($definitions) as $relation) {
$vars['fields'][$relation . '_id'] = [
'type' => 'foreignId',
'index' => true,
'required' => true,
];
}
}
}

if ($model->methodExists('getSortOrderColumn')) {
$field = $model->getSortOrderColumn();
$vars['fields'][$field] = [
'type' => 'unsignedinteger',
'required' => false,
'index' => true,
];
}

$vars['primaryKey'] = $model->getKeyName();
$vars['jsonable'] = $model->getJsonable();
$vars['timestamps'] = $model->timestamps;

if ($morphable = $model->morphTo) {
$vars['morphable'] = array_keys($morphable);
}

return $vars;
}

/**
* Get the next version number based on the current number.
*/
Expand All @@ -209,4 +301,55 @@ protected function getNextVersion($currentVersion): string
$parts[count($parts) - 1] = (int) $parts[count($parts) - 1] + 1;
return 'v' . implode('.', $parts);
}

/**
* Maps model fields config to DB Schema column types.
*/
protected function mapFieldType(string $fieldName, array $fieldConfig, ?Model $model = null) : array
{
switch ($fieldConfig['type'] ?? 'text') {
case 'checkbox':
case 'switch':
$dbType = 'boolean';
break;
case 'number':
$dbType = 'double';
if (isset($fieldConfig['step']) && is_int($fieldConfig['step'])) {
$dbType = 'integer';
}
if ($dbType === 'integer' && isset($fieldConfig['min']) && $fieldConfig['min'] >= 0) {
$dbType = 'unsignedInteger';
}
break;
case 'range':
$dbType = 'unsignedInteger';
break;
case 'datepicker':
$dbType = $fieldConfig['mode'] ?? 'datetime';
break;
case 'markdown':
$dbType = 'mediumText';
LukeTowers marked this conversation as resolved.
Show resolved Hide resolved
break;
case 'textarea':
$dbType = 'text';
break;
default:
$dbType = 'string';
}

if ($model) {
$rule = array_get($model->rules ?? [], $fieldName, '');
$rule = is_array($rule) ? implode(',', $rule) : $rule;

$required = str_contains($rule, 'required') ? true : $fieldConfig['required'] ?? false;
} else {
$required = $fieldConfig['required'] ?? false;
}

return [
'type' => $dbType,
'required' => $required,
'index' => in_array($fieldName, ["slug"]) or str_ends_with($fieldName, "_id"),
];
mjauvin marked this conversation as resolved.
Show resolved Hide resolved
}
}
15 changes: 15 additions & 0 deletions modules/system/console/scaffold/migration/migration.create.stub
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,23 @@ return new class extends Migration
public function up()
{
Schema::create('{{ table }}', function (Blueprint $table) {
{% if primaryKey %}
$table->increments('{{ primaryKey }}');
{% else %}
$table->id();
{% endif %}
{% for field,config in fields %}
$table->{{ config.type }}('{{ field }}'){{ config.required == false ? '->nullable()' }}{{ config.index ? '->index()' }};
{% endfor %}
{% for field in jsonable %}
$table->mediumText('{{ field }}')->nullable();
{% endfor %}
{% for field in morphable %}
$table->nullableMorphs('{{ field }}', 'morphable_index');
{% endfor %}
{% if not model or timestamps %}
$table->timestamps();
{% endif %}
});
}

Expand Down
20 changes: 18 additions & 2 deletions modules/system/console/scaffold/migration/migration.update.stub
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,15 @@ return new class extends Migration
public function up()
{
Schema::table('{{ table }}', function (Blueprint $table) {
//
{% for field,config in fields %}
$table->{{ config.type }}('{{ field }}'){{ config.required == false ? '->nullable()' }}{{ config.index ? '->index()' }};
{% endfor %}
{% for field in jsonable %}
$table->mediumText('{{ field }}')->nullable();
{% endfor %}
{% for field in morphable %}
$table->nullableMorphs('{{ field }}', 'morphable_index');
{% endfor %}
});
}

Expand All @@ -26,7 +34,15 @@ return new class extends Migration
public function down()
{
Schema::table('{{ table }}', function (Blueprint $table) {
//
{% for field,config in fields %}
$table->dropColumn('{{ field }}');
{% endfor %}
{% for field in jsonable %}
$table->dropColumn('{{ field }}');
{% endfor %}
{% for field in morphable %}
$table->dropColumn('{{ field }}');
{% endfor %}
});
}
};
90 changes: 90 additions & 0 deletions modules/system/tests/console/CreateMigrationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

namespace System\Tests\Console;

use File;
use Illuminate\Database\Schema\Blueprint;
use Schema;
use System\Tests\Bootstrap\PluginTestCase;

class CreateMigrationTest extends PluginTestCase
{
public function setUp(): void
{
parent::setUp();

$this->app->setPluginsPath(base_path() . '/modules/system/tests/fixtures/plugins/');

$this->table = 'winter_tester_test_model';
$this->versionFile = plugins_path('winter/tester/updates/version.yaml');
$this->versionFolder = plugins_path('winter/tester/updates/v0.0.1');

File::copy($this->versionFile, $this->versionFile . '.bak');
}

public function testCreateMigration()
{
$this->artisan('create:migration Winter.Tester -c --force --for-version v0.0.1 --model TestModel --name create_table');
$this->assertFileExists($this->versionFolder . '/create_table.php');

$migration = require_once $this->versionFolder . '/create_table.php';
$migration->up();

$this->assertTrue(Schema::hasTable($this->table));

$columns = [
'id' => ['type'=>'integer', 'index'=>'primary', 'required'=>true],
'cb' => ['type'=>'boolean'],
'switch' => ['type'=>'boolean'],
'int' => ['type'=>'integer'],
'uint' => ['type'=>'integer', 'required'=>true],
'double' => ['type'=>'float'],
'range' => ['type'=>'integer', 'required'=>true],
'datetime' => ['type'=>'datetime'],
'date' => ['type'=>'date', 'required'=>true],
'time' => ['type'=>'time'],
'md' => ['type'=>'text'],
'textarea' => ['type'=>'text'],
'text' => ['type'=>'string', 'required'=>true],
'phone_id' => ['type'=>'integer', 'index'=>true, 'required'=>true],
'user_id' => ['type'=>'integer', 'index'=>true, 'required'=>true],
'data' => ['type'=>'text'],
'sort_order' => ['type'=>'integer', 'index'=>true],
'taggable_id' => ['type'=>'integer', 'index'=>'morphable_index'],
'taggable_type' => ['type'=>'string', 'index'=>'morphable_index'],
'created_at' => ['type'=>'datetime'],
'updated_at' => ['type'=>'datetime'],
];

$table = Schema::getConnection()->getDoctrineSchemaManager()->listTableDetails($this->table);

foreach ($columns as $name => $definition) {
$this->assertEquals(array_get($definition, 'type'), Schema::getColumnType($this->table, $name));

// assert an index has been created for the primary, morph and foreign keys
if ($indexName = array_get($definition, 'index')) {
if ($indexName === true) {
$indexName = sprintf("%s_%s_index", $this->table, $name);
}
$this->assertTrue($table->hasIndex($indexName));

if ($indexName === 'morphable_index') {
$index = $table->getIndex($indexName);
$this->assertTrue(in_array($name, $index->getColumns()));
}
}
$this->assertEquals(array_get($definition, 'required', false), $table->getColumn($name)->getNotnull());
}

$migration->down();
$this->assertFalse(Schema::hasTable($this->table));
}

public function tearDown(): void
{
File::move($this->versionFile . '.bak', $this->versionFile);
File::deleteDirectory($this->versionFolder);

parent::tearDown();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php namespace Winter\Tester\Models;

use Model;

class TestModel extends Model
{
use \Winter\Storm\Database\Traits\Sortable;

public $table = 'winter_tester_test_model';

public $jsonable = [
'data',
];

public $belongsTo = [
'user' => TestUser::class
];

public $hasOne = [
'phone' => TestPhone::class
];

public $morphTo = [
'taggable' => []
];

public $rules = [
'uint' => 'required',
'range' => 'required|integer|between:1,10',
];
}

class TestUser extends Model
{
}

class TestPhone extends Model
{
}
Loading
Loading