From ef44ff5603e98522c2e74c66f44e2dd35b368f19 Mon Sep 17 00:00:00 2001 From: Alexander Skvortsov <38059171+askvortsov1@users.noreply.github.com> Date: Fri, 24 Apr 2020 09:10:24 -0400 Subject: [PATCH] Add model extender (#2100) This covers default attribute values, date attributes and custom relationships. --- framework/core/src/Database/AbstractModel.php | 39 +-- .../core/src/Event/ConfigureModelDates.php | 2 + .../Event/ConfigureModelDefaultAttributes.php | 3 + .../core/src/Event/GetModelRelationship.php | 2 + framework/core/src/Extend/Model.php | 171 ++++++++++++ .../tests/integration/extenders/ModelTest.php | 250 ++++++++++++++++++ 6 files changed, 452 insertions(+), 15 deletions(-) create mode 100644 framework/core/src/Extend/Model.php create mode 100644 framework/core/tests/integration/extenders/ModelTest.php diff --git a/framework/core/src/Database/AbstractModel.php b/framework/core/src/Database/AbstractModel.php index baf74e07b..5a74e0fae 100644 --- a/framework/core/src/Database/AbstractModel.php +++ b/framework/core/src/Database/AbstractModel.php @@ -14,6 +14,7 @@ use Flarum\Event\ConfigureModelDefaultAttributes; use Flarum\Event\GetModelRelationship; use Illuminate\Database\Eloquent\Model as Eloquent; use Illuminate\Database\Eloquent\Relations\Relation; +use Illuminate\Support\Arr; use LogicException; /** @@ -46,6 +47,12 @@ abstract class AbstractModel extends Eloquent */ protected $afterDeleteCallbacks = []; + public static $customRelations = []; + + public static $dateAttributes = []; + + public static $defaults = []; + /** * {@inheritdoc} */ @@ -71,13 +78,16 @@ abstract class AbstractModel extends Eloquent */ public function __construct(array $attributes = []) { - $defaults = []; + $this->attributes = Arr::get(static::$defaults, static::class, []); + // Deprecated in beta 13, remove in beta 14. static::$dispatcher->dispatch( - new ConfigureModelDefaultAttributes($this, $defaults) + new ConfigureModelDefaultAttributes($this, $this->attributes) ); - $this->attributes = $defaults; + $this->attributes = array_map(function ($item) { + return is_callable($item) ? $item() : $item; + }, $this->attributes); parent::__construct($attributes); } @@ -89,19 +99,11 @@ abstract class AbstractModel extends Eloquent */ public function getDates() { - static $dates = []; + static::$dispatcher->dispatch( + new ConfigureModelDates($this, $this->dates) + ); - $class = get_class($this); - - if (! isset($dates[$class])) { - static::$dispatcher->dispatch( - new ConfigureModelDates($this, $this->dates) - ); - - $dates[$class] = $this->dates; - } - - return $dates[$class]; + return array_merge($this->dates, Arr::get(static::$dateAttributes, static::class, [])); } /** @@ -139,6 +141,13 @@ abstract class AbstractModel extends Eloquent */ protected function getCustomRelation($name) { + $relation = Arr::get(static::$customRelations, static::class.".$name", null); + + if (! is_null($relation)) { + return $relation($this); + } + + // Deprecated, remove in beta 14 return static::$dispatcher->until( new GetModelRelationship($this, $name) ); diff --git a/framework/core/src/Event/ConfigureModelDates.php b/framework/core/src/Event/ConfigureModelDates.php index 8a1ef760b..d110986a0 100644 --- a/framework/core/src/Event/ConfigureModelDates.php +++ b/framework/core/src/Event/ConfigureModelDates.php @@ -12,6 +12,8 @@ namespace Flarum\Event; use Flarum\Database\AbstractModel; /** + * @deprecated in beta 13, removed in beta 14 + * * The `ConfigureModelDates` event is called to retrieve a list of fields for a model * that should be converted into date objects. */ diff --git a/framework/core/src/Event/ConfigureModelDefaultAttributes.php b/framework/core/src/Event/ConfigureModelDefaultAttributes.php index 6eae72a48..3965147b5 100644 --- a/framework/core/src/Event/ConfigureModelDefaultAttributes.php +++ b/framework/core/src/Event/ConfigureModelDefaultAttributes.php @@ -11,6 +11,9 @@ namespace Flarum\Event; use Flarum\Database\AbstractModel; +/** + * @deprecated in beta 13, removed in beta 14 + */ class ConfigureModelDefaultAttributes { /** diff --git a/framework/core/src/Event/GetModelRelationship.php b/framework/core/src/Event/GetModelRelationship.php index 337567160..1c52eba2e 100644 --- a/framework/core/src/Event/GetModelRelationship.php +++ b/framework/core/src/Event/GetModelRelationship.php @@ -12,6 +12,8 @@ namespace Flarum\Event; use Flarum\Database\AbstractModel; /** + * @deprecated beta 13, use the Model extender instead. + * * The `GetModelRelationship` event is called to retrieve Relation object for a * model. Listeners should return an Eloquent Relation object. */ diff --git a/framework/core/src/Extend/Model.php b/framework/core/src/Extend/Model.php new file mode 100644 index 000000000..8062466c9 --- /dev/null +++ b/framework/core/src/Extend/Model.php @@ -0,0 +1,171 @@ +modelClass = $modelClass; + } + + /** + * Add an attribute to be treated as a date. + * + * @param string $attribute + */ + public function dateAttribute(string $attribute) + { + Arr::set(AbstractModel::$dateAttributes, $this->modelClass, array_merge(Arr::get(AbstractModel::$dateAttributes, $this->modelClass, []), [$attribute])); + + return $this; + } + + /** + * Add a default value for a given attribute, which can be an explicit value, or a closure. + * + * @param string $attribute + * @param mixed $value + */ + public function default(string $attribute, $value) + { + Arr::set(AbstractModel::$defaults, "$this->modelClass.$attribute", $value); + + return $this; + } + + /** + * Add a relationship from this model to another model. + * + * @param string $name: The name of the relation. This doesn't have to be anything in particular, + * but has to be unique from other relation names for this model, and should + * work as the name of a method. + * @param callable $callable + * + * The callable can be a closure or invokable class, and should accept: + * - $instance: An instance of this model. + * + * The callable should return: + * - $relationship: A Laravel Relationship object. See relevant methods of models + * like \Flarum\User\User for examples of how relationships should be returned. + */ + public function relationship(string $name, callable $callable) + { + Arr::set(AbstractModel::$customRelations, "$this->modelClass.$name", $callable); + + return $this; + } + + /** + * Establish a simple belongsTo relationship from this model to another model. + * This represents an inverse one-to-one or inverse one-to-many relationship. + * For more complex relationships, use the ->relationship method. + * + * @param string $name: The name of the relation. This doesn't have to be anything in particular, + * but has to be unique from other relation names for this model, and should + * work as the name of a method. + * @param string $related: The ::class attribute of the model, which should extend \Flarum\Database\AbstractModel. + * @param string $foreignKey: The foreign key attribute of the parent model. + * @param string $ownerKey: The primary key attribute of the parent model. + */ + public function belongsTo(string $name, $related, $foreignKey = null, $ownerKey = null) + { + return $this->relationship($name, function (AbstractModel $model) use ($related, $foreignKey, $ownerKey, $name) { + return $model->belongsTo($related, $foreignKey, $ownerKey, $name); + }); + } + + /** + * Establish a simple belongsToMany relationship from this model to another model. + * This represents a many-to-many relationship. + * For more complex relationships, use the ->relationship method. + * + * @param string $name: The name of the relation. This doesn't have to be anything in particular, + * but has to be unique from other relation names for this model, and should + * work as the name of a method. + * @param string $related: The ::class attribute of the model, which should extend \Flarum\Database\AbstractModel. + * @param string $table: The intermediate table for this relation + * @param string $foreignPivotKey: The foreign key attribute of the parent model. + * @param string $relatedPivotKey: The associated key attribute of the relation. + * @param string $parentKey: The key name of the parent model. + * @param string $relatedKey: The key name of the related model. + */ + public function belongsToMany( + string $name, + $related, + $table = null, + $foreignPivotKey = null, + $relatedPivotKey = null, + $parentKey = null, + $relatedKey = null + ) + { + return $this->relationship($name, function (AbstractModel $model) use ($related, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, $name) { + return $model->belongsToMany($related, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, $name); + }); + } + + /** + * Establish a simple hasOne relationship from this model to another model. + * This represents a one-to-one relationship. + * For more complex relationships, use the ->relationship method. + * + * @param string $name: The name of the relation. This doesn't have to be anything in particular, + * but has to be unique from other relation names for this model, and should + * work as the name of a method. + * @param string $related: The ::class attribute of the model, which should extend \Flarum\Database\AbstractModel. + * @param string $foreignKey: The foreign key attribute of the parent model. + * @param string $localKey: The primary key attribute of the parent model. + */ + public function hasOne(string $name, $related, $foreignKey = null, $localKey = null) + { + return $this->relationship($name, function (AbstractModel $model) use ($related, $foreignKey, $localKey) { + return $model->hasOne($related, $foreignKey, $localKey); + }); + } + + /** + * Establish a simple hasMany relationship from this model to another model. + * This represents a one-to-many relationship. + * For more complex relationships, use the ->relationship method. + * + * @param string $name: The name of the relation. This doesn't have to be anything in particular, + * but has to be unique from other relation names for this model, and should + * work as the name of a method. + * @param string $related: The ::class attribute of the model, which should extend \Flarum\Database\AbstractModel. + * @param string $foreignKey: The foreign key attribute of the parent model. + * @param string $localKey: The primary key attribute of the parent model. + */ + public function hasMany(string $name, $related, $foreignKey = null, $localKey = null) + { + return $this->relationship($name, function (AbstractModel $model) use ($related, $foreignKey, $localKey) { + return $model->hasMany($related, $foreignKey, $localKey); + }); + } + + public function extend(Container $container, Extension $extension = null) + { + // Nothing needed here. + } +} diff --git a/framework/core/tests/integration/extenders/ModelTest.php b/framework/core/tests/integration/extenders/ModelTest.php new file mode 100644 index 000000000..f10c981b7 --- /dev/null +++ b/framework/core/tests/integration/extenders/ModelTest.php @@ -0,0 +1,250 @@ +prepareDatabase([ + 'users' => [ + $this->adminUser(), + $this->normalUser(), + ], + 'discussions' => [] + ]); + } + + /** + * @test + */ + public function custom_relationship_does_not_exist_by_default() + { + $this->prepDB(); + + $user = User::find(1); + + $this->expectException(\BadMethodCallException::class); + $user->customRelation(); + } + + /** + * @test + */ + public function custom_relationship_exists_if_added() + { + $this->extend((new Extend\Model(User::class))->relationship('customRelation', function (User $user) { + return $user->hasMany(Discussion::class, 'user_id'); + })); + + $this->prepDB(); + + $user = User::find(1); + + $this->assertEquals([], $user->customRelation()->get()->toArray()); + } + + /** + * @test + */ + public function custom_relationship_exists_and_can_return_instances_if_added() + { + $this->extend((new Extend\Model(User::class))->relationship('customRelation', function (User $user) { + return $user->hasMany(Discussion::class, 'user_id'); + })); + + $this->prepDB(); + + $this->prepareDatabase([ + 'discussions' => [ + ['id' => 1, 'title' => __CLASS__, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1] + ] + ]); + + $user = User::find(1); + + $this->assertNotEquals([], $user->customRelation()->get()->toArray()); + $this->assertContains(json_encode(__CLASS__), json_encode($user->customRelation()->get())); + } + + /** + * @test + */ + public function custom_relationship_does_not_exist_if_added_to_unrelated_model() + { + $this->extend((new Extend\Model(User::class))->relationship('customRelation', function (User $user) { + return $user->hasMany(Discussion::class, 'user_id'); + })); + + $this->prepDB(); + $this->prepareDatabase([ + 'groups' => [ + $this->adminGroup() + ] + ]); + + $group = Group::find(1); + + $this->expectException(\BadMethodCallException::class); + $group->customRelation(); + } + + /** + * @test + */ + public function custom_hasOne_relationship_exists_if_added() + { + $this->extend((new Extend\Model(User::class))->hasMany('customRelation', Discussion::class, 'user_id')); + + $this->prepDB(); + + $user = User::find(1); + + $this->assertEquals([], $user->customRelation()->get()->toArray()); + } + + /** + * @test + */ + public function custom_hasMany_relationship_exists_if_added() + { + $this->extend((new Extend\Model(User::class))->hasMany('customRelation', Discussion::class, 'user_id')); + + $this->prepDB(); + + $user = User::find(1); + + $this->assertEquals([], $user->customRelation()->get()->toArray()); + } + + /** + * @test + */ + public function custom_belongsTo_relationship_exists_if_added() + { + $this->extend((new Extend\Model(User::class))->belongsTo('customRelation', Discussion::class, 'user_id')); + + $this->prepDB(); + + $user = User::find(1); + + $this->assertEquals([], $user->customRelation()->get()->toArray()); + } + + /** + * @test + */ + public function custom_default_attribute_doesnt_exist_if_not_set() + { + $group = new Group; + + $this->app(); + + $this->assertNotEquals('Custom Default', $group->name_singular); + } + + /** + * @test + */ + public function custom_default_attribute_works_if_set() + { + $this->extend((new Extend\Model(Group::class))->default('name_singular', 'Custom Default')); + + $this->app(); + + $group = new Group; + + $this->assertEquals('Custom Default', $group->name_singular); + } + + /** + * @test + */ + public function custom_default_attribute_evaluated_at_runtime_if_callable() + { + $time = Carbon::now(); + $this->extend((new Extend\Model(Group::class))->default('name_singular', function () { + return Carbon::now(); + })); + + $this->app(); + + sleep(2); + + $group = new Group; + + $this->assertGreaterThanOrEqual($time->diffInSeconds($group->name_singular), 2); + } + + /** + * @test + */ + public function custom_default_attribute_doesnt_work_if_set_on_unrelated_model() + { + $this->extend((new Extend\Model(Group::class))->default('name_singular', 'Custom Default')); + + $this->app(); + + $user = new User; + + $this->assertNotEquals('Custom Default', $user->name_singular); + } + + /** + * @test + */ + public function custom_date_attribute_doesnt_exist_by_default() + { + $post = new Post; + + $this->app(); + + $this->assertNotContains('custom', $post->getDates()); + } + + /** + * @test + */ + public function custom_date_attribute_can_be_set() + { + $this->extend((new Extend\Model(Post::class))->dateAttribute('custom')); + + $this->app(); + + $post = new Post; + + $this->assertContains('custom', $post->getDates()); + } + + /** + * @test + */ + public function custom_date_attribute_doesnt_work_if_set_on_unrelated_model() + { + $this->extend((new Extend\Model(Post::class))->dateAttribute('custom')); + + $this->app(); + + $discussion = new Discussion; + + $this->assertNotContains('custom', $discussion->getDates()); + } +}