diff --git a/CREDITS.md b/CREDITS.md index 4e270b05d..ecb7995d9 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -16,3 +16,6 @@ https://github.com/jakeasmith/http_build_url "Twig extensions", Copyright (c) 2016 Vojta Svoboda https://github.com/vojtasvoboda/oc-twigextensions-plugin + +"October Code", Copyright (c) 2022 Sergey Kasyanov +https://github.com/SergeyKasyanov/vscode-october-extension diff --git a/composer.json b/composer.json index 837df2c5b..183dcf866 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "composer/composer": "^2.0.0", "doctrine/dbal": "^2.13.3|^3.1.4", "linkorb/jsmin-php": "~1.0", - "wikimedia/less.php": "~3.0", + "wikimedia/less.php": "~4.1", "scssphp/scssphp": "~1.0", "symfony/yaml": "^6.0", "twig/twig": "~3.0", diff --git a/helpers/Auth.php b/helpers/Auth.php new file mode 100644 index 000000000..5e25cab1b --- /dev/null +++ b/helpers/Auth.php @@ -0,0 +1,8 @@ +userModel; + } +} diff --git a/src/Auth/Manager.php b/src/Auth/Manager.php index 20312cdc8..c593a7b49 100644 --- a/src/Auth/Manager.php +++ b/src/Auth/Manager.php @@ -17,6 +17,7 @@ class Manager implements StatefulGuard use \October\Rain\Auth\Concerns\HasThrottle; use \October\Rain\Auth\Concerns\HasImpersonation; use \October\Rain\Auth\Concerns\HasStatefulGuard; + use \October\Rain\Auth\Concerns\HasProviderProxy; use \October\Rain\Auth\Concerns\HasGuard; /** diff --git a/src/Auth/Models/User.php b/src/Auth/Models/User.php index 30b5e3ce1..dbcc8e4c2 100644 --- a/src/Auth/Models/User.php +++ b/src/Auth/Models/User.php @@ -421,7 +421,7 @@ public function addGroup($group) { if (!$this->inGroup($group)) { $this->groups()->attach($group); - $this->reloadRelations('groups'); + $this->unsetRelation('groups'); } return true; @@ -436,7 +436,7 @@ public function removeGroup($group) { if ($this->inGroup($group)) { $this->groups()->detach($group); - $this->reloadRelations('groups'); + $this->unsetRelation('groups'); } return true; diff --git a/src/Database/Attach/File.php b/src/Database/Attach/File.php index e28c5a8c5..8efe895d2 100644 --- a/src/Database/Attach/File.php +++ b/src/Database/Attach/File.php @@ -517,10 +517,6 @@ public function beforeSave() if ($this->data instanceof UploadedFile) { $this->fromPost($this->data); } - // @deprecated see AttachOneOrMany::isValidFileData - else { - $this->fromFile($this->data); - } $this->data = null; } diff --git a/src/Database/Builder.php b/src/Database/Builder.php index 7b330d612..938c9d1ee 100644 --- a/src/Database/Builder.php +++ b/src/Database/Builder.php @@ -3,6 +3,7 @@ use Illuminate\Pagination\Paginator; use Illuminate\Database\Eloquent\Builder as BuilderModel; use October\Rain\Support\Facades\DbDongle; +use Closure; /** * Builder class for queries, extends the Eloquent builder class. @@ -13,6 +14,24 @@ class Builder extends BuilderModel { use \October\Rain\Database\Concerns\HasNicerPagination; + use \October\Rain\Database\Concerns\HasEagerLoadAttachRelation; + + /** + * eagerLoadRelation eagerly load the relationship on a set of models, with support + * for attach relations. + * @param array $models + * @param string $name + * @param \Closure $constraints + * @return array + */ + protected function eagerLoadRelation(array $models, $name, Closure $constraints) + { + if ($result = $this->eagerLoadAttachRelation($models, $name, $constraints)) { + return $result; + } + + return parent::eagerLoadRelation($models, $name, $constraints); + } /** * lists gets an array with the values of a given column. diff --git a/src/Database/Concerns/HasAttributes.php b/src/Database/Concerns/HasAttributes.php index 7fcb415bb..fd5361b21 100644 --- a/src/Database/Concerns/HasAttributes.php +++ b/src/Database/Concerns/HasAttributes.php @@ -164,7 +164,7 @@ public function setAttribute($key, $value) // Handle direct relation setting if ($this->hasRelation($key) && !$this->hasSetMutator($key)) { - return $this->setRelationValue($key, $value); + return $this->setRelationSimpleValue($key, $value); } /** diff --git a/src/Database/Concerns/HasEagerLoadAttachRelation.php b/src/Database/Concerns/HasEagerLoadAttachRelation.php new file mode 100644 index 000000000..55d3a88ba --- /dev/null +++ b/src/Database/Concerns/HasEagerLoadAttachRelation.php @@ -0,0 +1,68 @@ +getModel()->getRelationType($name); + if (!$relationType || !in_array($relationType, ['attachOne', 'attachMany'])) { + return null; + } + + // Only vanilla attachments are supported, pass complex lookups back to Laravel + $definition = $this->getModel()->getRelationDefinition($name); + if (isset($definition['conditions']) || isset($definition['scope'])) { + return null; + } + + // Opt-out of the combined eager loading logic + if (isset($definition['combineEager']) && $definition['combineEager'] === false) { + return null; + } + + $relation = $this->getRelation($name); + $relatedModel = get_class($relation->getRelated()); + + // Perform a global look up attachment without the 'field' constraint + // to produce a combined subset of all possible attachment relations. + if (!isset($this->eagerLoadAttachResultCache[$relatedModel])) { + $relation->addCommonEagerConstraints($models); + + // Note this takes first constraint only. If it becomes a problem one solution + // could be to compare the md5 of toSql() to ensure uniqueness. The workaround + // for this edge case is to set combineEager => false in the definition. + $constraints($relation); + + $this->eagerLoadAttachResultCache[$relatedModel] = $relation->getEager(); + } + + $results = $this->eagerLoadAttachResultCache[$relatedModel]; + + return $relation->match( + $relation->initRelation($models, $name), + $results->where('field', $name), + $name + ); + } +} diff --git a/src/Database/Concerns/HasEvents.php b/src/Database/Concerns/HasEvents.php index ef76fee1b..d34c9c82f 100644 --- a/src/Database/Concerns/HasEvents.php +++ b/src/Database/Concerns/HasEvents.php @@ -18,9 +18,7 @@ trait HasEvents */ protected function bootNicerEvents() { - $class = get_called_class(); - - if (isset(static::$eventsBooted[$class])) { + if (isset(static::$eventsBooted[static::class])) { return; } @@ -39,20 +37,26 @@ protected function bootNicerEvents() ]; foreach ($nicerEvents as $eventMethod => $method) { - self::$eventMethod(function ($model) use ($method) { - $model->fireEvent('model.' . $method); - - if ($model->methodExists($method)) { - return $model->$method(); - } + self::registerModelEvent($eventMethod, function ($model) use ($method) { + $model->fireEvent("model.{$method}"); + return $model->$method(); }); } + // Hooks for late stage attribute changes + self::registerModelEvent('creating', function ($model) { + $model->fireEvent('model.beforeSaveDone'); + }); + + self::registerModelEvent('updating', function ($model) { + $model->fireEvent('model.beforeSaveDone'); + }); + // Boot event $this->fireEvent('model.afterBoot'); $this->afterBoot(); - static::$eventsBooted[$class] = true; + static::$eventsBooted[static::class] = true; } /** diff --git a/src/Database/Concerns/HasRelationships.php b/src/Database/Concerns/HasRelationships.php index 001d46af2..a8b2b90b0 100644 --- a/src/Database/Concerns/HasRelationships.php +++ b/src/Database/Concerns/HasRelationships.php @@ -19,22 +19,24 @@ use InvalidArgumentException; /** - * HasRelationships concern for a model, using a cleaner declaration of relationships. + * HasRelationships is a concern used by the \October\Rain\Database\Model class, employing a + * cleaner declaration of model relationships. * - * Uses a similar approach to the relation methods used by Eloquent, but as separate properties - * that make the class file less cluttered. + * The relation definitions uses an almost identical approach to the relation methods defined + * by Eloquent, instead using class properties to make the class file less cluttered and keep + * the logic separated from the definition. * - * It should be declared with keys as the relation name, and value being a mixed array. - * The relation type $morphTo does not include a class name as the first value. + * Relations should be declared with keys as the relation name and value as a mixed array. + * The relation type `$morphTo` does not include a class name as the first value. * * Example: * - * class Order extends Model - * { - * protected $hasMany = [ - * 'items' => Item::class - * ]; - * } + * class Order extends Model + * { + * protected $hasMany = [ + * 'items' => Item::class + * ]; + * } * * @package october\database * @author Alexey Bobkov, Samuel Georges @@ -44,9 +46,9 @@ trait HasRelationships /** * @var array hasOne related record, inverse of belongsTo. * - * protected $hasOne = [ - * 'owner' => [User::class, 'key' => 'user_id'] - * ]; + * protected $hasOne = [ + * 'owner' => [User::class, 'key' => 'user_id'] + * ]; * */ public $hasOne = []; @@ -54,104 +56,108 @@ trait HasRelationships /** * @var array hasMany related records, inverse of belongsTo. * - * protected $hasMany = [ - * 'items' => Item::class - * ]; + * protected $hasMany = [ + * 'items' => Item::class + * ]; */ public $hasMany = []; /** * @var array belongsTo another record with a local key attribute * - * protected $belongsTo = [ - * 'parent' => [Category::class, 'key' => 'parent_id'] - * ]; + * protected $belongsTo = [ + * 'parent' => [Category::class, 'key' => 'parent_id'] + * ]; */ public $belongsTo = []; /** * @var array belongsToMany to multiple records using a join table. * - * protected $belongsToMany = [ - * 'groups' => [Group::class, 'table'=> 'join_groups_users'] - * ]; + * protected $belongsToMany = [ + * 'groups' => [Group::class, 'table'=> 'join_groups_users'] + * ]; */ public $belongsToMany = []; /** * @var array morphTo another record using local key and type attributes * - * protected $morphTo = [ - * 'pictures' => [] - * ]; + * protected $morphTo = [ + * 'pictures' => [] + * ]; */ public $morphTo = []; /** * @var array morphOne related record, inverse of morphTo. * - * protected $morphOne = [ - * 'log' => [History::class, 'name' => 'user'] - * ]; + * protected $morphOne = [ + * 'log' => [History::class, 'name' => 'user'] + * ]; */ public $morphOne = []; /** * @var array morphMany related records, inverse of morphTo. * - * protected $morphMany = [ - * 'log' => [History::class, 'name' => 'user'] - * ]; + * protected $morphMany = [ + * 'log' => [History::class, 'name' => 'user'] + * ]; */ public $morphMany = []; /** * @var array morphToMany to multiple records using a join table. * - * protected $morphToMany = [ - * 'tag' => [Tag::class, 'table' => 'tagables', 'name' => 'tagable'] - * ]; + * protected $morphToMany = [ + * 'tag' => [Tag::class, 'table' => 'tagables', 'name' => 'tagable'] + * ]; */ public $morphToMany = []; /** - * @var array morphedByMany + * @var array morphedByMany to a polymorphic, inverse many-to-many relationship. + * + * public $morphedByMany = [ + * 'tag' => [Tag::class, 'table' => 'tagables', 'name' => 'tagable'] + * ]; */ public $morphedByMany = []; /** * @var array attachOne file attachment. * - * protected $attachOne = [ - * 'picture' => [\October\Rain\Database\Attach\File::class, 'public' => false] - * ]; + * protected $attachOne = [ + * 'picture' => [\October\Rain\Database\Attach\File::class, 'public' => false] + * ]; */ public $attachOne = []; /** * @var array attachMany file attachments. * - * protected $attachMany = [ - * 'pictures' => [\October\Rain\Database\Attach\File::class, 'name'=> 'imageable'] - * ]; + * protected $attachMany = [ + * 'pictures' => [\October\Rain\Database\Attach\File::class, 'name'=> 'imageable'] + * ]; */ public $attachMany = []; /** * @var array hasManyThrough is related records through another record. * - * protected $hasManyThrough = [ - * 'posts' => [Post::class, 'through' => User::class] - * ]; + * protected $hasManyThrough = [ + * 'posts' => [Post::class, 'through' => User::class] + * ]; */ public $hasManyThrough = []; /** * @var array hasOneThrough is a related record through another record. * - * protected $hasOneThrough = [ - * 'post' => [Post::class, 'through' => User::class] - * ]; + * protected $hasOneThrough = [ + * 'post' => [Post::class, 'through' => User::class] + * ]; */ public $hasOneThrough = []; @@ -254,12 +260,24 @@ public function isRelationTypeSingular($name): bool } /** - * makeRelation returns a relation class object - * @param string $name Relation name - * @return object + * makeRelation returns a relation class object, supporting nested relations with + * dot notation + * @param string $name + * @return \Model|null */ public function makeRelation($name) { + if (str_contains($name, '.')) { + $model = $this; + $parts = explode('.', $name); + while ($relationName = array_shift($parts)) { + if (!$model = $model->makeRelation($relationName)) { + return null; + } + } + return $model; + } + $relation = $this->getRelationDefinition($name); $relationType = $this->getRelationType($name); @@ -271,7 +289,8 @@ public function makeRelation($name) } /** - * makeRelationInternal + * makeRelationInternal is used internally to create a new related instance. It also + * fires the `afterRelation` to extend the created instance. */ protected function makeRelationInternal(string $relationName, string $relationClass) { @@ -285,7 +304,7 @@ protected function makeRelationInternal(string $relationName, string $relationCl /** * isRelationPushable determines whether the specified relation should be saved - * when push() is called instead of save() on the model. Default: true. + * when `push()` is called instead of `save()` on the model. Defaults to `true`. */ public function isRelationPushable(string $name): bool { @@ -300,7 +319,7 @@ public function isRelationPushable(string $name): bool /** * getRelationDefaults returns default relation arguments for a given type. - * @param string $type Relation type + * @param string $type * @return array */ protected function getRelationDefaults($type) @@ -330,7 +349,7 @@ protected function handleRelation($relationName) throw new InvalidArgumentException(sprintf( "Relation '%s' on model '%s' should have at least a classname.", $relationName, - get_called_class() + static::class )); } @@ -338,7 +357,7 @@ protected function handleRelation($relationName) throw new InvalidArgumentException(sprintf( "Relation '%s' on model '%s' is a morphTo relation and should not contain additional arguments.", $relationName, - get_called_class() + static::class )); } @@ -393,7 +412,7 @@ protected function handleRelation($relationName) break; default: - throw new InvalidArgumentException(sprintf("There is no such relation type known as '%s' on model '%s'.", $relationType, get_called_class())); + throw new InvalidArgumentException(sprintf("There is no such relation type known as '%s' on model '%s'.", $relationType, static::class)); } // Relation hook event @@ -430,7 +449,7 @@ protected function validateRelationArgs($relationName, $optional, $required = [] throw new InvalidArgumentException(sprintf( 'Relation "%s" on model "%s" should contain the following key(s): %s', $relationName, - get_called_class(), + static::class, implode(', ', $missingRequired) )); } @@ -498,9 +517,9 @@ public function morphOne($related, $name, $type = null, $id = null, $localKey = } /** - * belongsTo defines an inverse one-to-one or many relationship. - * Overridden from {@link Eloquent\Model} to allow the usage of the intermediary methods to handle the {@link - * $relationsData} array. + * belongsTo defines an inverse one-to-one or many relationship. Overridden from + * \Eloquent\Model to allow the usage of the intermediary methods to handle the + * relationsData array. * @return \October\Rain\Database\Relations\BelongsTo */ public function belongsTo($related, $foreignKey = null, $parentKey = null, $relationName = null) @@ -524,7 +543,8 @@ public function belongsTo($related, $foreignKey = null, $parentKey = null, $rela /** * morphTo defines a polymorphic, inverse one-to-one or many relationship. - * Overridden from {@link Eloquent\Model} to allow the usage of the intermediary methods to handle the relation. + * Overridden from \Eloquent\Model to allow the usage of the intermediary + * methods to handle the relation. * @return \October\Rain\Database\Relations\BelongsTo */ public function morphTo($name = null, $type = null, $id = null, $ownerKey = null) @@ -848,58 +868,67 @@ protected function getRelationCaller() } /** - * getRelationValue returns a relation key value(s), not as an object. + * getRelationSimpleValue returns a relation key value(s), not as an object. */ - public function getRelationValue($relationName) + public function getRelationSimpleValue($relationName) { return $this->$relationName()->getSimpleValue(); } /** - * setRelationValue sets a relation value directly from its attribute. + * setRelationSimpleValue sets a relation value directly from its attribute. */ - protected function setRelationValue($relationName, $value) + protected function setRelationSimpleValue($relationName, $value) { $this->$relationName()->setSimpleValue($value); } /** - * performDeleteOnRelations locates relations with delete flag and cascades - * the delete event. + * performDeleteOnRelations locates relations with delete flag and cascades the + * delete event. This is called before the parent model is deleted. This method + * checks in with the Multisite trait to preserve shared relations. + * + * @see \October\Rain\Database\Traits\Multisite::canDeleteMultisiteRelation */ protected function performDeleteOnRelations() { $definitions = $this->getRelationDefinitions(); + $useMultisite = $this->isClassInstanceOf(\October\Contracts\Database\MultisiteInterface::class) && $this->isMultisiteEnabled(); + foreach ($definitions as $type => $relations) { - // Hard 'delete' definition foreach ($relations as $name => $options) { - if (!Arr::get($options, 'delete', false)) { - continue; - } - - if (!$relation = $this->{$name}) { + // Detect and preserve shared multisite relationships + if ($useMultisite && !$this->canDeleteMultisiteRelation($name, $type)) { continue; } - if ($relation instanceof EloquentModel) { - $relation->forceDelete(); - } - elseif ($relation instanceof CollectionBase) { - $relation->each(function ($model) { - $model->forceDelete(); - }); - } - } - - // Belongs-To-Many should clean up after itself by default - if ($type === 'belongsToMany') { - foreach ($relations as $name => $options) { + // Belongs-To-Many should clean up after itself by default + if ($type === 'belongsToMany') { if (!Arr::get($options, 'detach', true)) { return; } $this->{$name}()->detach(); } + // Hard 'delete' definition + else { + if (!Arr::get($options, 'delete', false)) { + continue; + } + + if (!$relation = $this->{$name}) { + continue; + } + + if ($relation instanceof EloquentModel) { + $relation->forceDelete(); + } + elseif ($relation instanceof CollectionBase) { + $relation->each(function ($model) { + $model->forceDelete(); + }); + } + } } } } diff --git a/src/Database/Concerns/HasReplication.php b/src/Database/Concerns/HasReplication.php index b9c6422c2..6a3d1bcf6 100644 --- a/src/Database/Concerns/HasReplication.php +++ b/src/Database/Concerns/HasReplication.php @@ -1,12 +1,12 @@ replicateRelationsInternal($except); + return App::makeWith('db.replicator', ['model' => $this])->replicate($except); } /** @@ -32,98 +32,20 @@ public function replicateWithRelations(array $except = null) */ public function duplicateWithRelations(array $except = null) { - return $this->replicateRelationsInternal($except, ['isDuplicate' => true]); + return App::makeWith('db.replicator', ['model' => $this])->duplicate($except); } /** - * replicateRelationsInternal + * newReplicationInstance returns a new instance used by the replicator */ - protected function replicateRelationsInternal(array $except = null, array $options = []) + public function newReplicationInstance($attributes) { - extract(array_merge([ - 'isDuplicate' => false - ], $options)); - - $defaults = [ - $this->getKeyName(), - $this->getCreatedAtColumn(), - $this->getUpdatedAtColumn(), - ]; - - $isMultisite = $this->isClassInstanceOf(\October\Contracts\Database\MultisiteInterface::class); - if ($isMultisite) { - $defaults[] = 'site_root_id'; - } - - $attributes = Arr::except( - $this->attributes, $except ? array_unique(array_merge($except, $defaults)) : $defaults - ); - $instance = $this->newInstance(); $instance->setRawAttributes($attributes); $instance->fireModelEvent('replicating', false); - $definitions = $this->getRelationDefinitions(); - - foreach ($definitions as $type => $relations) { - foreach ($relations as $name => $options) { - if ($this->isRelationReplicable($name, $isMultisite, $isDuplicate)) { - $this->replicateRelationInternal($instance->$name(), $this->$name); - } - } - } - return $instance; } - - /** - * replicateRelationInternal on the model instance with the supplied ones - */ - protected function replicateRelationInternal($relationObject, $models) - { - if ($models instanceof CollectionBase) { - $models = $models->all(); - } - elseif ($models instanceof EloquentModel) { - $models = [$models]; - } - else { - $models = (array) $models; - } - - foreach (array_filter($models) as $model) { - if ($relationObject instanceof HasOneOrMany) { - $relationObject->add($model->replicateWithRelations()); - } - else { - $relationObject->add($model); - } - } - } - - /** - * isRelationReplicable determines whether the specified relation should be replicated - * when replicateWithRelations() is called instead of save() on the model. Default: true. - */ - protected function isRelationReplicable(string $name, bool $isMultisite, bool $isDuplicate): bool - { - $relationType = $this->getRelationType($name); - if ($relationType === 'morphTo') { - return false; - } - - // Relation is shared via propagation - if (!$isDuplicate && $isMultisite && $this->isAttributePropagatable($name)) { - return false; - } - - $definition = $this->getRelationDefinition($name); - if (!array_key_exists('replicate', $definition)) { - return true; - } - - return (bool) $definition['replicate']; - } } diff --git a/src/Database/DatabaseServiceProvider.php b/src/Database/DatabaseServiceProvider.php index 3defd800b..78520b6e3 100644 --- a/src/Database/DatabaseServiceProvider.php +++ b/src/Database/DatabaseServiceProvider.php @@ -72,6 +72,8 @@ protected function registerConnectionServices() return new DatabaseTransactionsManager; }); + $this->app->bind('db.replicator', Replicator::class); + $this->app->singleton('db.dongle', function ($app) { return new Dongle($this->getDefaultDatabaseDriver(), $app['db']); }); @@ -84,6 +86,6 @@ protected function getDefaultDatabaseDriver(): string { $defaultConnection = $this->app['db']->getDefaultConnection(); - return $this->app['config']['database.connections.' . $defaultConnection . '.driver']; + return $this->app['config']["database.connections.{$defaultConnection}.driver"]; } } diff --git a/src/Database/ExpandoModel.php b/src/Database/ExpandoModel.php index e0f383c2a..14ca00621 100644 --- a/src/Database/ExpandoModel.php +++ b/src/Database/ExpandoModel.php @@ -31,13 +31,13 @@ public function __construct(array $attributes = []) $this->bindEvent('model.afterSave', [$this, 'expandoAfterSave']); // Process attributes last for traits with attribute modifiers - $this->bindEvent('model.saveInternal', [$this, 'expandoSaveInternal'], -1); + $this->bindEvent('model.beforeSaveDone', [$this, 'expandoBeforeSaveDone'], -1); $this->addJsonable($this->expandoColumn); } /** - * afterModelFetch constructor event + * expandoAfterFetch constructor event */ public function expandoAfterFetch() { @@ -47,9 +47,9 @@ public function expandoAfterFetch() } /** - * saveModelInternal constructor event + * expandoBeforeSaveDone constructor event */ - public function expandoSaveInternal() + public function expandoBeforeSaveDone() { $this->{$this->expandoColumn} = array_diff_key( $this->attributes, diff --git a/src/Database/Migrations/2013_10_01_000001_Db_Deferred_Bindings.php b/src/Database/Migrations/2013_10_01_000001_Db_Deferred_Bindings.php index 61528871c..aa0ed9ed1 100644 --- a/src/Database/Migrations/2013_10_01_000001_Db_Deferred_Bindings.php +++ b/src/Database/Migrations/2013_10_01_000001_Db_Deferred_Bindings.php @@ -14,7 +14,9 @@ public function up() $table->string('slave_type'); $table->integer('slave_id'); $table->string('session_key'); + $table->mediumText('pivot_data')->nullable(); $table->boolean('is_bind')->default(true); + $table->integer('sort_order')->nullable(); $table->timestamps(); }); } diff --git a/src/Database/Migrations/2021_10_01_000004_Db_Add_Pivot_Data_To_Deferred_Bindings.php b/src/Database/Migrations/2021_10_01_000004_Db_Add_Pivot_Data_To_Deferred_Bindings.php deleted file mode 100644 index 2b0238242..000000000 --- a/src/Database/Migrations/2021_10_01_000004_Db_Add_Pivot_Data_To_Deferred_Bindings.php +++ /dev/null @@ -1,21 +0,0 @@ -mediumText('pivot_data')->nullable()->after('slave_id'); - }); - } - - public function down() - { - Schema::table('deferred_bindings', function (Blueprint $table) { - $table->dropColumn('pivot_data'); - }); - } -}; diff --git a/src/Database/Model.php b/src/Database/Model.php index 48be9c88a..8a7382b6b 100644 --- a/src/Database/Model.php +++ b/src/Database/Model.php @@ -110,17 +110,15 @@ public function reload() } /** - * reloadRelations for this model. - * @param string $relationName - * @return void + * @deprecated use unsetRelation or unsetRelations */ public function reloadRelations($relationName = null) { if (!$relationName) { - $this->setRelations([]); + $this->unsetRelations(); } else { - unset($this->relations[$relationName]); + $this->unsetRelation($relationName); } } @@ -376,13 +374,6 @@ protected function saveInternal($options = []) return false; } - // Validate attributes before trying to save - foreach ($this->attributes as $attribute => $value) { - if (is_array($value)) { - throw new Exception(sprintf('Unexpected type of array when attempting to save attribute "%s", try adding it to the $jsonable property.', $attribute)); - } - } - // Apply pre deferred bindings if ($this->sessionKey !== null) { $this->commitDeferredBefore($this->sessionKey); diff --git a/src/Database/Models/DeferredBinding.php b/src/Database/Models/DeferredBinding.php index b00c5175f..1fbc2500b 100644 --- a/src/Database/Models/DeferredBinding.php +++ b/src/Database/Models/DeferredBinding.php @@ -59,7 +59,7 @@ public function beforeCreate() * getPivotDataForBind strips attributes beginning with an underscore, allowing * meta data to be stored using the column alongside the data. */ - public function getPivotDataForBind(): array + public function getPivotDataForBind($model, $relationName): array { $data = []; @@ -70,6 +70,15 @@ public function getPivotDataForBind(): array $data[$key] = $value; } + + if ( + $model->isClassInstanceOf(\October\Contracts\Database\SortableRelationInterface::class) && + $model->isSortableRelation($relationName) + ) { + $sortColumn = $model->getRelationSortOrderColumn($relationName); + $data[$sortColumn] = $this->sort_order; + } + return $data; } @@ -206,7 +215,7 @@ protected function deleteSlaveRecord() // Only delete it if the relationship is null $foreignKey = array_get($options, 'key', $masterObject->getForeignKey()); - if (!$relatedObj->$foreignKey) { + if ($foreignKey && !$relatedObj->$foreignKey) { $relatedObj->delete(); } } diff --git a/src/Database/Relations/AttachMany.php b/src/Database/Relations/AttachMany.php index 4e4b0bbdc..e57465d1b 100644 --- a/src/Database/Relations/AttachMany.php +++ b/src/Database/Relations/AttachMany.php @@ -3,6 +3,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\MorphMany as MorphManyBase; +use Symfony\Component\HttpFoundation\File\UploadedFile; use October\Rain\Database\Attach\File as FileModel; /** @@ -38,29 +39,64 @@ public function __construct(Builder $query, Model $parent, $type, $id, $isPublic */ public function setSimpleValue($value) { - // Newly uploaded file(s) - if ($this->isValidFileData($value)) { + // Append a single newly uploaded file(s) + if ($value instanceof UploadedFile) { $this->parent->bindEventOnce('model.afterSave', function () use ($value) { $this->create(['data' => $value]); }); + return; } - elseif (is_array($value)) { - $files = []; + + // Append existing File model + if ($value instanceof FileModel) { + $this->parent->bindEventOnce('model.afterSave', function () use ($value) { + $this->add($value); + }); + return; + } + + // Process multiple values + $files = $models = $keys = []; + if (is_array($value)) { foreach ($value as $_value) { - if ($this->isValidFileData($_value)) { + if ($_value instanceof UploadedFile) { $files[] = $_value; } + elseif ($_value instanceof FileModel) { + $models[] = $_value; + } + elseif (is_numeric($_value)){ + $keys[] = $_value; + } } + } + + if ($files) { $this->parent->bindEventOnce('model.afterSave', function () use ($files) { foreach ($files as $file) { $this->create(['data' => $file]); } }); } - // Existing File model - elseif ($value instanceof FileModel) { - $this->parent->bindEventOnce('model.afterSave', function () use ($value) { - $this->add($value); + + if ($keys) { + $this->parent->bindEventOnce('model.afterSave', function () use ($keys) { + $models = $this->getRelated() + ->whereIn($this->getRelatedKeyName(), (array) $keys) + ->get() + ; + + foreach ($models as $model) { + $this->add($model); + } + }); + } + + if ($models) { + $this->parent->bindEventOnce('model.afterSave', function () use ($models) { + foreach ($models as $model) { + $this->add($model); + } }); } } @@ -85,48 +121,10 @@ public function getSimpleValue() if ($files) { $value = []; foreach ($files as $file) { - $value[] = $file->getPath(); + $value[] = $file->getKey(); } } return $value; } - - /** - * @deprecated this method is removed in October CMS v4 - */ - public function getValidationValue() - { - if ($value = $this->getSimpleValueInternal()) { - $files = []; - foreach ($value as $file) { - $files[] = $this->makeValidationFile($file); - } - - return $files; - } - - return null; - } - - /** - * @deprecated this method is removed in October CMS v4 - */ - protected function getSimpleValueInternal() - { - $value = null; - - $files = ($sessionKey = $this->parent->sessionKey) - ? $this->withDeferred($sessionKey)->get() - : $this->parent->{$this->relationName}; - - if ($files) { - $value = []; - $files->each(function ($file) use (&$value) { - $value[] = $file; - }); - } - - return $value; - } } diff --git a/src/Database/Relations/AttachOne.php b/src/Database/Relations/AttachOne.php index eba55c974..f7e2a65a7 100644 --- a/src/Database/Relations/AttachOne.php +++ b/src/Database/Relations/AttachOne.php @@ -3,6 +3,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\MorphOne as MorphOneBase; +use Symfony\Component\HttpFoundation\File\UploadedFile; use October\Rain\Database\Attach\File as FileModel; /** @@ -43,7 +44,7 @@ public function setSimpleValue($value) } // Newly uploaded file - if ($this->isValidFileData($value)) { + if ($value instanceof UploadedFile) { $this->parent->bindEventOnce('model.afterSave', function () use ($value) { $file = $this->create(['data' => $value]); $this->parent->setRelation($this->relationName, $file); @@ -55,8 +56,16 @@ public function setSimpleValue($value) $this->add($value); }); } + // Model key + elseif (is_numeric($value)) { + $this->parent->bindEventOnce('model.afterSave', function () use ($value) { + if ($model = $this->getRelated()->find($value)) { + $this->add($model); + } + }); + } - // The relation is set here to satisfy `getValidationValue` + // The relation is set here to satisfy validation $this->parent->setRelation($this->relationName, $value); } @@ -71,40 +80,14 @@ public function getSimpleValue() $relationName = $this->relationName; if ($this->parent->relationLoaded($relationName)) { - $value = $this->parent->getRelation($relationName); + $file = $this->parent->getRelation($relationName); } else { - $value = $this->getResults(); + $file = $this->getResults(); } - return $value; - } - - /** - * @deprecated this method is removed in October CMS v4 - */ - public function getValidationValue() - { - if ($value = $this->getSimpleValueInternal()) { - return $this->makeValidationFile($value); - } - - return null; - } - - /** - * @deprecated this method is removed in October CMS v4 - */ - protected function getSimpleValueInternal() - { - $value = null; - - $file = ($sessionKey = $this->parent->sessionKey) - ? $this->withDeferred($sessionKey)->first() - : $this->parent->{$this->relationName}; - if ($file) { - $value = $file; + $value = $file->getKey(); } return $value; diff --git a/src/Database/Relations/AttachOneOrMany.php b/src/Database/Relations/AttachOneOrMany.php index 2cf0c171f..d3d80a883 100644 --- a/src/Database/Relations/AttachOneOrMany.php +++ b/src/Database/Relations/AttachOneOrMany.php @@ -105,6 +105,18 @@ public function addEagerConstraints(array $models) $this->query->where('field', $this->relationName); } + /** + * addCommonEagerConstraints adds constraints without the field constraint, used to + * eager load multiple relations of a common type. + * @see \October\Rain\Database\Concerns\HasEagerLoadAttachRelation + * @param array $models + * @return void + */ + public function addCommonEagerConstraints(array $models) + { + parent::addEagerConstraints($models); + } + /** * save the supplied related model */ @@ -150,6 +162,32 @@ public function create(array $attributes = [], $sessionKey = null) return $model; } + /** + * createFromFile + */ + public function createFromFile(string $filePath, array $attributes = [], $sessionKey = null) + { + if (!array_key_exists('is_public', $attributes)) { + $attributes = array_merge(['is_public' => $this->isPublic()], $attributes); + } + + $attributes['field'] = $this->relationName; + + if ($sessionKey === null) { + $this->ensureAttachOneIsSingular(); + } + + $model = parent::make($attributes); + $model->fromFile($filePath); + $model->save(); + + if ($sessionKey !== null) { + $this->add($model, $sessionKey); + } + + return $model; + } + /** * add a model to this relationship type */ @@ -200,7 +238,7 @@ public function add(Model $model, $sessionKey = null) $this->parent->setRelation($this->relationName, $model); } else { - $this->parent->reloadRelations($this->relationName); + $this->parent->unsetRelation($this->relationName); } /** @@ -280,7 +318,7 @@ public function remove(Model $model, $sessionKey = null) $this->parent->setRelation($this->relationName, null); } else { - $this->parent->reloadRelations($this->relationName); + $this->parent->unsetRelation($this->relationName); } /** @@ -336,23 +374,6 @@ protected function ensureAttachOneIsSingular($sessionKey = null) } } - /** - * isValidFileData returns true if the specified value can be used as the data attribute - */ - protected function isValidFileData($value) - { - if ($value instanceof UploadedFile) { - return true; - } - - // @deprecated this method should be replaced by an instanceof UploadedFile check - if (is_string($value) && file_exists($value)) { - return true; - } - - return false; - } - /** * @deprecated this method is removed in October CMS v4 */ diff --git a/src/Database/Relations/BelongsTo.php b/src/Database/Relations/BelongsTo.php index 190809449..905bffe82 100644 --- a/src/Database/Relations/BelongsTo.php +++ b/src/Database/Relations/BelongsTo.php @@ -180,7 +180,7 @@ public function setSimpleValue($value) } else { $this->child->setAttribute($this->getForeignKeyName(), $value); - $this->child->reloadRelations($this->relationName); + $this->child->unsetRelation($this->relationName); } } diff --git a/src/Database/Relations/BelongsToMany.php b/src/Database/Relations/BelongsToMany.php index d9663ac56..6309dcf43 100644 --- a/src/Database/Relations/BelongsToMany.php +++ b/src/Database/Relations/BelongsToMany.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection as CollectionBase; use Illuminate\Database\Eloquent\Relations\BelongsToMany as BelongsToManyBase; +use October\Rain\Support\Facades\DbDongle; /** * BelongsToMany @@ -23,11 +24,6 @@ class BelongsToMany extends BelongsToManyBase */ public $countMode = false; - /** - * @var bool orphanMode used when a join is not used, don't select aliased columns - */ - public $orphanMode = false; - /** * __construct a new belongs to many relationship instance. * @@ -60,27 +56,6 @@ public function __construct( $this->addDefinedConstraints(); } - /** - * shouldSelect gets the select columns for the relation query - * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany - */ - protected function shouldSelect(array $columns = ['*']) - { - if ($this->countMode) { - return $this->table.'.'.$this->foreignPivotKey.' as pivot_'.$this->foreignPivotKey; - } - - if ($columns === ['*']) { - $columns = [$this->related->getTable().'.*']; - } - - if ($this->orphanMode) { - return $columns; - } - - return array_merge($columns, $this->aliasedPivotColumns()); - } - /** * save the supplied related model with deferred binding support. */ @@ -232,7 +207,7 @@ public function add(Model $model, $sessionKey = null, $pivotData = []) }); } - $this->parent->reloadRelations($this->relationName); + $this->parent->unsetRelation($this->relationName); } else { $this->parent->bindDeferred($this->relationName, $model, $sessionKey, $pivotData); @@ -246,7 +221,7 @@ public function remove(Model $model, $sessionKey = null) { if ($sessionKey === null) { $this->detach($model); - $this->parent->reloadRelations($this->relationName); + $this->parent->unsetRelation($this->relationName); } else { $this->parent->unbindDeferred($this->relationName, $model, $sessionKey); @@ -452,4 +427,107 @@ public function getOtherKey() { return $this->table.'.'.$this->relatedPivotKey; } + + /** + * shouldSelect gets the select columns for the relation query + * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + */ + protected function shouldSelect(array $columns = ['*']) + { + // @deprecated remove this whole method when `countMode` is gone + if ($this->countMode) { + return $this->table.'.'.$this->foreignPivotKey.' as pivot_'.$this->foreignPivotKey; + } + + if ($columns === ['*']) { + $columns = [$this->related->getTable().'.*']; + } + + return array_merge($columns, $this->aliasedPivotColumns()); + } + + /** + * performJoin will join the pivot table opportunistically instead of mandatorily + * to support deferred bindings that exist in another table. + * + * This method is based on `performJoin` method logic except it uses a left join. + * + * @param \Illuminate\Database\Eloquent\Builder|null $query + * @return $this + */ + protected function performLeftJoin($query = null) + { + $query = $query ?: $this->query; + + $query->leftJoin($this->table, function($join) { + $join->on($this->getQualifiedRelatedKeyName(), '=', $this->getQualifiedRelatedPivotKeyName()); + $join->where($this->getQualifiedForeignPivotKeyName(), $this->parent->getKey()); + }); + + return $this; + } + + /** + * performSortableColumnJoin includes custom logic to replace the sort order column with + * a unified column + */ + protected function performSortableColumnJoin($query = null, $sessionKey = null) + { + if ( + !$this->parent->isClassInstanceOf(\October\Contracts\Database\SortableRelationInterface::class) || + !$this->parent->isSortableRelation($this->relationName) + ) { + return; + } + + // Check if sorting by the matched sort_order column + $sortColumn = $this->qualifyPivotColumn( + $this->parent->getRelationSortOrderColumn($this->relationName) + ); + + $orderDefinitions = $query->getQuery()->orders; + + if (!is_array($orderDefinitions)) { + return; + } + + $sortableIndex = false; + foreach ($orderDefinitions as $index => $order) { + if ($order['column'] === $sortColumn) { + $sortableIndex = $index; + } + } + + // Not sorting by the sort column, abort + if ($sortableIndex === false) { + return; + } + + // Join the deferred binding table and select the combo column + $tempOrderColumns = 'october_reserved_sort_order'; + $combinedOrderColumn = "ifnull(deferred_bindings.sort_order, {$sortColumn}) as {$tempOrderColumns}"; + $this->performDeferredLeftJoin($query, $sessionKey); + $this->addSelect(DbDongle::raw($combinedOrderColumn)); + + // Overwrite the sortable column with the combined one + $query->getQuery()->orders[$sortableIndex]['column'] = $tempOrderColumns; + } + + /** + * performDeferredLeftJoin left joins the deferred bindings table + */ + protected function performDeferredLeftJoin($query = null, $sessionKey = null) + { + $query = $query ?: $this->query; + + $query->leftJoin('deferred_bindings', function($join) use ($sessionKey) { + $join->on( + $this->getQualifiedRelatedKeyName(), '=', 'deferred_bindings.slave_id') + ->where('master_field', $this->relationName) + ->where('master_type', get_class($this->parent)) + ->where('session_key', $sessionKey); + }); + + return $this; + } } diff --git a/src/Database/Relations/DeferOneOrMany.php b/src/Database/Relations/DeferOneOrMany.php index 3a6c24257..9eb3991e7 100644 --- a/src/Database/Relations/DeferOneOrMany.php +++ b/src/Database/Relations/DeferOneOrMany.php @@ -1,7 +1,7 @@ query; + $newQuery = $this->query->getQuery()->newQuery(); + $newQuery->from($this->related->getTable()); - $newQuery = $modelQuery->getQuery()->newQuery(); + // Readd the defined constraints + $this->addDefinedConstraintsToQuery($newQuery); - $newQuery->from($this->related->getTable()); + // Apply deferred binding to the new query + $newQuery = $this->withDeferredQuery($newQuery, $sessionKey); + + // Bless this query with the deferred query + $this->query->setQuery($newQuery); + + // Readd the global scopes + foreach ($this->related->getGlobalScopes() as $identifier => $scope) { + $this->query->withGlobalScope($identifier, $scope); + } + + return $this->query; + } + + /** + * withDeferredQuery returns the supplied model query, or current model query, with + * deferred bindings added, this will preserve any constraints that came before it + * @param \Illuminate\Database\Query\Builder|null $newQuery + * @param string|null $sessionKey + * @return \Illuminate\Database\Query\Builder + */ + public function withDeferredQuery($newQuery = null, $sessionKey = null) + { + if ($newQuery === null) { + $newQuery = $this->query->getQuery(); + } // Guess the key from the parent model if ($sessionKey === null) { $sessionKey = $this->parent->sessionKey; } - // No join table will be used, strip the selected "pivot_" columns + // Swap the standard inner join for a left join if ($this instanceof BelongsToManyBase) { - $this->orphanMode = true; + $this->performLeftJoin($newQuery); + $this->performSortableColumnJoin($newQuery, $sessionKey); } $newQuery->where(function ($query) use ($sessionKey) { + // Trick the relation to add constraints to this nested query if ($this->parent->exists) { - if ($this instanceof MorphToMany) { - // Custom query for MorphToMany since a "join" cannot be used - $query->whereExists(function ($query) { - $query - ->select($this->parent->getConnection()->raw(1)) - ->from($this->table) - ->where($this->getQualifiedRelatedPivotKeyName(), DbDongle::raw(DbDongle::getTablePrefix().$this->related->getQualifiedKeyName())) - ->where($this->getQualifiedForeignPivotKeyName(), $this->parent->getKey()) - ->where($this->getMorphType(), $this->getMorphClass()); - }); - } - elseif ($this instanceof BelongsToManyBase) { - // Custom query for BelongsToManyBase since a "join" cannot be used - $query->whereExists(function ($query) { - $query - ->select($this->parent->getConnection()->raw(1)) - ->from($this->table) - ->where($this->getQualifiedRelatedPivotKeyName(), DbDongle::raw(DbDongle::getTablePrefix().$this->related->getQualifiedKeyName())) - ->where($this->getQualifiedForeignPivotKeyName(), $this->parent->getKey()); - }); - } - else { - // Trick the relation to add constraints to this nested query - $this->query = $query; - $this->addConstraints(); - } - - $this->addDefinedConstraintsToQuery($this); + $oldQuery = $this->query; + $this->query = $query; + $this->addConstraints(); + $this->query = $oldQuery; } // Bind (Add) @@ -101,14 +107,7 @@ public function withDeferred($sessionKey = null) ]); }); - $modelQuery->setQuery($newQuery); - - // Apply global scopes - foreach ($this->related->getGlobalScopes() as $identifier => $scope) { - $modelQuery->withGlobalScope($identifier, $scope); - } - - return $this->query = $modelQuery; + return $newQuery; } /** diff --git a/src/Database/Relations/HasOneOrMany.php b/src/Database/Relations/HasOneOrMany.php index 7591f7d19..2f3b9bfaf 100644 --- a/src/Database/Relations/HasOneOrMany.php +++ b/src/Database/Relations/HasOneOrMany.php @@ -96,7 +96,7 @@ public function add(Model $model, $sessionKey = null) $this->parent->setRelation($this->relationName, $model); } else { - $this->parent->reloadRelations($this->relationName); + $this->parent->unsetRelation($this->relationName); } /** @@ -174,7 +174,7 @@ public function remove(Model $model, $sessionKey = null) $this->parent->setRelation($this->relationName, null); } else { - $this->parent->reloadRelations($this->relationName); + $this->parent->unsetRelation($this->relationName); } /** diff --git a/src/Database/Relations/MorphOneOrMany.php b/src/Database/Relations/MorphOneOrMany.php index d9d97815d..0758fd7c7 100644 --- a/src/Database/Relations/MorphOneOrMany.php +++ b/src/Database/Relations/MorphOneOrMany.php @@ -87,7 +87,7 @@ public function add(Model $model, $sessionKey = null) $this->parent->setRelation($this->relationName, $model); } else { - $this->parent->reloadRelations($this->relationName); + $this->parent->unsetRelation($this->relationName); } /** @@ -154,7 +154,7 @@ public function remove(Model $model, $sessionKey = null) $this->parent->setRelation($this->relationName, null); } else { - $this->parent->reloadRelations($this->relationName); + $this->parent->unsetRelation($this->relationName); } /** diff --git a/src/Database/Relations/MorphTo.php b/src/Database/Relations/MorphTo.php index 7835eb0b1..dfdca99e2 100644 --- a/src/Database/Relations/MorphTo.php +++ b/src/Database/Relations/MorphTo.php @@ -146,11 +146,11 @@ public function setSimpleValue($value) [$modelId, $modelClass] = $value; $this->parent->setAttribute($this->foreignKey, $modelId); $this->parent->setAttribute($this->morphType, $modelClass); - $this->parent->reloadRelations($this->relationName); + $this->parent->unsetRelation($this->relationName); } else { $this->parent->setAttribute($this->foreignKey, $value); - $this->parent->reloadRelations($this->relationName); + $this->parent->unsetRelation($this->relationName); } } diff --git a/src/Database/Relations/MorphToMany.php b/src/Database/Relations/MorphToMany.php index c96f7e9ef..8c5b96e87 100644 --- a/src/Database/Relations/MorphToMany.php +++ b/src/Database/Relations/MorphToMany.php @@ -146,14 +146,10 @@ public function newPivotQuery() */ public function newPivot(array $attributes = [], $exists = false) { - /* - * October looks to the relationship parent - */ + // October looks to the relationship parent $pivot = $this->parent->newRelationPivot($this->relationName, $this->parent, $attributes, $this->table, $exists); - /* - * Laravel creates new pivot model this way - */ + // Laravel creates new pivot model this way if (empty($pivot)) { $using = $this->using; diff --git a/src/Database/Replicator.php b/src/Database/Replicator.php new file mode 100644 index 000000000..135ed4321 --- /dev/null +++ b/src/Database/Replicator.php @@ -0,0 +1,188 @@ +model = $model; + $this->isMultisite = $model->isClassInstanceOf(\October\Contracts\Database\MultisiteInterface::class); + } + + /** + * replicate replicates the model into a new, non-existing instance, + * including replicating relations. + * + * @param array|null $except + * @return static + */ + public function replicate(array $except = null) + { + $this->isDuplicating = false; + + return $this->replicateRelationsInternal($except); + } + + /** + * duplicate replicates a model with special multisite duplication logic. + * To avoid duplication of has many relations, the logic only propagates relations on + * the parent model since they are shared via site_root_id beyond this point. + * + * @param array|null $except + * @return static + */ + public function duplicate(array $except = null) + { + $this->isDuplicating = true; + + return $this->replicateRelationsInternal($except); + } + + /** + * replicateRelationsInternal + */ + protected function replicateRelationsInternal(array $except = null) + { + $defaults = [ + $this->model->getKeyName(), + $this->model->getCreatedAtColumn(), + $this->model->getUpdatedAtColumn(), + ]; + + if ($this->isMultisite) { + $defaults[] = 'site_root_id'; + } + + $attributes = Arr::except( + $this->model->attributes, + $except ? array_unique(array_merge($except, $defaults)) : $defaults + ); + + $instance = $this->model->newReplicationInstance($attributes); + + $definitions = $this->model->getRelationDefinitions(); + + foreach ($definitions as $type => $relations) { + foreach ($relations as $name => $options) { + if ($this->isRelationReplicable($name)) { + $this->replicateRelationInternal($instance->$name(), $this->model->$name); + } + } + } + + return $instance; + } + + /** + * replicateRelationInternal on the model instance with the supplied ones + */ + protected function replicateRelationInternal($relationObject, $models) + { + if ($models instanceof CollectionBase) { + $models = $models->all(); + } + elseif ($models instanceof EloquentModel) { + $models = [$models]; + } + else { + $models = (array) $models; + } + + $this->associationMap = []; + foreach (array_filter($models) as $model) { + if ($relationObject instanceof HasOneOrMany) { + $relationObject->add($newModel = $model->replicateWithRelations()); + $this->mapAssociation($model, $newModel); + } + else { + $relationObject->add($model); + } + } + + $relatedModel = $relationObject->getRelated(); + if ($relatedModel->isClassInstanceOf(\October\Contracts\Database\TreeInterface::class)) { + $this->updateTreeAssociations(); + } + } + + /** + * isRelationReplicable determines whether the specified relation should be replicated + * when replicateWithRelations() is called instead of save() on the model. Default: true. + */ + protected function isRelationReplicable(string $name): bool + { + $relationType = $this->model->getRelationType($name); + if ($relationType === 'morphTo') { + return false; + } + + // Relation is shared via propagation + if ( + !$this->isDuplicating && + $this->isMultisite && + $this->model->isAttributePropagatable($name) + ) { + return false; + } + + $definition = $this->model->getRelationDefinition($name); + if (!array_key_exists('replicate', $definition)) { + return true; + } + + return (bool) $definition['replicate']; + } + + /** + * mapAssociation is an internal method that keeps a record of what records were created + * and their associated source, the following format is used: + * + * [FromModel::id] => [FromModel, ToModel] + */ + protected function mapAssociation($currentModel, $replicatedModel) + { + $this->associationMap[$currentModel->getKey()] = [$currentModel, $replicatedModel]; + } + + /** + * updateTreeAssociations sets new parents on the replicated records + */ + protected function updateTreeAssociations() + { + foreach ($this->associationMap as $tuple) { + [$currentModel, $replicatedModel] = $tuple; + $newParent = $this->associationMap[$currentModel->getParentId()][1] ?? null; + $replicatedModel->parent = $newParent; + } + } +} diff --git a/src/Database/Traits/BaseIdentifier.php b/src/Database/Traits/BaseIdentifier.php index 1e0a8c1cc..44be7ccb7 100644 --- a/src/Database/Traits/BaseIdentifier.php +++ b/src/Database/Traits/BaseIdentifier.php @@ -5,6 +5,10 @@ * lookup key that is immune to enumeration attacks. The model is assumed to have * the attribute: baseid. * + * Add this to your database table with: + * + * $table->string('baseid')->nullable()->index(); + * * @package october\database * @author Alexey Bobkov, Samuel Georges */ diff --git a/src/Database/Traits/Defaultable.php b/src/Database/Traits/Defaultable.php new file mode 100644 index 000000000..e76b8abe6 --- /dev/null +++ b/src/Database/Traits/Defaultable.php @@ -0,0 +1,61 @@ +bindEvent('model.afterSave', [$this, 'defaultableAfterSave']); + } + + /** + * defaultableAfterSave + */ + public function defaultableAfterSave() + { + if ($this->is_default) { + $this->makeDefault(); + } + } + + /** + * makeDefault + */ + public function makeDefault() + { + $this->newQuery()->where('id', $this->id)->update(['is_default' => true]); + $this->newQuery()->where('id', '<>', $this->id)->update(['is_default' => false]); + } + + /** + * getDefault returns the default product type. + */ + public static function getDefault() + { + if (static::$defaultableCache !== null) { + return static::$defaultableCache; + } + + $defaultType = static::where('is_default', true)->first(); + + // If no default is found, find the first record and make it the default. + if (!$defaultType && ($defaultType = static::first())) { + $defaultType->makeDefault(); + } + + return static::$defaultableCache = $defaultType; + } +} diff --git a/src/Database/Traits/DeferredBinding.php b/src/Database/Traits/DeferredBinding.php index 3af09366b..3c94e0a92 100644 --- a/src/Database/Traits/DeferredBinding.php +++ b/src/Database/Traits/DeferredBinding.php @@ -3,7 +3,7 @@ use October\Rain\Database\Models\DeferredBinding as DeferredBindingModel; /** - * DeferredBinding trait + * DeferredBinding trait is implemented by all models * * @package october\database * @author Alexey Bobkov, Samuel Georges @@ -196,7 +196,8 @@ protected function commitDeferredOfType($sessionKey, $include = null, $exclude = $relationObj = $this->$relationName(); if ($binding->is_bind) { if (in_array($relationType, ['belongsToMany', 'morphToMany', 'morphedByMany'])) { - $relationObj->add($slaveModel, null, $binding->getPivotDataForBind()); + $pivotData = $binding->getPivotDataForBind($this, $relationName); + $relationObj->add($slaveModel, null, $pivotData); } else { $relationObj->add($slaveModel); diff --git a/src/Database/Traits/Encryptable.php b/src/Database/Traits/Encryptable.php index 21f551d53..b30495699 100644 --- a/src/Database/Traits/Encryptable.php +++ b/src/Database/Traits/Encryptable.php @@ -31,7 +31,7 @@ public function initializeEncryptable() if (!is_array($this->encryptable)) { throw new Exception(sprintf( 'The $encryptable property in %s must be an array to use the Encryptable trait.', - get_class($this) + static::class )); } diff --git a/src/Database/Traits/Hashable.php b/src/Database/Traits/Hashable.php index f91356351..7913d7c8e 100644 --- a/src/Database/Traits/Hashable.php +++ b/src/Database/Traits/Hashable.php @@ -30,7 +30,7 @@ public function initializeHashable() if (!is_array($this->hashable)) { throw new Exception(sprintf( 'The $hashable property in %s must be an array to use the Hashable trait.', - get_class($this) + static::class )); } diff --git a/src/Database/Traits/Multisite.php b/src/Database/Traits/Multisite.php index 32583aad9..332c86be2 100644 --- a/src/Database/Traits/Multisite.php +++ b/src/Database/Traits/Multisite.php @@ -16,13 +16,20 @@ trait Multisite /** * @var array propagatable list of attributes to propagate to other sites. * - * protected $propagatable = []; + * protected $propagatable = []; */ /** - * @var bool propagatableSync will enforce model structures between all sites + * @var bool|array propagatableSync will enforce model structures between all sites. + * When set to `false` will disable sync, set `true` will sync between the site group. + * The sync option allow sync to `all` sites, sites in the `group`, and sites the `locale`. * - * protected $propagatableSync = false; + * Set to an array of options for more granular controls: + * + * - **sync** - logic to sync specific sites, available options: `all`, `group`, `locale` + * - **delete** - delete all linked records when any record is deleted, default: `true` + * + * protected $propagatableSync = false; */ /** @@ -41,7 +48,7 @@ public function initializeMultisite() if (!is_array($this->propagatable)) { throw new Exception(sprintf( 'The $propagatable property in %s must be an array to use the Multisite trait.', - get_class($this) + static::class )); } @@ -51,6 +58,8 @@ public function initializeMultisite() $this->bindEvent('model.saveComplete', [$this, 'multisiteSaveComplete']); + $this->bindEvent('model.afterDelete', [$this, 'multisiteAfterDelete']); + $this->defineMultisiteRelations(); } @@ -116,6 +125,24 @@ public function multisiteAfterCreate() ; } + /** + * multisiteAfterDelete + */ + public function multisiteAfterDelete() + { + if (!$this->isMultisiteSyncEnabled() || !$this->getMultisiteConfig('delete', true)) { + return; + } + + Site::withGlobalContext(function() { + foreach ($this->getMultisiteSyncSites() as $siteId) { + if (!$this->isModelUsingSameSite($siteId)) { + $this->deleteForSite($siteId); + } + } + }); + } + /** * defineMultisiteRelations will spin over every relation and apply propagation config */ @@ -131,7 +158,35 @@ protected function defineMultisiteRelations() } /** - * defineMultisiteRelation + * canDeleteMultisiteRelation checks if a relation has the potential to be shared with + * the current model. If there are 2 or more records in existence, then this method + * will prevent the cascading deletion of relations. + * + * @see \October\Rain\Database\Concerns\HasRelationships::performDeleteOnRelations + */ + public function canDeleteMultisiteRelation($name, $type = null): bool + { + if (!$this->isAttributePropagatable($name)) { + return false; + } + + if ($type === null) { + $type = $this->getRelationType($name); + } + + if (!in_array($type, ['belongsToMany', 'belongsTo', 'hasOne', 'hasMany', 'attachOne', 'attachMany'])) { + return false; + } + + // The current record counts for one so halt if we find more + return !($this->newOtherSiteQuery()->count() > 1); + } + + /** + * defineMultisiteRelation will modify defined relations on this model so they share + * their association using the shared identifier (`site_root_id`). Only these relation + * types support relation sharing: `belongsToMany`, `belongsTo`, `hasOne`, `hasMany`, + * `attachOne`, `attachMany`. */ protected function defineMultisiteRelation($name, $type = null) { @@ -239,9 +294,27 @@ public function isMultisiteEnabled() */ public function isMultisiteSyncEnabled() { - return property_exists($this, 'propagatableSync') - ? (bool) $this->propagatableSync - : false; + if (!property_exists($this, 'propagatableSync')) { + return false; + } + + if (!is_array($this->propagatableSync)) { + return ($this->propagatableSync['sync'] ?? false) !== false; + } + + return (bool) $this->propagatableSync; + } + + /** + * getMultisiteConfig + */ + public function getMultisiteConfig($key, $default = null) + { + if (!property_exists($this, 'propagatableSync') || !is_array($this->propagatableSync)) { + return $default; + } + + return array_get($this->propagatableSync, $key, $default); } /** @@ -250,7 +323,17 @@ public function isMultisiteSyncEnabled() */ public function getMultisiteSyncSites() { - return Site::listSiteIdsInContext(); + if ($this->getMultisiteConfig('sync') === 'all') { + return Site::listSiteIds(); + } + + $siteId = $this->{$this->getSiteIdColumn()} ?: null; + + if ($this->getMultisiteConfig('sync') === 'locale') { + return Site::listSiteIdsInLocale($siteId); + } + + return Site::listSiteIdsInGroup($siteId); } /** @@ -341,6 +424,30 @@ protected function findOtherSiteModel($siteId = null) return $otherModel; } + /** + * deleteForSite runs the delete command on a model for another site, useful for cleaning + * up records for other sites when the parent is deleted. + */ + public function deleteForSite($siteId = null) + { + $otherModel = $this->findForSite($siteId); + if (!$otherModel) { + return; + } + + $useSoftDeletes = $this->isClassInstanceOf(\October\Contracts\Database\SoftDeleteInterface::class); + if ($useSoftDeletes && !$this->isSoftDelete()) { + static::withoutEvents(function() use ($otherModel) { + $otherModel->forceDelete(); + }); + return; + } + + static::withoutEvents(function() use ($otherModel) { + $otherModel->delete(); + }); + } + /** * isModelUsingSameSite */ diff --git a/src/Database/Traits/NestedTree.php b/src/Database/Traits/NestedTree.php index 31946dd24..7ffe7dd07 100644 --- a/src/Database/Traits/NestedTree.php +++ b/src/Database/Traits/NestedTree.php @@ -92,13 +92,13 @@ public function initializeNestedTree() { // Define relationships $this->hasMany['children'] = [ - get_class($this), + static::class, 'key' => $this->getParentColumnName(), 'replicate' => false ]; $this->belongsTo['parent'] = [ - get_class($this), + static::class, 'key' => $this->getParentColumnName(), 'replicate' => false ]; diff --git a/src/Database/Traits/Nullable.php b/src/Database/Traits/Nullable.php index 86e611b04..1df12dc91 100644 --- a/src/Database/Traits/Nullable.php +++ b/src/Database/Traits/Nullable.php @@ -24,19 +24,15 @@ public function initializeNullable() if (!is_array($this->nullable)) { throw new Exception(sprintf( 'The $nullable property in %s must be an array to use the Nullable trait.', - get_class($this) + static::class )); } - $this->bindEvent('model.beforeSave', function () { - $this->nullableBeforeSave(); - }); + $this->bindEvent('model.beforeSaveDone', [$this, 'nullableBeforeSave']); } /** * addNullable attribute to the nullable attributes list - * @param array|string|null $attributes - * @return void */ public function addNullable($attributes = null) { @@ -47,10 +43,8 @@ public function addNullable($attributes = null) /** * checkNullableValue checks if the supplied value is empty, excluding zero. - * @param string $value Value to check - * @return bool */ - public function checkNullableValue($value) + public function checkNullableValue($value): bool { if ($value === 0 || $value === '0' || $value === 0.0 || $value === false) { return false; diff --git a/src/Database/Traits/Purgeable.php b/src/Database/Traits/Purgeable.php index b7604f67d..4a14a25ea 100644 --- a/src/Database/Traits/Purgeable.php +++ b/src/Database/Traits/Purgeable.php @@ -29,14 +29,11 @@ public function initializePurgeable() if (!is_array($this->purgeable)) { throw new Exception(sprintf( 'The $purgeable property in %s must be an array to use the Purgeable trait.', - get_class($this) + static::class )); } - // Remove any purge attributes from the data set - $this->bindEvent('model.saveInternal', function () { - $this->purgeAttributes(); - }); + $this->bindEvent('model.beforeSaveDone', [$this, 'purgeAttributes']); } /** @@ -53,28 +50,29 @@ public function addPurgeable($attributes = null) /** * purgeAttributes removes purged attributes from the dataset, used before saving. - * @param $attributes mixed Attribute(s) to purge, if unspecified, $purgable property is used - * @return array Current attribute set + * Specify attributesToPurge, if unspecified, $purgeable property is used + * @param mixed $attributes + * @return array */ public function purgeAttributes($attributesToPurge = null) { - if ($attributesToPurge !== null) { - $purgeable = is_array($attributesToPurge) ? $attributesToPurge : [$attributesToPurge]; + if ($attributesToPurge === null) { + $purgeable = $this->getPurgeableAttributes(); } else { - $purgeable = $this->getPurgeableAttributes(); + $purgeable = (array) $attributesToPurge; } $attributes = $this->getAttributes(); + $cleanAttributes = array_diff_key($attributes, array_flip($purgeable)); + $originalAttributes = array_diff_key($attributes, $cleanAttributes); - if (is_array($this->originalPurgeableValues)) { - $this->originalPurgeableValues = array_merge($this->originalPurgeableValues, $originalAttributes); - } - else { - $this->originalPurgeableValues = $originalAttributes; - } + $this->originalPurgeableValues = array_merge( + $this->originalPurgeableValues, + $originalAttributes + ); return $this->attributes = $cleanAttributes; } @@ -100,7 +98,8 @@ public function getOriginalPurgeValues() */ public function getOriginalPurgeValue($attribute) { - return $this->originalPurgeableValues[$attribute] ?? null; + return $this->attributes[$attribute] + ?? ($this->originalPurgeableValues[$attribute] ?? null); } /** @@ -108,6 +107,9 @@ public function getOriginalPurgeValue($attribute) */ public function restorePurgedValues() { - $this->attributes = array_merge($this->getAttributes(), $this->originalPurgeableValues); + $this->attributes = array_merge( + $this->getAttributes(), + $this->originalPurgeableValues + ); } } diff --git a/src/Database/Traits/Revisionable.php b/src/Database/Traits/Revisionable.php index 965abe7c6..e7bda43db 100644 --- a/src/Database/Traits/Revisionable.php +++ b/src/Database/Traits/Revisionable.php @@ -44,7 +44,7 @@ public function initializeRevisionable() if (!is_array($this->revisionable)) { throw new Exception(sprintf( 'The $revisionable property in %s must be an array to use the Revisionable trait.', - get_class($this) + static::class )); } @@ -110,7 +110,7 @@ public function revisionableAfterDelete() $softDeletes = in_array( \October\Rain\Database\Traits\SoftDelete::class, - class_uses_recursive(get_class($this)) + class_uses_recursive(static::class) ); if (!$softDeletes) { diff --git a/src/Database/Traits/SimpleTree.php b/src/Database/Traits/SimpleTree.php index ecdf2eb35..181525efb 100644 --- a/src/Database/Traits/SimpleTree.php +++ b/src/Database/Traits/SimpleTree.php @@ -48,13 +48,13 @@ public function initializeSimpleTree() { // Define relationships $this->hasMany['children'] = [ - get_class($this), + static::class, 'key' => $this->getParentColumnName(), 'replicate' => false ]; $this->belongsTo['parent'] = [ - get_class($this), + static::class, 'key' => $this->getParentColumnName(), 'replicate' => false ]; diff --git a/src/Database/Traits/Sluggable.php b/src/Database/Traits/Sluggable.php index 1129e2e21..2333547f6 100644 --- a/src/Database/Traits/Sluggable.php +++ b/src/Database/Traits/Sluggable.php @@ -26,7 +26,7 @@ public function initializeSluggable() if (!is_array($this->slugs)) { throw new Exception(sprintf( 'The $slugs property in %s must be an array to use the Sluggable trait.', - get_class($this) + static::class )); } diff --git a/src/Database/Traits/SortableRelation.php b/src/Database/Traits/SortableRelation.php index d9df334e6..b27195301 100644 --- a/src/Database/Traits/SortableRelation.php +++ b/src/Database/Traits/SortableRelation.php @@ -1,5 +1,6 @@ $relationName(); - + // Order already set in pivot data (assuming singular) $column = $this->getRelationSortOrderColumn($relationName); + if (is_array($data) && array_key_exists($column, $data)) { + return; + } + // Calculate a new order + $relation = $this->$relationName(); $order = $relation->max($relation->qualifyPivotColumn($column)); - foreach ((array) $attached as $id) { $relation->updateExistingPivot($id, [$column => ++$order]); } @@ -117,9 +121,19 @@ public function setSortableRelationOrder($relationName, $itemIds, $referencePool if ($upsert) { foreach ($upsert as $update) { - $this->$relationName()->updateExistingPivot($update['id'], [ + $result = $this->exists ? $this->$relationName()->updateExistingPivot($update['id'], [ $this->getRelationSortOrderColumn($relationName) => $update['sort_order'] - ]); + ]) : 0; + + if (!$result && $this->sessionKey) { + Db::table('deferred_bindings') + ->where('master_field', $relationName) + ->where('master_type', get_class($this)) + ->where('session_key', $this->sessionKey) + ->where('slave_id', $update['id']) + ->limit(1) + ->update(['sort_order' => $update['sort_order']]); + } } } } diff --git a/src/Database/Traits/UserFootprints.php b/src/Database/Traits/UserFootprints.php new file mode 100644 index 000000000..9053ffeb2 --- /dev/null +++ b/src/Database/Traits/UserFootprints.php @@ -0,0 +1,82 @@ +bindEvent('model.saveInternal', function () { + $this->updateUserFootprints(); + }); + + $userModel = $this->getUserFootprintAuth()->getProvider()->getModel(); + + $this->belongsTo['updated_user'] = [ + $userModel, + 'replicate' => false + ]; + + $this->belongsTo['created_user'] = [ + $userModel, + 'replicate' => false + ]; + } + + /** + * updateUserFootprints + */ + public function updateUserFootprints() + { + $userId = $this->getUserFootprintAuth()->id(); + if (!$userId) { + return; + } + + $updatedColumn = $this->getUpdatedUserIdColumn(); + if ($updatedColumn !== null && !$this->isDirty($updatedColumn)) { + $this->{$updatedColumn} = $userId; + } + + $createdColumn = $this->getCreatedUserIdColumn(); + if (!$this->exists && $createdColumn !== null && !$this->isDirty($createdColumn)) { + $this->{$createdColumn} = $userId; + } + } + + /** + * getCreatedUserIdColumn gets the name of the "created user id" column. + * @return string + */ + public function getCreatedUserIdColumn() + { + return defined('static::CREATED_USER_ID') ? static::CREATED_USER_ID : 'created_user_id'; + } + + /** + * getCreatedUserIdColumn gets the name of the "updated user id" column. + * @return string + */ + public function getUpdatedUserIdColumn() + { + return defined('static::UPDATED_USER_ID') ? static::UPDATED_USER_ID : 'updated_user_id'; + } + + /** + * getUserFootprintAuth + */ + protected function getUserFootprintAuth() + { + return App::make('backend.auth'); + } +} diff --git a/src/Database/Traits/Validation.php b/src/Database/Traits/Validation.php index 8428ae5d3..15d821902 100644 --- a/src/Database/Traits/Validation.php +++ b/src/Database/Traits/Validation.php @@ -65,7 +65,7 @@ public function initializeValidation() if (!is_array($this->rules)) { throw new Exception(sprintf( 'The $rules property in %s must be an array to use the Validation trait.', - get_class($this) + static::class )); } @@ -147,6 +147,13 @@ public function removeValidationRule(string $name, $definition) if ($rule === $definition) { unset($rules[$key]); } + elseif ( + is_string($definition) && + is_string($rule) && + str_starts_with($rule, "{$definition}:") + ) { + unset($rules[$key]); + } } $this->rules[$name] = $rules; @@ -441,14 +448,14 @@ protected function processValidationRules($rules) continue; } // Remove primary key unique validation rule if the model already exists - if (starts_with($rulePart, 'unique')) { + if (str_starts_with($rulePart, 'unique')) { $ruleParts[$key] = $this->processValidationUniqueRule($rulePart, $field); } // Look for required:create and required:update rules - elseif (starts_with($rulePart, 'required:create') && $this->exists) { + elseif (str_starts_with($rulePart, 'required:create') && $this->exists) { unset($ruleParts[$key]); } - elseif (starts_with($rulePart, 'required:update') && !$this->exists) { + elseif (str_starts_with($rulePart, 'required:update') && !$this->exists) { unset($ruleParts[$key]); } } @@ -503,7 +510,7 @@ protected function processValidationUniqueRule($definition, $fieldName) [$ruleName, $ruleDefinition] = array_pad(explode(':', $definition, 2), 2, ''); [$tableName, $column, $key, $keyName, $whereColumn, $whereValue] = array_pad(explode(',', $ruleDefinition, 6), 6, null); - $tableName = str_contains($tableName, '.') ? $tableName : $this->getTable(); + $tableName = $tableName ?: $this->getTable(); $column = $column ?: $fieldName; $key = $keyName ? $this->$keyName : $this->getKey(); $keyName = $keyName ?: $this->getKeyName(); diff --git a/src/Extension/ExtendableTrait.php b/src/Extension/ExtendableTrait.php index 6aaaab6f4..45f292765 100644 --- a/src/Extension/ExtendableTrait.php +++ b/src/Extension/ExtendableTrait.php @@ -45,7 +45,7 @@ trait ExtendableTrait public function extendableConstruct() { // Apply init callbacks - $classes = array_merge([get_class($this)], class_parents($this)); + $classes = array_merge([static::class], class_parents($this)); foreach ($classes as $class) { if (isset(Container::$classCallbacks[$class]) && is_array(Container::$classCallbacks[$class])) { foreach (Container::$classCallbacks[$class] as $callback) { @@ -122,7 +122,7 @@ protected function extensionExtractImplements(): array $uses = $this->implement; } else { - throw new Exception(sprintf('Class %s contains an invalid $implement value', get_class($this))); + throw new Exception(sprintf('Class %s contains an invalid $implement value', static::class)); } foreach ($uses as &$use) { @@ -225,7 +225,7 @@ public function extendClassWith($extensionName) if (isset($this->extensionData['extensions'][$extensionName])) { throw new Exception(sprintf( 'Class %s has already been extended with %s', - get_class($this), + static::class, $extensionName )); } @@ -480,7 +480,7 @@ public function extendableSet($name, $value) // if (!$found) { // throw new BadMethodCallException(sprintf( // 'Call to undefined property %s::%s', - // get_class($this), + // static::class, // $name // )); // } @@ -511,7 +511,7 @@ public function extendableCall($name, $params = null) throw new BadMethodCallException(sprintf( 'Call to undefined method %s::%s()', - get_class($this), + static::class, $name )); } diff --git a/src/Extension/ExtensionTrait.php b/src/Extension/ExtensionTrait.php index 6aa966681..adb6096b7 100644 --- a/src/Extension/ExtensionTrait.php +++ b/src/Extension/ExtensionTrait.php @@ -18,8 +18,8 @@ trait ExtensionTrait * @var array extensionHidden are properties and methods that cannot be accessed. */ protected $extensionHidden = [ - 'fields' => [], - 'methods' => ['extensionIsHiddenField', 'extensionIsHiddenMethod'] + 'methods' => ['extensionIsHiddenProperty', 'extensionIsHiddenMethod'], + 'properties' => [] ]; /** @@ -27,7 +27,7 @@ trait ExtensionTrait */ public function extensionApplyInitCallbacks() { - $classes = array_merge([get_class($this)], class_parents($this)); + $classes = array_merge([static::class], class_parents($this)); foreach ($classes as $class) { if (isset(Container::$extensionCallbacks[$class]) && is_array(Container::$extensionCallbacks[$class])) { foreach (Container::$extensionCallbacks[$class] as $callback) { @@ -55,14 +55,6 @@ public static function extensionExtendCallback($callback) Container::$extensionCallbacks[$class][] = $callback; } - /** - * extensionHideField - */ - protected function extensionHideField($name) - { - $this->extensionHidden['fields'][] = $name; - } - /** * extensionHideMethod */ @@ -72,11 +64,11 @@ protected function extensionHideMethod($name) } /** - * extensionIsHiddenField + * extensionHideProperty */ - public function extensionIsHiddenField($name) + protected function extensionHideProperty($name) { - return in_array($name, $this->extensionHidden['fields']); + $this->extensionHidden['properties'][] = $name; } /** @@ -87,6 +79,14 @@ public function extensionIsHiddenMethod($name) return in_array($name, $this->extensionHidden['methods']); } + /** + * extensionIsHiddenProperty + */ + public function extensionIsHiddenProperty($name) + { + return in_array($name, $this->extensionHidden['properties']); + } + /** * getCalledExtensionClass */ diff --git a/src/Filesystem/Filesystem.php b/src/Filesystem/Filesystem.php index 307f901d6..58021938a 100644 --- a/src/Filesystem/Filesystem.php +++ b/src/Filesystem/Filesystem.php @@ -211,7 +211,8 @@ public function normalizePath($path) } /** - * nicePath returns a nice path that is suitable for sharing. + * nicePath removes the base path from a local path and returns a relatively nice + * path that is suitable and safe for sharing. * @param string $path * @return string */ @@ -307,9 +308,7 @@ public function makeDirectory($path, $mode = 0755, $recursive = false, $force = $mode = $mask; } - /* - * Find the green leaves - */ + // Find the green leaves if ($recursive && $mask) { $chmodPath = $path; while (true) { @@ -327,14 +326,10 @@ public function makeDirectory($path, $mode = 0755, $recursive = false, $force = $chmodPath = $path; } - /* - * Make the directory - */ + // Make the directory $result = parent::makeDirectory($path, $mode, $recursive, $force); - /* - * Apply the permissions - */ + // Apply the permissions if ($mask) { $this->chmod($chmodPath, $mask); diff --git a/src/Foundation/Application.php b/src/Foundation/Application.php index 56293420c..b7c8fafa7 100644 --- a/src/Foundation/Application.php +++ b/src/Foundation/Application.php @@ -324,27 +324,32 @@ public function registerCoreContainerAliases() 'blade.compiler' => [\Illuminate\View\Compilers\BladeCompiler::class], 'cache' => [\Illuminate\Cache\CacheManager::class, \Illuminate\Contracts\Cache\Factory::class], 'cache.store' => [\Illuminate\Cache\Repository::class, \Illuminate\Contracts\Cache\Repository::class], + 'cache.psr6' => [\Symfony\Component\Cache\Adapter\Psr16Adapter::class, \Symfony\Component\Cache\Adapter\AdapterInterface::class, \Psr\Cache\CacheItemPoolInterface::class], 'config' => [\Illuminate\Config\Repository::class, \Illuminate\Contracts\Config\Repository::class], 'cookie' => [\Illuminate\Cookie\CookieJar::class, \Illuminate\Contracts\Cookie\Factory::class, \Illuminate\Contracts\Cookie\QueueingFactory::class], - 'encrypter' => [\Illuminate\Encryption\Encrypter::class, \Illuminate\Contracts\Encryption\Encrypter::class], 'db' => [\October\Rain\Database\DatabaseManager::class], 'db.connection' => [\Illuminate\Database\Connection::class, \Illuminate\Database\ConnectionInterface::class], 'db.schema' => [\Illuminate\Database\Schema\Builder::class], + 'encrypter' => [\Illuminate\Encryption\Encrypter::class, \Illuminate\Contracts\Encryption\Encrypter::class], 'events' => [\October\Rain\Events\Dispatcher::class, \Illuminate\Contracts\Events\Dispatcher::class], 'files' => [\Illuminate\Filesystem\Filesystem::class], 'filesystem' => [\Illuminate\Filesystem\FilesystemManager::class, \Illuminate\Contracts\Filesystem\Factory::class], 'filesystem.disk' => [\Illuminate\Contracts\Filesystem\Filesystem::class], 'filesystem.cloud' => [\Illuminate\Contracts\Filesystem\Cloud::class], 'hash' => [\Illuminate\Contracts\Hashing\Hasher::class], + 'hash.driver' => [\Illuminate\Contracts\Hashing\Hasher::class], 'translator' => [\Illuminate\Translation\Translator::class, \Illuminate\Contracts\Translation\Translator::class], 'log' => [\Illuminate\Log\Logger::class, \Psr\Log\LoggerInterface::class], 'mail.manager' => [\Illuminate\Mail\MailManager::class, \Illuminate\Contracts\Mail\Factory::class], 'mailer' => [\Illuminate\Mail\Mailer::class, \Illuminate\Contracts\Mail\Mailer::class, \Illuminate\Contracts\Mail\MailQueue::class], + 'auth.password' => [\Illuminate\Auth\Passwords\PasswordBrokerManager::class, \Illuminate\Contracts\Auth\PasswordBrokerFactory::class], + 'auth.password.broker' => [\Illuminate\Auth\Passwords\PasswordBroker::class, \Illuminate\Contracts\Auth\PasswordBroker::class], 'queue' => [\Illuminate\Queue\QueueManager::class, \Illuminate\Contracts\Queue\Factory::class, \Illuminate\Contracts\Queue\Monitor::class], 'queue.connection' => [\Illuminate\Contracts\Queue\Queue::class], 'queue.failer' => [\Illuminate\Queue\Failed\FailedJobProviderInterface::class], 'redirect' => [\Illuminate\Routing\Redirector::class], 'redis' => [\Illuminate\Redis\RedisManager::class, \Illuminate\Contracts\Redis\Factory::class], + 'redis.connection' => [\Illuminate\Redis\Connections\Connection::class, \Illuminate\Contracts\Redis\Connection::class], 'request' => [\Illuminate\Http\Request::class, \Symfony\Component\HttpFoundation\Request::class], 'router' => [\Illuminate\Routing\Router::class, \Illuminate\Contracts\Routing\Registrar::class, \Illuminate\Contracts\Routing\BindingRegistrar::class], 'session' => [\Illuminate\Session\SessionManager::class], @@ -450,4 +455,16 @@ public function getNamespace() { return 'App\\'; } + + /** + * extendInstance is useful for extending singletons regardless of their execution + */ + public function extendInstance($abstract, Closure $callback) + { + $this->afterResolving($abstract, $callback); + + if ($this->resolved($abstract)) { + $callback($this->make($abstract), $this); + } + } } diff --git a/src/Foundation/Providers/AppDeferSupportServiceProvider.php b/src/Foundation/Providers/AppDeferSupportServiceProvider.php index f54c540ab..5faaa5d58 100644 --- a/src/Foundation/Providers/AppDeferSupportServiceProvider.php +++ b/src/Foundation/Providers/AppDeferSupportServiceProvider.php @@ -21,6 +21,7 @@ class AppDeferSupportServiceProvider extends AggregateServiceProvider implements \October\Rain\Resize\ResizeServiceProvider::class, \October\Rain\Validation\ValidationServiceProvider::class, \October\Rain\Translation\TranslationServiceProvider::class, + \Illuminate\Auth\Passwords\PasswordResetServiceProvider:: class, // Console \October\Rain\Foundation\Providers\ArtisanServiceProvider::class, diff --git a/src/Halcyon/Builder.php b/src/Halcyon/Builder.php index 79a66630e..2a8a85d54 100644 --- a/src/Halcyon/Builder.php +++ b/src/Halcyon/Builder.php @@ -735,7 +735,7 @@ protected function processInitCacheData($data) */ public function __call($method, $parameters) { - $className = get_class($this); + $className = static::class; throw new BadMethodCallException("Call to undefined method {$className}::{$method}()"); } diff --git a/src/Halcyon/Concerns/HasEvents.php b/src/Halcyon/Concerns/HasEvents.php new file mode 100644 index 000000000..185fae8cf --- /dev/null +++ b/src/Halcyon/Concerns/HasEvents.php @@ -0,0 +1,557 @@ + 'beforeCreate', + 'created' => 'afterCreate', + 'saving' => 'beforeSave', + 'saved' => 'afterSave', + 'updating' => 'beforeUpdate', + 'updated' => 'afterUpdate', + 'deleting' => 'beforeDelete', + 'deleted' => 'afterDelete', + 'fetching' => 'beforeFetch', + 'fetched' => 'afterFetch', + ]; + + foreach ($nicerEvents as $eventMethod => $method) { + self::registerModelEvent($eventMethod, function ($model) use ($method) { + $model->fireEvent("model.{$method}"); + return $model->$method(); + }); + } + + // Boot event + $this->fireEvent('model.afterBoot'); + $this->afterBoot(); + + static::$eventsBooted[static::class] = true; + } + + /** + * initializeModelEvent is called every time the model is constructed. + */ + protected function initializeModelEvent() + { + $this->fireEvent('model.afterInit'); + $this->afterInit(); + } + + /** + * flushEventListeners removes all of the event listeners for the model. + */ + public static function flushEventListeners() + { + if (!isset(static::$dispatcher)) { + return; + } + + $instance = new static; + + foreach ($instance->getObservableEvents() as $event) { + static::$dispatcher->forget("halcyon.{$event}: ".static::class); + } + + static::$eventsBooted = []; + } + + /** + * getObservableEvents names. + * @return array + */ + public function getObservableEvents() + { + return array_merge( + [ + 'creating', 'created', 'updating', 'updated', + 'deleting', 'deleted', 'saving', 'saved', + 'fetching', 'fetched' + ], + $this->observables + ); + } + + + /** + * setObservableEvents names. + * @param array $observables + * @return $this + */ + public function setObservableEvents(array $observables) + { + $this->observables = $observables; + + return $this; + } + + /** + * addObservableEvents name. + * @param array|mixed $observables + * @return void + */ + public function addObservableEvents($observables) + { + $observables = is_array($observables) ? $observables : func_get_args(); + + $this->observables = array_unique(array_merge($this->observables, $observables)); + } + + /** + * removeObservableEvents name. + * @param array|mixed $observables + * @return void + */ + public function removeObservableEvents($observables) + { + $observables = is_array($observables) ? $observables : func_get_args(); + + $this->observables = array_diff($this->observables, $observables); + } + + /** + * getEventDispatcher instance. + * @return \Illuminate\Contracts\Events\Dispatcher + */ + public static function getEventDispatcher() + { + return static::$dispatcher; + } + + /** + * setEventDispatcher instance. + * @param \Illuminate\Contracts\Events\Dispatcher $dispatcher + * @return void + */ + public static function setEventDispatcher(Dispatcher $dispatcher) + { + static::$dispatcher = $dispatcher; + } + + /** + * unsetEventDispatcher for models. + * @return void + */ + public static function unsetEventDispatcher() + { + static::$dispatcher = null; + } + + /** + * registerModelEvent with the dispatcher. + * @param string $event + * @param \Closure|string $callback + * @param int $priority + * @return void + */ + protected static function registerModelEvent($event, $callback, $priority = 0) + { + if (isset(static::$dispatcher)) { + $name = static::class; + + static::$dispatcher->listen("halcyon.{$event}: {$name}", $callback, $priority); + } + } + + /** + * fireModelEvent for the model. + * @param string $event + * @param bool $halt + * @return mixed + */ + protected function fireModelEvent($event, $halt = true) + { + if (!isset(static::$dispatcher)) { + return true; + } + + // We will append the names of the class to the event to distinguish it from + // other model events that are fired, allowing us to listen on each model + // event set individually instead of catching event for all the models. + $event = "halcyon.{$event}: ".static::class; + + $method = $halt ? 'until' : 'dispatch'; + + return static::$dispatcher->$method($event, $this); + } + + /** + * Create a new native event for handling beforeFetch(). + * @param Closure|string $callback + * @return void + */ + public static function fetching($callback) + { + static::registerModelEvent('fetching', $callback); + } + + /** + * Create a new native event for handling afterFetch(). + * @param Closure|string $callback + * @return void + */ + public static function fetched($callback) + { + static::registerModelEvent('fetched', $callback); + } + + /** + * Register a saving model event with the dispatcher. + * + * @param \Closure|string $callback + * @param int $priority + * @return void + */ + public static function saving($callback, $priority = 0) + { + static::registerModelEvent('saving', $callback, $priority); + } + + /** + * Register a saved model event with the dispatcher. + * + * @param \Closure|string $callback + * @param int $priority + * @return void + */ + public static function saved($callback, $priority = 0) + { + static::registerModelEvent('saved', $callback, $priority); + } + + /** + * Register an updating model event with the dispatcher. + * + * @param \Closure|string $callback + * @param int $priority + * @return void + */ + public static function updating($callback, $priority = 0) + { + static::registerModelEvent('updating', $callback, $priority); + } + + /** + * Register an updated model event with the dispatcher. + * + * @param \Closure|string $callback + * @param int $priority + * @return void + */ + public static function updated($callback, $priority = 0) + { + static::registerModelEvent('updated', $callback, $priority); + } + + /** + * Register a creating model event with the dispatcher. + * + * @param \Closure|string $callback + * @param int $priority + * @return void + */ + public static function creating($callback, $priority = 0) + { + static::registerModelEvent('creating', $callback, $priority); + } + + /** + * Register a created model event with the dispatcher. + * + * @param \Closure|string $callback + * @param int $priority + * @return void + */ + public static function created($callback, $priority = 0) + { + static::registerModelEvent('created', $callback, $priority); + } + + /** + * Register a deleting model event with the dispatcher. + * + * @param \Closure|string $callback + * @param int $priority + * @return void + */ + public static function deleting($callback, $priority = 0) + { + static::registerModelEvent('deleting', $callback, $priority); + } + + /** + * Register a deleted model event with the dispatcher. + * + * @param \Closure|string $callback + * @param int $priority + * @return void + */ + public static function deleted($callback, $priority = 0) + { + static::registerModelEvent('deleted', $callback, $priority); + } + + + /** + * afterBoot is called after the model is constructed for the first time. + */ + protected function afterBoot() + { + /** + * @event model.afterBoot + * Called after the model is booted + * + * Example usage: + * + * $model->bindEvent('model.afterBoot', function () use (\October\Rain\Halcyon\Model $model) { + * \Log::info(get_class($model) . ' has booted'); + * }); + * + */ + } + + /** + * afterInit is called after the model is constructed, a nicer version + * of overriding the __construct method. + */ + protected function afterInit() + { + /** + * @event model.afterInit + * Called after the model is initialized + * + * Example usage: + * + * $model->bindEvent('model.afterInit', function () use (\October\Rain\Halcyon\Model $model) { + * \Log::info(get_class($model) . ' has initialized'); + * }); + * + */ + } + + /** + * beforeCreate handles the "creating" model event + */ + protected function beforeCreate() + { + /** + * @event model.beforeCreate + * Called before the model is created + * + * Example usage: + * + * $model->bindEvent('model.beforeCreate', function () use (\October\Rain\Halcyon\Model $model) { + * if (!$model->isValid()) { + * throw new \Exception("Invalid Model!"); + * } + * }); + * + */ + } + + /** + * afterCreate handles the "created" model event + */ + protected function afterCreate() + { + /** + * @event model.afterCreate + * Called after the model is created + * + * Example usage: + * + * $model->bindEvent('model.afterCreate', function () use (\October\Rain\Halcyon\Model $model) { + * \Log::info("{$model->name} was created!"); + * }); + * + */ + } + + /** + * beforeUpdate handles the "updating" model event + */ + protected function beforeUpdate() + { + /** + * @event model.beforeUpdate + * Called before the model is updated + * + * Example usage: + * + * $model->bindEvent('model.beforeUpdate', function () use (\October\Rain\Halcyon\Model $model) { + * if (!$model->isValid()) { + * throw new \Exception("Invalid Model!"); + * } + * }); + * + */ + } + + /** + * afterUpdate handles the "updated" model event + */ + protected function afterUpdate() + { + /** + * @event model.afterUpdate + * Called after the model is updated + * + * Example usage: + * + * $model->bindEvent('model.afterUpdate', function () use (\October\Rain\Halcyon\Model $model) { + * if ($model->title !== $model->original['title']) { + * \Log::info("{$model->name} updated its title!"); + * } + * }); + * + */ + } + + /** + * beforeSave handles the "saving" model event + */ + protected function beforeSave() + { + /** + * @event model.beforeSave + * Called before the model is created or updated + * + * Example usage: + * + * $model->bindEvent('model.beforeSave', function () use (\October\Rain\Halcyon\Model $model) { + * if (!$model->isValid()) { + * throw new \Exception("Invalid Model!"); + * } + * }); + * + */ + } + + /** + * afterSave handles the "saved" model event + */ + protected function afterSave() + { + /** + * @event model.afterSave + * Called after the model is created or updated + * + * Example usage: + * + * $model->bindEvent('model.afterSave', function () use (\October\Rain\Halcyon\Model $model) { + * if ($model->title !== $model->original['title']) { + * \Log::info("{$model->name} updated its title!"); + * } + * }); + * + */ + } + + /** + * beforeDelete handles the "deleting" model event + */ + protected function beforeDelete() + { + /** + * @event model.beforeDelete + * Called before the model is deleted + * + * Example usage: + * + * $model->bindEvent('model.beforeDelete', function () use (\October\Rain\Halcyon\Model $model) { + * if (!$model->isAllowedToBeDeleted()) { + * throw new \Exception("You cannot delete me!"); + * } + * }); + * + */ + } + + /** + * afterDelete handles the "deleted" model event + */ + protected function afterDelete() + { + /** + * @event model.afterDelete + * Called after the model is deleted + * + * Example usage: + * + * $model->bindEvent('model.afterDelete', function () use (\October\Rain\Halcyon\Model $model) { + * \Log::info("{$model->name} was deleted"); + * }); + * + */ + } + + /** + * beforeFetch handles the "fetching" model event + */ + protected function beforeFetch() + { + /** + * @event model.beforeFetch + * Called before the model is fetched + * + * Example usage: + * + * $model->bindEvent('model.beforeFetch', function () use (\October\Rain\Halcyon\Model $model) { + * if (!\Auth::getUser()->hasAccess('fetch.this.model')) { + * throw new \Exception("You shall not pass!"); + * } + * }); + * + */ + } + + /** + * afterFetch handles the "fetched" model event + */ + protected function afterFetch() + { + /** + * @event model.afterFetch + * Called after the model is fetched + * + * Example usage: + * + * $model->bindEvent('model.afterFetch', function () use (\October\Rain\Halcyon\Model $model) { + * \Log::info("{$model->name} was retrieved from the database"); + * }); + * + */ + } +} diff --git a/src/Halcyon/Model.php b/src/Halcyon/Model.php index 9175036ed..617e3a0cc 100644 --- a/src/Halcyon/Model.php +++ b/src/Halcyon/Model.php @@ -6,7 +6,6 @@ use October\Rain\Halcyon\Datasource\ResolverInterface as Resolver; use Illuminate\Contracts\Support\Jsonable; use Illuminate\Contracts\Support\Arrayable; -use Illuminate\Contracts\Events\Dispatcher; use BadMethodCallException; use JsonSerializable; use ArrayAccess; @@ -21,6 +20,7 @@ class Model extends Extendable implements ArrayAccess, Arrayable, Jsonable, JsonSerializable { use \October\Rain\Support\Traits\Emitter; + use \October\Rain\Halcyon\Concerns\HasEvents; /** * @var string datasource is the data source for the model, a directory path. @@ -89,11 +89,6 @@ class Model extends Extendable implements ArrayAccess, Arrayable, Jsonable, Json */ protected $loadedFromCache = false; - /** - * @var array observables are user exposed observable events. - */ - protected $observables = []; - /** * @var bool exists indicates if the model exists. */ @@ -109,25 +104,20 @@ class Model extends Extendable implements ArrayAccess, Arrayable, Jsonable, Json */ protected static $resolver; - /** - * @var \Illuminate\Contracts\Events\Dispatcher dispatcher instance - */ - protected static $dispatcher; - /** * @var array mutatorCache for each class. */ protected static $mutatorCache = []; /** - * @var array eventsBooted is the array of models booted events. + * @var array booted models */ - protected static $eventsBooted = []; + protected static $booted = []; /** - * @var array booted models + * @var array traitInitializers that will be called on each new instance. */ - protected static $booted = []; + protected static $traitInitializers = []; /** * __construct a new Halcyon model instance. @@ -138,10 +128,14 @@ public function __construct(array $attributes = []) { $this->bootIfNotBooted(); + $this->initializeTraits(); + $this->bootNicerEvents(); parent::__construct(); + $this->initializeModelEvent(); + $this->syncOriginal(); $this->fill($attributes); @@ -152,19 +146,27 @@ public function __construct(array $attributes = []) */ protected function bootIfNotBooted() { - $class = get_class($this); - - if (!isset(static::$booted[$class])) { - static::$booted[$class] = true; + if (!isset(static::$booted[static::class])) { + static::$booted[static::class] = true; $this->fireModelEvent('booting', false); + static::booting(); static::boot(); + static::booted(); $this->fireModelEvent('booted', false); } } + /** + * booting performs any actions required before the model boots. + */ + protected static function booting() + { + // + } + /** * boot is the "booting" method of the model. */ @@ -178,63 +180,55 @@ protected static function boot() */ protected static function bootTraits() { - foreach (class_uses_recursive(get_called_class()) as $trait) { - if (method_exists(get_called_class(), $method = 'boot'.class_basename($trait))) { - forward_static_call([get_called_class(), $method]); + $class = static::class; + + $booted = []; + + static::$traitInitializers[$class] = []; + + foreach (class_uses_recursive($class) as $trait) { + $method = 'boot'.class_basename($trait); + + if (method_exists($class, $method) && ! in_array($method, $booted)) { + forward_static_call([$class, $method]); + + $booted[] = $method; + } + + if (method_exists($class, $method = 'initialize'.class_basename($trait))) { + static::$traitInitializers[$class][] = $method; + + static::$traitInitializers[$class] = array_unique( + static::$traitInitializers[$class] + ); } } } /** - * clearBootedModels clears the list of booted models so they will be re-booted. + * initializeTraits on the model. */ - public static function clearBootedModels() + protected function initializeTraits() { - static::$booted = []; + foreach (static::$traitInitializers[static::class] as $method) { + $this->{$method}(); + } } /** - * bootNicerEvents binds some nicer events to this model, in the format of method overrides. + * booted performs any actions required after the model boots. */ - protected function bootNicerEvents() + protected static function booted() { - $class = get_called_class(); - - if (isset(static::$eventsBooted[$class])) { - return; - } - - $radicals = ['creat', 'sav', 'updat', 'delet', 'fetch']; - $hooks = ['before' => 'ing', 'after' => 'ed']; - - foreach ($radicals as $radical) { - foreach ($hooks as $hook => $event) { - $eventMethod = $radical . $event; // saving / saved - $method = $hook . ucfirst($radical); // beforeSave / afterSave - if ($radical !== 'fetch') { - $method .= 'e'; - } - - self::$eventMethod(function ($model) use ($method) { - $model->fireEvent('model.' . $method); - - if ($model->methodExists($method)) { - return $model->$method(); - } - }); - } - } - - // Hook to boot events // - static::registerModelEvent('booted', function ($model) { - $model->fireEvent('model.afterBoot'); - if ($model->methodExists('afterBoot')) { - return $model->afterBoot(); - } - }); + } - static::$eventsBooted[$class] = true; + /** + * clearBootedModels clears the list of booted models so they will be re-booted. + */ + public static function clearBootedModels() + { + static::$booted = []; } /** @@ -924,215 +918,6 @@ protected function performDeleteOnModel() $this->newQuery()->delete($this->fileName); } - /** - * Create a new native event for handling beforeFetch(). - * @param Closure|string $callback - * @return void - */ - public static function fetching($callback) - { - static::registerModelEvent('fetching', $callback); - } - - /** - * Create a new native event for handling afterFetch(). - * @param Closure|string $callback - * @return void - */ - public static function fetched($callback) - { - static::registerModelEvent('fetched', $callback); - } - - /** - * Register a saving model event with the dispatcher. - * - * @param \Closure|string $callback - * @param int $priority - * @return void - */ - public static function saving($callback, $priority = 0) - { - static::registerModelEvent('saving', $callback, $priority); - } - - /** - * Register a saved model event with the dispatcher. - * - * @param \Closure|string $callback - * @param int $priority - * @return void - */ - public static function saved($callback, $priority = 0) - { - static::registerModelEvent('saved', $callback, $priority); - } - - /** - * Register an updating model event with the dispatcher. - * - * @param \Closure|string $callback - * @param int $priority - * @return void - */ - public static function updating($callback, $priority = 0) - { - static::registerModelEvent('updating', $callback, $priority); - } - - /** - * Register an updated model event with the dispatcher. - * - * @param \Closure|string $callback - * @param int $priority - * @return void - */ - public static function updated($callback, $priority = 0) - { - static::registerModelEvent('updated', $callback, $priority); - } - - /** - * Register a creating model event with the dispatcher. - * - * @param \Closure|string $callback - * @param int $priority - * @return void - */ - public static function creating($callback, $priority = 0) - { - static::registerModelEvent('creating', $callback, $priority); - } - - /** - * Register a created model event with the dispatcher. - * - * @param \Closure|string $callback - * @param int $priority - * @return void - */ - public static function created($callback, $priority = 0) - { - static::registerModelEvent('created', $callback, $priority); - } - - /** - * Register a deleting model event with the dispatcher. - * - * @param \Closure|string $callback - * @param int $priority - * @return void - */ - public static function deleting($callback, $priority = 0) - { - static::registerModelEvent('deleting', $callback, $priority); - } - - /** - * Register a deleted model event with the dispatcher. - * - * @param \Closure|string $callback - * @param int $priority - * @return void - */ - public static function deleted($callback, $priority = 0) - { - static::registerModelEvent('deleted', $callback, $priority); - } - - /** - * Remove all of the event listeners for the model. - * - * @return void - */ - public static function flushEventListeners() - { - if (!isset(static::$dispatcher)) { - return; - } - - $instance = new static; - - foreach ($instance->getObservableEvents() as $event) { - static::$dispatcher->forget("halcyon.{$event}: ".get_called_class()); - } - - static::$eventsBooted = []; - } - - /** - * Register a model event with the dispatcher. - * - * @param string $event - * @param \Closure|string $callback - * @param int $priority - * @return void - */ - protected static function registerModelEvent($event, $callback, $priority = 0) - { - if (isset(static::$dispatcher)) { - $name = get_called_class(); - - static::$dispatcher->listen("halcyon.{$event}: {$name}", $callback, $priority); - } - } - - /** - * Get the observable event names. - * - * @return array - */ - public function getObservableEvents() - { - return array_merge( - [ - 'creating', 'created', 'updating', 'updated', - 'deleting', 'deleted', 'saving', 'saved', - 'fetching', 'fetched' - ], - $this->observables - ); - } - - /** - * Set the observable event names. - * - * @param array $observables - * @return $this - */ - public function setObservableEvents(array $observables) - { - $this->observables = $observables; - - return $this; - } - - /** - * Add an observable event name. - * - * @param array|mixed $observables - * @return void - */ - public function addObservableEvents($observables) - { - $observables = is_array($observables) ? $observables : func_get_args(); - - $this->observables = array_unique(array_merge($this->observables, $observables)); - } - - /** - * Remove an observable event name. - * - * @param array|mixed $observables - * @return void - */ - public function removeObservableEvents($observables) - { - $observables = is_array($observables) ? $observables : func_get_args(); - - $this->observables = array_diff($this->observables, $observables); - } - /** * Update the model in the database. * @@ -1255,7 +1040,7 @@ protected function performInsert(Builder $query, array $options = []) } // Ensure the settings attribute is passed through so this distinction - // is recognised, mainly by the processor. + // is recognized, mainly by the processor. $attributes = $this->attributesToArray(); $query->insert($attributes); @@ -1270,29 +1055,6 @@ protected function performInsert(Builder $query, array $options = []) return true; } - /** - * Fire the given event for the model. - * - * @param string $event - * @param bool $halt - * @return mixed - */ - protected function fireModelEvent($event, $halt = true) - { - if (!isset(static::$dispatcher)) { - return true; - } - - // We will append the names of the class to the event to distinguish it from - // other model events that are fired, allowing us to listen on each model - // event set individually instead of catching event for all the models. - $event = "halcyon.{$event}: ".get_class($this); - - $method = $halt ? 'until' : 'dispatch'; - - return static::$dispatcher->$method($event, $this); - } - /** * Get a new query builder for the object * @return \October\Rain\Halcyon\Builder @@ -1341,7 +1103,7 @@ public function getFileNameParts($fileName = null) } /** - * Get the datasource for the model. + * getDatasource for the model. * * @return \October\Rain\Halcyon\Datasource\DatasourceInterface */ @@ -1351,7 +1113,7 @@ public function getDatasource() } /** - * Get the current datasource name for the model. + * getDatasourceName for the model. * * @return string */ @@ -1361,7 +1123,7 @@ public function getDatasourceName() } /** - * Set the datasource associated with the model. + * setDatasource associated with the model. * * @param string $name * @return $this @@ -1374,7 +1136,7 @@ public function setDatasource($name) } /** - * Resolve a datasource instance. + * resolveDatasource instance. * * @param string|null $datasource * @return \October\Rain\Halcyon\Datasource @@ -1385,7 +1147,7 @@ public static function resolveDatasource($datasource = null) } /** - * Get the datasource resolver instance. + * getDatasourceResolver instance. * * @return \October\Rain\Halcyon\DatasourceResolverInterface */ @@ -1395,7 +1157,7 @@ public static function getDatasourceResolver() } /** - * Set the datasource resolver instance. + * setDatasourceResolver instance. * * @param \October\Rain\Halcyon\Datasource\ResolverInterface $resolver * @return void @@ -1406,7 +1168,7 @@ public static function setDatasourceResolver(Resolver $resolver) } /** - * Unset the datasource resolver for models. + * unsetDatasourceResolver for models. * * @return void */ @@ -1416,38 +1178,7 @@ public static function unsetDatasourceResolver() } /** - * Get the event dispatcher instance. - * - * @return \Illuminate\Contracts\Events\Dispatcher - */ - public static function getEventDispatcher() - { - return static::$dispatcher; - } - - /** - * Set the event dispatcher instance. - * - * @param \Illuminate\Contracts\Events\Dispatcher $dispatcher - * @return void - */ - public static function setEventDispatcher(Dispatcher $dispatcher) - { - static::$dispatcher = $dispatcher; - } - - /** - * Unset the event dispatcher for models. - * - * @return void - */ - public static function unsetEventDispatcher() - { - static::$dispatcher = null; - } - - /** - * Get the cache manager instance. + * getCacheManager instance. * * @return \Illuminate\Cache\CacheManager */ @@ -1457,7 +1188,7 @@ public static function getCacheManager() } /** - * Set the cache manager instance. + * setCacheManager instance. * * @param \Illuminate\Cache\CacheManager $cache * @return void @@ -1468,7 +1199,7 @@ public static function setCacheManager($cache) } /** - * Unset the cache manager for models. + * unsetCacheManager for models. * * @return void */ @@ -1478,7 +1209,7 @@ public static function unsetCacheManager() } /** - * Initializes the object properties from the cached data. The extra data + * initCacheItem initializes the object properties from the cached data. The extra data * set here becomes available as attributes set on the model after fetch. * @param array $cached The cached data array. */ @@ -1487,13 +1218,13 @@ public static function initCacheItem(&$item) } /** - * Get the mutated attributes for a given instance. + * getMutatedAttributes gets the mutated attributes for a given instance. * * @return array */ public function getMutatedAttributes() { - $class = get_class($this); + $class = static::class; if (!isset(static::$mutatorCache[$class])) { static::cacheMutatedAttributes($class); @@ -1503,7 +1234,7 @@ public function getMutatedAttributes() } /** - * Extract and cache all the mutated attributes of a class. + * cacheMutatedAttributes extracts and cache all the mutated attributes of a class. * * @param string $class * @return void @@ -1525,7 +1256,7 @@ public static function cacheMutatedAttributes($class) } /** - * Dynamically retrieve attributes on the model. + * __get dynamically retrieve attributes on the model. * * @param string $key * @return mixed @@ -1536,7 +1267,7 @@ public function __get($key) } /** - * Dynamically set attributes on the model. + * __set dynamically set attributes on the model. * * @param string $key * @param mixed $value @@ -1553,7 +1284,7 @@ public function __set($key, $value) } /** - * Determine if the given attribute exists. + * offsetExists determines if the given attribute exists. * * @param mixed $offset * @return bool @@ -1564,7 +1295,7 @@ public function offsetExists($offset): bool } /** - * Get the value for a given offset. + * offsetGet the value for a given offset. * * @param mixed $offset * @return mixed @@ -1575,7 +1306,7 @@ public function offsetGet($offset): mixed } /** - * Set the value for a given offset. + * offsetSet the value for a given offset. * * @param mixed $offset * @param mixed $value @@ -1587,7 +1318,7 @@ public function offsetSet($offset, $value): void } /** - * Unset the value for a given offset. + * offsetUnset the value for a given offset. * * @param mixed $offset * @return void @@ -1598,7 +1329,7 @@ public function offsetUnset($offset): void } /** - * Determine if an attribute exists on the model. + * __isset determines if an attribute exists on the model. * * @param string $key * @return bool @@ -1613,7 +1344,7 @@ public function __isset($key) } /** - * Unset an attribute on the model. + * __unset an attribute on the model. * * @param string $key * @return void @@ -1624,7 +1355,7 @@ public function __unset($key) } /** - * Handle dynamic method calls into the model. + * __call handles dynamic method calls into the model. * * @param string $method * @param array $parameters @@ -1642,7 +1373,7 @@ public function __call($method, $parameters) } /** - * Handle dynamic static method calls into the method. + * __callStatic handles dynamic static method calls into the method. * * @param string $method * @param array $parameters @@ -1656,7 +1387,7 @@ public static function __callStatic($method, $parameters) } /** - * Convert the model to its string representation. + * __toString converts the model to its string representation. * * @return string */ @@ -1664,4 +1395,32 @@ public function __toString() { return $this->toJson(); } + + /** + * __sleep prepare the object for serialization. + */ + public function __sleep() + { + $this->unbindEvent(); + + $this->extendableDestruct(); + + return parent::__sleep(); + } + + /** + * __wakeup when a model is being unserialized, check if it needs to be booted. + */ + public function __wakeup() + { + parent::__wakeup(); + + $this->bootIfNotBooted(); + + $this->initializeTraits(); + + $this->bootNicerEvents(); + + $this->initializeModelEvent(); + } } diff --git a/src/Halcyon/Traits/Validation.php b/src/Halcyon/Traits/Validation.php index 2f5138267..7e890cd7e 100644 --- a/src/Halcyon/Traits/Validation.php +++ b/src/Halcyon/Traits/Validation.php @@ -48,8 +48,8 @@ trait Validation */ public static function bootValidation() { - if (!property_exists(get_called_class(), 'rules')) { - throw new Exception(sprintf('You must define a $rules property in %s to use the Validation trait.', get_called_class())); + if (!property_exists(static::class, 'rules')) { + throw new Exception(sprintf('You must define a $rules property in %s to use the Validation trait.', static::class)); } static::extend(function ($model) { diff --git a/src/Html/FormBuilder.php b/src/Html/FormBuilder.php index b2b674dcc..cab7fa687 100644 --- a/src/Html/FormBuilder.php +++ b/src/Html/FormBuilder.php @@ -351,6 +351,18 @@ public function email($name, $value = null, $options = []) return $this->input('email', $name, $value, $options); } + /** + * number input field. + * @param string $name + * @param string $value + * @param array $options + * @return string + */ + public function number($name, $value = null, $options = []) + { + return $this->input('number', $name, $value, $options); + } + /** * url input field. * @param string $name @@ -462,6 +474,12 @@ public function select($name, $list = [], $selected = null, $options = []) $list = ['' => $options['emptyOption']] + $list; } + $selectOptions = false; + if (array_key_exists('selectOptions', $options)) { + $selectOptions = $options['selectOptions'] === true; + unset($options['selectOptions']); + } + // When building a select box the "value" attribute is really the selected one // so we will use that when checking the model or session for a value which // should provide a convenient method of re-populating the forms on post. @@ -489,7 +507,20 @@ public function select($name, $list = [], $selected = null, $options = []) $list = implode('', $html); - return ""; + return $selectOptions ? $list : ""; + } + + /** + * selectOptions only renders the options inside a select. + * @param string $name + * @param array $list + * @param string $selected + * @param array $options + * @return string + */ + public function selectOptions($name, $list = [], $selected = null, $options = []) + { + return $this->select($name, $list, $selected, ['selectOptions' => true] + $options); } /** diff --git a/src/Html/Helper.php b/src/Html/Helper.php index 5f45ca5b6..d1912b5dc 100644 --- a/src/Html/Helper.php +++ b/src/Html/Helper.php @@ -24,7 +24,7 @@ public static function nameToId($string) * nameToArray converts a HTML named array string to a PHP array. Empty values are removed. * HTML: user[location][city] * PHP: ['user', 'location', 'city'] - * @param $string String to process + * @param $string * @return array */ public static function nameToArray($string) @@ -51,6 +51,18 @@ public static function nameToArray($string) return $result; } + /** + * nameToDot converts a HTML named array string to a dot notated string. + * HTML: user[location][city] + * Dot: user.location.city + * @param $string + * @return string + */ + public static function nameToDot($string) + { + return implode('.', static::nameToArray($string)); + } + /** * reduceNameHierarchy reduces the field name hierarchy depth by $level levels. * country[city][0][street][0] turns into country[city][0] when reduced by 1 level; diff --git a/src/Html/UrlMixin.php b/src/Html/UrlMixin.php new file mode 100644 index 000000000..a939c060d --- /dev/null +++ b/src/Html/UrlMixin.php @@ -0,0 +1,82 @@ +provider = $provider; + } + + /** + * makeRelative converts a full URL to a relative URL + */ + public function makeRelative($url) + { + $fullUrl = $this->provider->to($url); + return parse_url($fullUrl, PHP_URL_PATH) + . (($query = parse_url($fullUrl, PHP_URL_QUERY)) ? '?' . $query : '') + . (($fragment = parse_url($fullUrl, PHP_URL_FRAGMENT)) ? '#' . $fragment : '') + ?: '/'; + } + + /** + * toRelative makes a link relative if configuration asks for it + */ + public function toRelative($url) + { + return Config::get('system.relative_links', false) + ? $this->makeRelative($url) + : $this->provider->to($url); + } + + /** + * toSigned signs a bare URL that can be validated with hasValidSignature + */ + public function toSigned($url, $expiration = null, $absolute = true) + { + if (!$absolute) { + $url = $this->makeRelative($url); + } + + $parameters = []; + + $parts = parse_url($url); + + parse_str($parts['query'] ?? '', $parameters); + + unset($parameters['signature']); + + ksort($parameters); + + if ($expiration) { + unset($parameters['expires']); + $parameters = $parameters + ['expires' => $this->availableAt($expiration)]; + } + + $key = Config::get('app.key'); + + $signUrl = http_build_url($url, ['query' => http_build_query($parameters)]); + + $signature = hash_hmac('sha256', $signUrl, $key); + + return http_build_url($url, ['query' => http_build_query($parameters + ['signature' => $signature])]); + } +} diff --git a/src/Html/UrlServiceProvider.php b/src/Html/UrlServiceProvider.php index acf75ca46..28a464364 100644 --- a/src/Html/UrlServiceProvider.php +++ b/src/Html/UrlServiceProvider.php @@ -32,22 +32,23 @@ public function register() */ public function registerUrlGeneratorPolicy() { - $policy = $this->app['config']->get('system.link_policy', 'detect'); + $provider = $this->app['url']; + $policy = Config::get('system.link_policy', 'detect'); switch (strtolower($policy)) { case 'force': - $appUrl = $this->app['config']->get('app.url'); + $appUrl = Config::get('app.url'); $schema = Str::startsWith($appUrl, 'http://') ? 'http' : 'https'; - $this->app['url']->forceRootUrl($appUrl); - $this->app['url']->forceScheme($schema); + $provider->forceRootUrl($appUrl); + $provider->forceScheme($schema); break; case 'insecure': - $this->app['url']->forceScheme('http'); + $provider->forceScheme('http'); break; case 'secure': - $this->app['url']->forceScheme('https'); + $provider->forceScheme('https'); break; } } @@ -59,19 +60,16 @@ public function registerRelativeHelper() { $provider = $this->app['url']; - $provider->macro('makeRelative', function($url) use ($provider) { - $fullUrl = $provider->to($url); - return parse_url($fullUrl, PHP_URL_PATH) - . (($query = parse_url($fullUrl, PHP_URL_QUERY)) ? '?' . $query : '') - . (($fragment = parse_url($fullUrl, PHP_URL_FRAGMENT)) ? '#' . $fragment : ''); + $provider->macro('makeRelative', function(...$args) use ($provider) { + return (new \October\Rain\Html\UrlMixin($provider))->makeRelative(...$args); }); - $provider->macro('toRelative', function($url) use ($provider) { - if (Config::get('system.relative_links', false)) { - return $provider->makeRelative($url); - } + $provider->macro('toRelative', function(...$args) use ($provider) { + return (new \October\Rain\Html\UrlMixin($provider))->toRelative(...$args); + }); - return $provider->to($url); + $provider->macro('toSigned', function(...$args) use ($provider) { + return (new \October\Rain\Html\UrlMixin($provider))->toSigned(...$args); }); } diff --git a/src/Mail/Mailer.php b/src/Mail/Mailer.php index 19c3908bb..e958498b3 100644 --- a/src/Mail/Mailer.php +++ b/src/Mail/Mailer.php @@ -187,9 +187,7 @@ public function sendTo($recipients, $view, array $data = [], $callback = null, $ $recipients = $this->processRecipients($recipients); return $this->{$method}($view, $data, function ($message) use ($recipients, $callback, $bcc) { - $method = $bcc === true ? 'bcc' : 'to'; - foreach ($recipients as $address => $name) { $message->{$method}($address, $name); } diff --git a/src/Scaffold/Console/controller/_list_toolbar.stub b/src/Scaffold/Console/controller/_list_toolbar.stub index b89b55f87..2092a00e2 100644 --- a/src/Scaffold/Console/controller/_list_toolbar.stub +++ b/src/Scaffold/Console/controller/_list_toolbar.stub @@ -1,17 +1,23 @@ -