From 79e3354ba7655eff9227c46eec5caf2eb64e4bda Mon Sep 17 00:00:00 2001 From: David Rogers Date: Thu, 1 Oct 2020 14:56:52 -0700 Subject: [PATCH] Use Config to Generate Better Data - Establish a config file (modelfromtable.php) that devs can use to preset configurations (which can be overridden by command line) - downside is debug, all, etc have to be explicitly set to true in command line - Use `app/Models` directory if Laravel 8+ (#40) - New configuration to enable/disable timestamps (#13) - Generating model docblocks (#7) - New configuration to set delimiter (esp useful for tables with a lot of columns) - New configuration that sets `overwrite` to `false` by default, much safer for models that a dev has created and modified (force them to overwrite explicitly) - New configuration that sets `$primaryKey` - supports lambda to run database-specific query to obtain the primary key column - Expanded on previous `$cast` types, tried to make it a little smarter with regexp - New configurations for whitelist and blacklist ("migrations" table included in config's blacklist by default, so devs have the choice to include it or not) - General stub cleanup --- config/modelfromtable.php | 40 ++++++ src/Commands/ModelFromTableCommand.php | 170 ++++++++++++++++++++----- src/GeneratorsServiceProvider.php | 5 +- src/stubs/model.stub | 44 +++++-- 4 files changed, 219 insertions(+), 40 deletions(-) create mode 100644 config/modelfromtable.php diff --git a/config/modelfromtable.php b/config/modelfromtable.php new file mode 100644 index 0000000..4ed9f74 --- /dev/null +++ b/config/modelfromtable.php @@ -0,0 +1,40 @@ + '', + + 'namespace' => '', + + 'table' => '', + + 'primaryKey' => 'id', + + 'folder' => '', + + 'debug' => false, + + 'all' => false, + + 'singular' => false, + + 'overwrite' => false, + + 'blacklist' => ['migrations'], + + 'whitelist' => [], + + 'delimiter' => ', ' +]; \ No newline at end of file diff --git a/src/Commands/ModelFromTableCommand.php b/src/Commands/ModelFromTableCommand.php index 992b0bc..c82bd82 100644 --- a/src/Commands/ModelFromTableCommand.php +++ b/src/Commands/ModelFromTableCommand.php @@ -17,11 +17,13 @@ class ModelFromTableCommand extends Command protected $signature = 'generate:modelfromtable {--table= : a single table or a list of tables separated by a comma (,)} {--connection= : database connection to use, leave off and it will use the .env connection} - {--debug : turns on debugging} + {--debug= : turns on debugging} {--folder= : by default models are stored in app, but you can change that} {--namespace= : by default the namespace that will be applied to all models is App} {--singular : class name and class file name singular or plural} - {--all : run for all tables}'; + {--all= : run for all tables} + {--overwrite= : overwrite model(s) if exists} + {--timestamps= : whether to timestamp or not}'; /** * The console command description. @@ -30,6 +32,7 @@ class ModelFromTableCommand extends Command */ protected $description = 'Generate models for the given tables based on their columns'; + public $fieldsDocBlock; public $fieldsFillable; public $fieldsHidden; public $fieldsCast; @@ -38,6 +41,7 @@ class ModelFromTableCommand extends Command public $debug; public $options; + private $delimiter; public $databaseConnection; @@ -52,12 +56,16 @@ public function __construct() $this->options = [ 'connection' => '', + 'namespace' => '', 'table' => '', - 'folder' => app()->path(), + 'folder' => $this->getModelPath(), 'debug' => false, 'all' => false, - 'singular' => '', + 'singular' => false, + 'overwrite' => false ]; + + $this->delimiter = config('modelfromtable.delimiter', ', '); } /** @@ -72,6 +80,7 @@ public function handle() $tables = []; $path = $this->options['folder']; + $overwrite = $this->getOption('overwrite', false); $modelStub = file_get_contents($this->getStub()); // can we run? @@ -82,7 +91,7 @@ public function handle() } // figure out if we need to create a folder or not - if($this->options['folder'] != app()->path()) { + if($this->options['folder'] != $this->getModelPath()) { if(! is_dir($this->options['folder'])) { mkdir($this->options['folder']); } @@ -108,6 +117,12 @@ public function handle() } $fullPath = "$path/$filename.php"; + + if (!$overwrite and file_exists($fullPath)) { + $this->doComment("Skipping file: $filename.php"); + continue; + } + $this->doComment("Generating file: $filename.php"); // gather information on it @@ -200,50 +215,103 @@ public function replaceModuleInformation($stub, $modelInformation) // replace table $stub = str_replace('{{table}}', $modelInformation['table'], $stub); + $primaryKey = config('modelfromtable.primaryKey', 'id'); + + // allow config to apply a lamba to obtain non-ordinary primary key name + if (is_callable($primaryKey)) { + $primaryKey = $primaryKey($modelInformation['table']); + } + // replace fillable + $this->fieldsDocBlock = ''; $this->fieldsHidden = ''; $this->fieldsFillable = ''; $this->fieldsCast = ''; foreach ($modelInformation['fillable'] as $field) { // fillable and hidden - if ($field != 'id') { - $this->fieldsFillable .= (strlen($this->fieldsFillable) > 0 ? ', ' : '')."'$field'"; + if ($field != $primaryKey) { + $this->interpolate($this->fieldsFillable, "'$field'"); $fieldsFiltered = $this->columns->where('field', $field); if ($fieldsFiltered) { // check type - switch (strtolower($fieldsFiltered->first()['type'])) { + $type = strtolower($fieldsFiltered->first()['type']); + $type = preg_replace("/\s.*$/", '', $type); + preg_match_all("/^(\w*)\((?:(\d+)(?:,(\d+))*)\)/", $type, $matches); + + $columnType = isset($matches[1][0]) ? $matches[1][0] : $type; + $columnLength = isset($matches[2][0]) ? $matches[2][0] : ''; + + $generateDocLine = function (string $type, string $field) { return str_pad("\n * @property {$type}", 25, ' ') . "$$field";}; + + switch ($columnType) { + case 'int': + case 'tinyint': + case 'boolean': + case 'bool': + $castType = ($columnLength == 1) ? 'boolean' : 'int'; + + $this->interpolate($this->fieldsDocBlock, $generateDocLine($castType, $field), ""); + $this->interpolate($this->fieldsCast, "'$field' => '$castType'"); + break; + case 'varchar': + case 'text': + case 'tinytext': + case 'mediumtext': + case 'longtext': + $this->interpolate($this->fieldsDocBlock, $generateDocLine('string', $field), ""); + $this->interpolate($this->fieldsCast, "'$field' => 'string'"); + break; + case 'float': + case 'double': + $this->interpolate($this->fieldsDocBlock, $generateDocLine('float', $field), ""); + $this->interpolate($this->fieldsCast, "'$field' => '$columnType'"); + break; case 'timestamp': - $this->fieldsDate .= (strlen($this->fieldsDate) > 0 ? ', ' : '')."'$field'"; + $this->interpolate($this->fieldsDocBlock, $generateDocLine('int', $field), ""); + $this->interpolate($this->fieldsCast, "'$field' => '$columnType'"); + $this->interpolate($this->fieldsDate, "'$field'"); break; case 'datetime': - $this->fieldsDate .= (strlen($this->fieldsDate) > 0 ? ', ' : '')."'$field'"; + $this->interpolate($this->fieldsDocBlock, $generateDocLine('DateTime', $field), ""); + $this->interpolate($this->fieldsCast, "'$field' => '$columnType'"); + $this->interpolate($this->fieldsDate, "'$field'"); break; case 'date': - $this->fieldsDate .= (strlen($this->fieldsDate) > 0 ? ', ' : '')."'$field'"; - break; - case 'tinyint(1)': - $this->fieldsCast .= (strlen($this->fieldsCast) > 0 ? ', ' : '')."'$field' => 'boolean'"; + $this->interpolate($this->fieldsDocBlock, $generateDocLine('Date', $field), ""); + $this->interpolate($this->fieldsCast, "'$field' => '$columnType'"); + $this->interpolate($this->fieldsDate, "'$field'"); break; } } } else { - if ($field != 'id' && $field != 'created_at' && $field != 'updated_at') { - $this->fieldsHidden .= (strlen($this->fieldsHidden) > 0 ? ', ' : '')."'$field'"; + if ($field != $primaryKey && $field != 'created_at' && $field != 'updated_at') { + $this->interpolate($this->fieldsHidden, "'$field'"); } } } + $timestamps = ($this->getOption('timestamps', false, true)) ? 'true' : 'false'; + // replace in stub + $stub = str_replace('{{docblock}}', $this->fieldsDocBlock, $stub); + $stub = str_replace('{{primaryKey}}', $primaryKey, $stub); $stub = str_replace('{{fillable}}', $this->fieldsFillable, $stub); $stub = str_replace('{{hidden}}', $this->fieldsHidden, $stub); $stub = str_replace('{{casts}}', $this->fieldsCast, $stub); $stub = str_replace('{{dates}}', $this->fieldsDate, $stub); + $stub = str_replace('{{timestamps}}', $timestamps, $stub); $stub = str_replace('{{modelnamespace}}', $this->options['namespace'], $stub); return $stub; } + private function interpolate(string &$string, string $add, $delimiter = null) + { + $delimiter = $delimiter ?? $this->delimiter; + $string .= (strlen($string) > 0 ? $delimiter : '').$add; + } + public function replaceConnection($stub, $database) { $replacementString = '/** @@ -251,7 +319,7 @@ public function replaceConnection($stub, $database) * * @var string */ - protected $connection = \''.$database.'\';'; + protected $connection = \''.$database.'\';'."\n\n"; if (strlen($database) <= 0) { $stub = str_replace('{{connection}}', '', $stub); @@ -278,41 +346,67 @@ public function getStub() public function getOptions() { // debug - $this->options['debug'] = ($this->option('debug')) ? true : false; + $this->options['debug'] = $this->getOption('debug', false, true); // connection - $this->options['connection'] = ($this->option('connection')) ? $this->option('connection') : ''; + $this->options['connection'] = $this->getOption('connection', ''); + + // folder + $this->options['folder'] = $this->getOption('folder', ''); + + // namespace + $this->options['namespace'] = $this->getOption('namespace', ''); // namespace with possible folder // if there is no folder specified and no namespace - if(! $this->option('folder') && ! $this->option('namespace')) { + if(! $this->options['folder'] && ! $this->options['namespace']) { // assume default APP $this->options['namespace'] = 'App'; } else { // if we have a namespace, use it first - if($this->option('namespace')) { - $this->options['namespace'] = str_replace('/', '\\', $this->option('namespace')); + if($this->options['namespace']) { + $this->options['namespace'] = str_replace('/', '\\', $this->options['namespace']); } else { - if($this->option('folder')) { - $folder = $this->option('folder'); + if($folder = $this->options['folder']) { $this->options['namespace'] = str_replace('/', '\\', $folder); } } } // finish setting up folder - $this->options['folder'] = ($this->option('folder')) ? base_path($this->option('folder')) : app()->path(); + $this->options['folder'] = ($this->options['folder']) ? base_path($this->options['folder']) : $this->getModelPath(); // trim trailing slashes $this->options['folder'] = rtrim($this->options['folder'], '/'); // all tables - $this->options['all'] = ($this->option('all')) ? true : false; + $this->options['all'] = $this->getOption('all', false, true); // single or list of tables - $this->options['table'] = ($this->option('table')) ? $this->option('table') : ''; + $this->options['table'] = $this->getOption('table', ''); // class name and class file name singular/plural - $this->options['singular'] = ($this->option('singular')) ? $this->option('singular') : ''; + $this->options['singular'] = $this->getOption('singular', false, true); + } + + /** + * returns single option with priority being user input, then user config, then default + */ + private function getOption(string $key, $default = null, bool $isBool = false) + { + if ($isBool) { + $return = ($this->option($key)) + ? filter_var($this->option($key), FILTER_VALIDATE_BOOLEAN) + : config("modelfromtable.{$key}", $default); + } else { + $return = $this->options[$key] = $this->option($key) ?? config("modelfromtable.{$key}", $default); + } + + return $return; + } + + private function getModelPath() + { + return (app()->version() > '8')? app()->path('Models') : app()->path(); } /** @@ -331,6 +425,8 @@ public function doComment($text, $overrideDebug = false) public function getAllTables() { $tables = []; + $whitelist = config('modelfromtable.whitelist', []); + $blacklist = config('modelfromtable.blacklist', []); if (strlen($this->options['connection']) <= 0) { $tables = collect(DB::select(DB::raw("show full tables where Table_Type = 'BASE TABLE'")))->flatten(); @@ -340,8 +436,21 @@ public function getAllTables() $tables = $tables->map(function ($value, $key) { return collect($value)->flatten()[0]; - })->reject(function ($value, $key) { - return $value == 'migrations'; + })->reject(function ($value, $key) use ($blacklist) { + foreach($blacklist as $reject) { + if (fnmatch($reject, $value)) { + return true; + } + } + })->filter(function ($value, $key) use ($whitelist) { + if (!$whitelist) { + return true; + } + foreach($whitelist as $accept) { + if (fnmatch($accept, $value)) { + return true; + } + } }); return $tables; @@ -352,6 +461,7 @@ public function getAllTables() */ public function resetFields() { + $this->fieldsDocBlock = ''; $this->fieldsFillable = ''; $this->fieldsHidden = ''; $this->fieldsCast = ''; diff --git a/src/GeneratorsServiceProvider.php b/src/GeneratorsServiceProvider.php index 51dbbcf..5018d27 100644 --- a/src/GeneratorsServiceProvider.php +++ b/src/GeneratorsServiceProvider.php @@ -20,11 +20,14 @@ class GeneratorsServiceProvider extends ServiceProvider public function boot() { - // + $this->publishes([ + __DIR__.'/../config/modelfromtable.php' => config_path('modelfromtable.php'), + ], 'modelfromtable'); } public function register() { + $this->mergeConfigFrom(__DIR__.'/../config/modelfromtable.php', 'modelfromtable'); $this->registerModelGenerator(); } diff --git a/src/stubs/model.stub b/src/stubs/model.stub index 8f41c65..b6b1d08 100644 --- a/src/stubs/model.stub +++ b/src/stubs/model.stub @@ -4,44 +4,70 @@ namespace {{modelnamespace}}; use Illuminate\Database\Eloquent\Model; -class {{class}} extends Model +/**{{docblock}} + */ +class {{class}} extends Model { - - {{connection}} - - /** + {{connection}}/** * The database table used by the model. * * @var string */ protected $table = '{{table}}'; + /** + * The primary key for the model. + * + * @var string + */ + protected $primaryKey = '{{primaryKey}}'; + /** * Attributes that should be mass-assignable. * * @var array */ - protected $fillable = [{{fillable}}]; + protected $fillable = [ + {{fillable}} + ]; /** * The attributes excluded from the model's JSON form. * * @var array */ - protected $hidden = [{{hidden}}]; + protected $hidden = [ + {{hidden}} + ]; /** * The attributes that should be casted to native types. * * @var array */ - protected $casts = [{{casts}}]; + protected $casts = [ + {{casts}} + ]; /** * The attributes that should be mutated to dates. * * @var array */ - protected $dates = [{{dates}}]; + protected $dates = [ + {{dates}} + ]; + + /** + * Indicates if the model should be timestamped. + * + * @var boolean + */ + public $timestamps = {{timestamps}}; + + // Scopes... + + // Functions ... + // Relations ... }