diff --git a/README.md b/README.md index 65b4ff4..bbebc8e 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ [![Total Downloads](https://poser.pugx.org/leafs/mvc-core/downloads)](https://packagist.org/packages/leafs/mvc-core) [![License](https://poser.pugx.org/leafs/mvc-core/license)](https://packagist.org/packages/leafs/mvc-core) -This is the heart of Leaf MVC. It serves as a bridge between Leaf and the MVC file structure. It provides a ton of functionality that makes it easy to build a full-blown MVC application with Leaf. +Leaf MVC Core is the heart of Leaf MVC and serves as bridge between Leaf, modules and the MVC file structure. It provides a ton of extra functionality like extra globals, classes and methods that help with separation of concerns and building a full-blown MVC application with Leaf. ## 📦 Installation @@ -32,12 +32,9 @@ composer require leafs/mvc-core MVC Core comes with: - Controllers -- Api Controllers -- Database & Models -- Factories -- Models -- Schemas +- Database & Model functionalities - Tons of MVC and module globals +- Autoloading directory files Since you don't use this package on its own, the documentation is covered in the [Leaf MVC documentation](https://leafphp.dev/docs/mvc/). diff --git a/composer.json b/composer.json index 7c895f2..f8362d2 100644 --- a/composer.json +++ b/composer.json @@ -32,12 +32,16 @@ ] }, "minimum-stability": "dev", - "prefer-stable": true, + "prefer-stable": true, "require": { "leafs/leaf": "*", "doctrine/dbal": "^3.2", "vlucas/phpdotenv": "^5.4", "illuminate/database": "^8.75", - "illuminate/events": "^8.75" + "illuminate/events": "^8.75", + "symfony/yaml": "^6.4" + }, + "require-dev": { + "fakerphp/faker": "^1.24" } } diff --git a/src/Controller.php b/src/Controller.php index 4635d3b..fffafa8 100755 --- a/src/Controller.php +++ b/src/Controller.php @@ -66,54 +66,7 @@ public function id() */ public function view(string $view, array $data = []) { - /// WILL REFACTOR IN NEXT VERSION - - if (is_object($data)) { - $data = (array) $data; - } - - if (ViewConfig('render')) { - if (ViewConfig('config')) { - call_user_func_array(ViewConfig('config'), [[ - 'views' => AppConfig('views.path'), - 'cache' => AppConfig('views.cachePath'), - ]]); - } - - return ViewConfig('render')($view, $data); - } - - $engine = ViewConfig('viewEngine'); - $className = strtolower(get_class(new $engine)); - - $fullName = explode('\\', $className); - $className = $fullName[count($fullName) - 1]; - - if (\Leaf\Config::getStatic("views.$className")) { - if (ViewConfig('config')) { - call_user_func_array(ViewConfig('config'), [[ - 'views' => AppConfig('views.path'), - 'cache' => AppConfig('views.cachePath'), - ]]); - } else { - \Leaf\Config::get("views.$className")->configure(AppConfig('views.path'), AppConfig('views.cachePath')); - } - - return \Leaf\Config::get("views.$className")->render($view, $data); - } - - $engine = new $engine($engine); - - if (ViewConfig('config')) { - call_user_func_array(ViewConfig('config'), [[ - 'views' => AppConfig('views.path'), - 'cache' => AppConfig('views.cachePath'), - ]]); - } else { - $engine->config(AppConfig('views.path'), AppConfig('views.cachePath')); - } - - return $engine->render($view, $data); + return view($view, $data); } /** diff --git a/src/Core.php b/src/Core.php index 95c2e59..3ce017b 100644 --- a/src/Core.php +++ b/src/Core.php @@ -50,7 +50,7 @@ public static function loadApplicationConfig() Config::getStatic('mvc.config.auth')['session'] ?? false ); - if ($csrfConfig['enabled'] ?? null !== null) { + if (($csrfConfig['enabled'] ?? null) !== null) { $csrfEnabled = $csrfConfig['enabled']; } @@ -65,25 +65,47 @@ public static function loadApplicationConfig() \Leaf\Vite::config('hotFile', 'public/hot'); } - Config::attachView(ViewConfig('viewEngine'), 'template'); - - if (ViewConfig('config')) { - call_user_func_array(ViewConfig('config'), [ - app()->template(), - [ + if (ViewConfig('viewEngine')) { + Config::attachView(ViewConfig('viewEngine'), 'template'); + + if (ViewConfig('config')) { + call_user_func_array(ViewConfig('config'), [ + app()->template(), + [ + 'views' => AppConfig('views.path'), + 'cache' => AppConfig('views.cachePath'), + ] + ]); + } else if (method_exists(app()->template(), 'configure')) { + app()->template()->configure([ 'views' => AppConfig('views.path'), 'cache' => AppConfig('views.cachePath'), - ] - ]); - } else if (method_exists(app()->template(), 'configure')) { - app()->template()->configure([ - 'views' => AppConfig('views.path'), - 'cache' => AppConfig('views.cachePath'), - ]); + ]); + } + + if (is_callable(ViewConfig('extend'))) { + call_user_func(ViewConfig('extend'), app()->template()); + } + } + + if (DatabaseConfig('sync')) { + \Leaf\Database::initDb(); + } + + if (storage()->exists(LibPath())) { + static::loadLibs(); + } + + if ( + class_exists('Leaf\Billing\Stripe') || + class_exists('Leaf\Billing\PayStack') || + class_exists('Leaf\Billing\LemonSqueezy') + ) { + billing(Config::getStatic('mvc.config.billing')); } - if (is_callable(ViewConfig('extend'))) { - call_user_func_array(ViewConfig('extend'), app()->template()); + if (storage()->exists('app/index.php')) { + require 'app/index.php'; } } } diff --git a/src/Database.php b/src/Database.php index 335da1d..27eddb0 100755 --- a/src/Database.php +++ b/src/Database.php @@ -38,7 +38,7 @@ public static function connect() static::$capsule->bootEloquent(); if (php_sapi_name() === 'cli') { - Schema::$capsule = static::$capsule; + Schema::setDbConnection(static::$capsule); } } diff --git a/src/Factory.php b/src/Factory.php deleted file mode 100755 index bd73261..0000000 --- a/src/Factory.php +++ /dev/null @@ -1,184 +0,0 @@ -faker = \Faker\Factory::create(); - } - - if (class_exists(\Illuminate\Support\Str::class)) { - $this->str = \Illuminate\Support\Str::class; - } - } - - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - return []; - } - - /** - * Create a number of records based on definition - * - * @param int $number The number of records to create - * - * @return self - */ - public function create(int $number): Factory - { - $data = []; - - for ($i = 0; $i < $number; $i++) { - $data[] = $this->definition(); - } - - $this->data = $data; - - return $this; - } - - /** - * Create a relationship with another factory - * - * @param \Leaf\Factory $factory The instance of the factory to tie to - * @param array|string $primaryKey The primary key for that factory's table - * @throws \Exception - * @throws \Throwable - */ - public function has(Factory $factory, $primaryKey = null): Factory - { - if (count($this->data) === 0) { - $this->data[] = $this->definition(); - } - - $dataToOverride = []; - $model = $this->model ?? $this->getModelName(); - - if (!$primaryKey) { - $primaryKey = strtolower($this->getModelName() . '_id'); - $primaryKey = str_replace('\app\models\\', '', $primaryKey); - } - - if (is_array($primaryKey)) { - $dataToOverride = $primaryKey; - } else { - $key = explode('_', $primaryKey); - if (count($key) > 1) { - unset($key[0]); - } - $key = implode($key); - - $primaryKeyData = $this->data[\rand(0, count($this->data) - 1)][$key] ?? null; - $primaryKeyData = $primaryKeyData ?? $model::all()[\rand(0, count($model::all()) - 1)][$key]; - - $dataToOverride[$primaryKey] = $primaryKeyData; - } - - $factory->save($dataToOverride); - - return $this; - } - - /** - * Save created records in db - * - * @param array $override Override data to save - * - * @return true - * @throws \Exception - */ - public function save(array $override = []): bool - { - $model = $this->model ?? $this->getModelName(); - - if (count($this->data) === 0) { - $this->data[] = $this->definition(); - } - - foreach ($this->data as $item) { - $item = array_merge($item, $override); - - $model = new $model; - foreach ($item as $key => $value) { - $model->{$key} = $value; - } - $model->save(); - } - - return true; - } - - /** - * Return created records - * - * @param array|null $override Override data to save - * - * @return array - */ - public function get(array $override = null): array - { - if (count($this->data) === 0) { - $this->data[] = $this->definition(); - } - - if ($override) { - foreach ($this->data as $item) { - $item = array_merge($item, $override); - } - } - - return $this->data; - } - - /** - * Get the default model name - * @throws \Exception - */ - public function getModelName(): string - { - $class = get_class($this); - $modelClass = '\App\Models' . $this->str::studly(str_replace(['App\Database\Factories', 'Factory'], '', $class)); - - if (!class_exists($modelClass)) { - throw new \Exception('Couldn\'t retrieve model for ' . get_class($this) . '. Add a \$model attribute to fix this.'); - } - - return $modelClass; - } -} diff --git a/src/Schema.php b/src/Schema.php index b7b0ca2..36ecf4d 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -2,186 +2,333 @@ namespace Leaf; +use Illuminate\Database\Capsule\Manager; use Illuminate\Database\Schema\Blueprint; - +use Symfony\Component\Yaml\Yaml; + +/** + * Leaf DB Schema [WIP] + * --- + * One file to rule them all. + * + * @version 1.0 + */ class Schema { - public static $capsule; + /**@var \Illuminate\Database\Capsule\Manager $capsule */ + protected static Manager $connection; /** - * @param string $table The name of table to manipulate - * @param string|null $schema The JSON schema for database + * Migrate your schema file tables + * + * @param string $fileToMigrate The schema file to migrate + * @return bool */ - public static function build(string $table, ?string $schema = null) + public static function migrate(string $fileToMigrate): bool { - list($table, $schema) = static::getSchema($table, $schema); + $data = Yaml::parseFile($fileToMigrate); + $tableName = rtrim(path($fileToMigrate)->basename(), '.yml'); - if (is_array($schema)) { - $schema = $schema[0]; + if ($data['truncate'] ?? false) { + static::$connection::schema()->dropIfExists($tableName); } - if (!static::$capsule::schema()->hasTable($table)) { - static::$capsule::schema()->create($table, function (Blueprint $table) use ($schema) { - foreach ($schema as $key => $value) { - list($key, $type) = static::getColumns($key, $value); + try { + if (!static::$connection::schema()->hasTable($tableName)) { + if (storage()->exists(StoragePath("database/$tableName"))) { + storage()->delete(StoragePath("database/$tableName")); + } - echo $key . " => " . $type . "\n"; + static::$connection::schema()->create($tableName, function (Blueprint $table) use ($data) { + $columns = $data['columns'] ?? []; + $relationships = $data['relationships'] ?? []; - if (strpos($key, '*') === 0) { - $table->foreignId(substr($key, 1)); - continue; - } + $increments = $data['increments'] ?? true; + $timestamps = $data['timestamps'] ?? true; + $softDeletes = $data['softDeletes'] ?? false; + $rememberToken = $data['remember_token'] ?? false; - if ($key === 'timestamps') { - $table->timestamps(); - continue; + if ($increments) { + $table->increments('id'); } - if ($key === 'softDeletes') { - $table->softDeletes(); - continue; - } + foreach ($relationships as $model) { + if (strpos($model, 'App\Models') === false) { + $model = "App\Models\\$model"; + } - if ($key === 'rememberToken') { - $table->rememberToken(); - continue; + $table->foreignIdFor($model); } - if ($type === 'enum') { - if (substr($key, -1) === '?') { - $table->enum(substr($key, 0, -1), $value)->nullable(); - continue; + foreach ($columns as $columnName => $columnValue) { + if (is_string($columnValue)) { + $table->{$columnValue}($columnName); } - $table->enum($key, $value); - continue; - } + if (is_array($columnValue)) { + $column = $table->{$columnValue['type']}($columnName); - if (method_exists($table, $type)) { - if (substr($key, -1) === '?') { - call_user_func_array([$table, $type], [substr($key, 0, -1)])->nullable(); - continue; + unset($columnValue['type']); + + foreach ($columnValue as $columnOptionName => $columnOptionValue) { + if (is_bool($columnOptionValue)) { + $column->{$columnOptionName}(); + } else { + $column->{$columnOptionName}($columnOptionValue); + } + } } + } - call_user_func_array([$table, $type], [$key]); - continue; + if ($rememberToken) { + $table->rememberToken(); } - } - }); - } - } - /** - * Get the table and table structure - * @param string $table The name of table to manipulate (can be the name of the file) - * @param string|null $schema The JSON schema for database (if $table is not the name of the file) - * - * @return array - */ - protected static function getSchema(string $table, ?string $schema = null): array - { - try { - if ($schema === null) { - if (file_exists($table)) { - $schema = file_get_contents($table); - $table = str_replace('.json', '', basename($table)); - } else { - $table = str_replace('.json', '', $table); - $schema = json_decode(file_get_contents(AppPaths('schema') . "/$table.json")); - } - } else { - $schema = json_decode($schema); - } + if ($softDeletes) { + $table->softDeletes(); + } - return [$table, $schema]; - } catch (\Throwable $th) { - throw $th; - } - } + if ($timestamps) { + $table->timestamps(); + } + }); + } else if (storage()->exists(StoragePath("database/$tableName"))) { + static::$connection::schema()->table($tableName, function (Blueprint $table) use ($data, $tableName) { + $columns = $data['columns'] ?? []; + $relationships = $data['relationships'] ?? []; + + $allPreviousMigrations = glob(StoragePath("database/$tableName/*.yml")); + $lastMigration = $allPreviousMigrations[count($allPreviousMigrations) - 1] ?? null; + $lastMigration = Yaml::parseFile($lastMigration); + + $increments = $data['increments'] ?? true; + $timestamps = $data['timestamps'] ?? true; + $softDeletes = $data['softDeletes'] ?? false; + $rememberToken = $data['remember_token'] ?? false; + + if ($increments !== ($lastMigration['increments'] ?? true)) { + if ($increments && !static::$connection::schema()->hasColumn($tableName, 'id')) { + $table->increments('id'); + } else if (!$increments && static::$connection::schema()->hasColumn($tableName, 'id')) { + $table->dropColumn('id'); + } + } - /** - * Get the columns of a table and their types - * - * @param string $key The column as provided in the schema - * @param mixed $value The value of the column as provided in the schema - * - * @return array - */ - protected static function getColumns(string $key, $value): array - { - $type = ''; - $column = ''; - - $keyData = explode(':', $key); - - if (count($keyData) > 1) { - $type = trim($keyData[1]); - $column = trim($keyData[0]); - - if ($type === 'id') { - $column .= '*'; - $type = 'bigIncrements'; - } else if ($type === 'number') { - $type = 'integer'; - } else if ($type === 'bool') { - $type = 'boolean'; - } + $columnsDiff = []; + $staticColumns = []; + $removedColumns = []; + + foreach ($lastMigration['columns'] as $colKey => $colVal) { + if (!array_key_exists($colKey, $columns)) { + $removedColumns[] = $colKey; + } else if (static::getColumnAttributes($colVal) !== static::getColumnAttributes($columns[$colKey])) { + $columnsDiff[] = $colKey; + $staticColumns[] = $colKey; + } else { + $staticColumns[] = $colKey; + } + } - if (gettype($value) === 'NULL' && rtrim($column, '*') !== $column) { - $column .= '?'; - } + $newColumns = array_diff(array_keys($columns), $staticColumns); - return [$column, $type]; - } + if (count($newColumns) > 0) { + foreach ($newColumns as $newColumn) { + $column = static::getColumnAttributes($columns[$newColumn]); - echo $key . " => " . $value . "\n"; + if (!static::$connection::schema()->hasColumn($tableName, $newColumn)) { + $newCol = $table->{$column['type']}($newColumn); - if ( - (strtolower($key) === 'id' && gettype($value) === 'integer') || - (strpos(strtolower($key), '_id') !== false && gettype($value) === 'integer') - ) { - return [$key, 'bigIncrements']; - } + unset($column['type']); - if ( - strpos(ltrim(strtolower(preg_replace('/[A-Z]([A-Z](?![a-z]))*/', '_$0', $key)), '_'), '_at') !== false || - strpos(ltrim(strtolower(preg_replace('/[A-Z]([A-Z](?![a-z]))*/', '_$0', $key)), '_'), '_date') !== false || - strpos(ltrim(strtolower(preg_replace('/[A-Z]([A-Z](?![a-z]))*/', '_$0', $key)), '_'), '_time') !== false || - strpos($key, 'timestamp') === 0 || - strpos($key, 'time') === 0 || - strpos($key, 'date') === 0 - ) { - return [$key, 'timestamp']; - } + foreach ($column as $columnOptionName => $columnOptionValue) { + if (is_bool($columnOptionValue)) { + if ($columnOptionValue) { + $newCol->{$columnOptionName}(); + } + } else { + $newCol->{$columnOptionName}($columnOptionValue); + } + } + } + } + } - if (gettype($value) === 'integer') { - return [$key, 'integer']; - } + if (count($columnsDiff) > 0) { + foreach ($columnsDiff as $changedColumn) { + $column = static::getColumnAttributes($columns[$changedColumn]); + $prevMigrationColumn = static::getColumnAttributes($lastMigration['columns'][$changedColumn] ?? []); + + if ($column['type'] === 'timestamp') { + continue; + } + + $newCol = $table->{$column['type']}( + $changedColumn, + ($column['type'] === 'string') ? $column['length'] : null + ); + + unset($column['type']); + + foreach ($column as $columnOptionName => $columnOptionValue) { + if ($columnOptionValue === $prevMigrationColumn[$columnOptionName]) { + continue; + } + + if ($columnOptionName === 'unique') { + if ($columnOptionValue) { + $newCol->unique()->change(); + } else { + $table->dropUnique("{$tableName}_{$changedColumn}_unique"); + } + + continue; + } + + if ($columnOptionName === 'index') { + if ($columnOptionValue) { + $newCol->index()->change(); + } else { + $table->dropIndex("{$tableName}_{$changedColumn}_index"); + } + + continue; + } + + // skipping this for now, primary + autoIncrement + // doesn't work well in the same run. They need to be + // run separately for some reason + // if ($columnOptionName === 'autoIncrement') { + + if ($columnOptionName === 'primary') { + if ($columnOptionValue) { + $newCol->primary()->change(); + } else { + $table->dropPrimary("{$tableName}_{$changedColumn}_primary"); + } + + continue; + } + + if ($columnOptionName === 'default') { + $newCol->default($columnOptionValue)->change(); + continue; + } + + if (is_bool($columnOptionValue)) { + + if ($columnOptionValue) { + $newCol->{$columnOptionName}()->change(); + } else { + $newCol->{$columnOptionName}(false)->change(); + } + } else { + $newCol->{$columnOptionName}($columnOptionValue)->change(); + } + } + + $newCol->change(); + } + } - if (gettype($value) === 'double') { - return [$key, 'float']; - } + if (count($removedColumns) > 0) { + foreach ($removedColumns as $removedColumn) { + if (static::$connection::schema()->hasColumn($tableName, $removedColumn)) { + $table->dropColumn($removedColumn); + } + } + } - if (gettype($value) === 'string') { - if (strpos($value, '{') === 0 || strpos($value, '[') === 0) { - return [$key, 'json']; - } + if ($rememberToken !== ($lastMigration['remember_token'] ?? false)) { + if ($rememberToken && !static::$connection::schema()->hasColumn($tableName, 'remember_token')) { + $table->rememberToken(); + } else if (!$rememberToken && static::$connection::schema()->hasColumn($tableName, 'remember_token')) { + $table->dropRememberToken(); + } + } - if ($key === 'description' || $key === 'text' || strlen($value) > 150) { - return [$key, 'text']; + if ($softDeletes !== ($lastMigration['softDeletes'] ?? false)) { + if ($softDeletes && !static::$connection::schema()->hasColumn($tableName, 'deleted_at')) { + $table->softDeletes(); + } else if (!$softDeletes && static::$connection::schema()->hasColumn($tableName, 'deleted_at')) { + $table->dropSoftDeletes(); + } + } + + if ($timestamps !== ($lastMigration['timestamps'] ?? true)) { + if ($timestamps && !static::$connection::schema()->hasColumn($tableName, 'created_at')) { + $table->timestamps(); + } else if (!$timestamps && static::$connection::schema()->hasColumn($tableName, 'created_at')) { + $table->dropTimestamps(); + } + } + }); } - return [$key, 'string']; + storage()->copy( + $fileToMigrate, + StoragePath('database' . '/' . $tableName . '/' . tick()->format('YYYY_MM_DD_HHmmss[.yml]')), + ['recursive' => true] + ); + } catch (\Throwable $th) { + throw $th; } - if (gettype($value) === 'array') { - return [$key, 'enum']; - } + return true; + } + + /** + * Seed a database table from schema file + * + * @param string $fileToSeed The name of the schema file + * @return bool + */ + public static function seed(string $fileToSeed): bool + { + $data = Yaml::parseFile($fileToSeed); + return true; + } - if (gettype($value) === 'boolean') { - return [$key, 'boolean']; + /** + * Get all column attributes + */ + public static function getColumnAttributes($value) + { + $attributes = [ + 'type' => 'string', + 'length' => null, + 'nullable' => false, + 'default' => null, + 'unsigned' => false, + 'index' => false, + 'unique' => false, + 'primary' => false, + 'foreign' => false, + 'foreignTable' => null, + 'foreignColumn' => null, + 'onDelete' => null, + 'onUpdate' => null, + 'comment' => null, + 'autoIncrement' => false, + 'useCurrent' => false, + 'useCurrentOnUpdate' => false, + ]; + + if (is_string($value)) { + $attributes['type'] = $value; + } else if (is_array($value)) { + $attributes = array_merge($attributes, $value); } - return [$column, $type]; + return $attributes; + } + + /** + * Set the internal db connection + * @param mixed $connection + * @return void + */ + public static function setDbConnection($connection) + { + static::$connection = $connection; } } diff --git a/src/globals/config.php b/src/globals/config.php index b7cbb86..1fede03 100644 --- a/src/globals/config.php +++ b/src/globals/config.php @@ -19,6 +19,7 @@ function PathsConfig($setting = null) 'channels' => 'app/channels', 'components' => 'app/components', 'controllers' => 'app/controllers', + 'database' => 'app/database', 'databaseStorage' => 'storage/app/db', 'exceptions' => 'app/exceptions', 'events' => 'app/events', @@ -94,5 +95,5 @@ function MailConfig($setting = null) function MvcConfig($appConfig, $setting = null) { $config = \Leaf\Config::getStatic("mvc.config.$appConfig"); - return !$setting ? $config : $config[$setting]; + return !$setting ? $config : ($config[$setting] ?? null); } diff --git a/src/globals/paths.php b/src/globals/paths.php index 07156ac..26d25b8 100755 --- a/src/globals/paths.php +++ b/src/globals/paths.php @@ -45,7 +45,7 @@ function ControllersPath($path = ''): string if (!function_exists('DatabasePath')) { /** - * Database storage path + * Database path */ function DatabasePath($path = ''): string {