From cbaa2e577f4be012fb5776cbc9ad33d4ce8bc591 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Mon, 19 Feb 2024 16:24:40 +0800 Subject: [PATCH 01/59] WIP Laravel style relations --- composer.json | 4 - phpunit.xml.dist | 1 + src/Database/Concerns/HasRelationships.php | 84 +++++++-- tests/Database/Fixtures/Author.php | 94 ++++++++++ tests/Database/Fixtures/Category.php | 58 +++++++ tests/Database/Fixtures/CategoryNested.php | 47 +++++ tests/Database/Fixtures/CategorySimple.php | 8 + tests/Database/Fixtures/MigratesForTest.php | 33 ++++ tests/Database/Fixtures/Post.php | 98 +++++++++++ tests/Database/Relations/BelongsToTest.php | 179 ++++++++++++++++++++ tests/DbTestCase.php | 74 +++++++- tests/TestCase.php | 3 + tests/bootstrap.php | 4 + 13 files changed, 662 insertions(+), 25 deletions(-) create mode 100644 tests/Database/Fixtures/Author.php create mode 100644 tests/Database/Fixtures/Category.php create mode 100644 tests/Database/Fixtures/CategoryNested.php create mode 100644 tests/Database/Fixtures/CategorySimple.php create mode 100644 tests/Database/Fixtures/MigratesForTest.php create mode 100644 tests/Database/Fixtures/Post.php create mode 100644 tests/Database/Relations/BelongsToTest.php create mode 100644 tests/bootstrap.php diff --git a/composer.json b/composer.json index dca52f738..db2f342af 100644 --- a/composer.json +++ b/composer.json @@ -83,10 +83,6 @@ } }, "autoload-dev": { - "classmap": [ - "tests/TestCase.php", - "tests/DbTestCase.php" - ], "psr-4": { "Winter\\Storm\\Tests\\": "tests/" } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 5db8edbf7..c33e73c08 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -2,6 +2,7 @@ HasOne::class, + 'hasMany' => HasMany::class, + 'belongsTo' => BelongsTo::class, + 'belongsToMany' => BelongsToMany::class, + 'morphTo' => MorphTo::class, + 'morphOne' => MorphOne::class, + 'morphMany' => MorphMany::class, + 'morphToMany' => MorphToMany::class, + 'morphedByMany' => MorphToMany::class, + 'attachOne' => AttachOne::class, + 'attachMany' => AttachMany::class, + 'hasOneThrough' => HasOneThrough::class, + 'hasManyThrough' => HasManyThrough::class, ]; + /** + * @var array Stores relations that have resolved to Laravel-style relation objects. + */ + protected static array $resolvedRelations = []; + // // Relations // @@ -145,6 +150,15 @@ trait HasRelationships */ public function hasRelation($name): bool { + if (method_exists($this, $name)) { + if (array_key_exists($name, static::$resolvedRelations)) { + return static::$resolvedRelations[$name] !== null; + } + + static::$resolvedRelations[$name] = $this->returnsRelation(new \ReflectionMethod($this, $name)); + return static::$resolvedRelations[$name] !== null; + } + return $this->getRelationDefinition($name) !== null; } @@ -168,7 +182,7 @@ public function getRelationDefinition($name): ?array */ public function getRelationTypeDefinitions($type) { - if (in_array($type, static::$relationTypes)) { + if (in_array($type, array_keys(static::$relationTypes))) { return $this->{$type}; } @@ -199,7 +213,7 @@ public function getRelationDefinitions(): array { $result = []; - foreach (static::$relationTypes as $type) { + foreach (array_keys(static::$relationTypes) as $type) { $result[$type] = $this->getRelationTypeDefinitions($type); /* @@ -220,7 +234,21 @@ public function getRelationDefinitions(): array */ public function getRelationType(string $name): ?string { - foreach (static::$relationTypes as $type) { + if (method_exists($this, $name)) { + if (array_key_exists($name, static::$resolvedRelations)) { + return array_search(static::$resolvedRelations[$name], static::$relationTypes) ?: null; + } + + static::$resolvedRelations[$name] = $this->returnsRelation(new \ReflectionMethod($this, $name)); + + if (static::$resolvedRelations[$name] !== null) { + return array_search(static::$resolvedRelations[$name], static::$relationTypes) ?: null; + } + + return null; + } + + foreach (array_keys(static::$relationTypes) as $type) { if ($this->getRelationTypeDefinition($type, $name) !== null) { return $type; } @@ -802,7 +830,7 @@ protected function setRelationValue($relationName, $value) */ protected function addRelation(string $type, string $name, array $config): void { - if (!in_array($type, static::$relationTypes)) { + if (!in_array($type, array_keys(static::$relationTypes))) { throw new InvalidArgumentException( sprintf( 'Cannot add the "%s" relation to %s, %s is not a valid relationship type.', @@ -968,4 +996,26 @@ protected function getMorphs($name, $type = null, $id = null) { return [$type ?: $name.'_type', $id ?: $name.'_id']; } + + /** + * Determines if a method returns a relation class. + * + * This is used to determine Laravel-style relation methods in a way that won't cause issues with current Winter + * code that may be defining attributes with the same name as a relation method, as the method must specifically + * define a return type of a Relation class in order to qualify as a relation. + */ + protected function returnsRelation(\ReflectionMethod $method): ?string + { + $returnType = $method->getReturnType(); + + if ($returnType === null || $returnType instanceof \ReflectionNamedType === false) { + return null; + } + + if (!is_subclass_of($returnType->getName(), 'Illuminate\Database\Eloquent\Relations\Relation')) { + return null; + } + + return $returnType->getName(); + } } diff --git a/tests/Database/Fixtures/Author.php b/tests/Database/Fixtures/Author.php new file mode 100644 index 000000000..f0c0efce1 --- /dev/null +++ b/tests/Database/Fixtures/Author.php @@ -0,0 +1,94 @@ + ['Database\Tester\Models\User', 'delete' => true], + 'country' => ['Database\Tester\Models\Country'], + 'user_soft' => ['Database\Tester\Models\SoftDeleteUser', 'key' => 'user_id', 'softDelete' => true], + ]; + + public $hasMany = [ + 'posts' => Post::class, + ]; + + public $hasOne = [ + 'phone' => 'Database\Tester\Models\Phone', + ]; + + public $belongsToMany = [ + 'roles' => [ + 'Database\Tester\Models\Role', + 'table' => 'database_tester_authors_roles' + ], + 'executive_authors' => [ + 'Database\Tester\Models\Role', + 'table' => 'database_tester_authors_roles', + 'conditions' => 'is_executive = 1' + ], + ]; + + public $morphMany = [ + 'event_log' => ['Database\Tester\Models\EventLog', 'name' => 'related', 'delete' => true, 'softDelete' => true], + ]; + + public $morphOne = [ + 'meta' => ['Database\Tester\Models\Meta', 'name' => 'taggable'], + ]; + + public $morphToMany = [ + 'tags' => [ + 'Database\Tester\Models\Tag', + 'name' => 'taggable', + 'table' => 'database_tester_taggables', + 'pivot' => ['added_by'] + ], + ]; + + public static function migrateUp(Builder $builder): void + { + if ($builder->hasTable('database_tester_authors')) { + return; + } + + $builder->create('database_tester_authors', function ($table) { + $table->engine = 'InnoDB'; + $table->increments('id'); + $table->integer('user_id')->unsigned()->index()->nullable(); + $table->integer('country_id')->unsigned()->index()->nullable(); + $table->string('name')->nullable(); + $table->string('email')->nullable(); + $table->softDeletes(); + $table->timestamps(); + }); + } + + public static function migrateDown(Builder $builder): void + { + if (!$builder->hasTable('database_tester_authors')) { + return; + } + + $builder->dropIfExists('database_tester_authors'); + } +} diff --git a/tests/Database/Fixtures/Category.php b/tests/Database/Fixtures/Category.php new file mode 100644 index 000000000..223ed5dd9 --- /dev/null +++ b/tests/Database/Fixtures/Category.php @@ -0,0 +1,58 @@ + [ + Post::class, + 'table' => 'database_tester_categories_posts', + 'pivot' => ['category_name', 'post_name'] + ] + ]; + + public function getCustomNameAttribute() + { + return $this->name.' (#'.$this->id.')'; + } + + public static function migrateUp(Builder $builder): void + { + if ($builder->hasTable('database_tester_categories')) { + return; + } + + $builder->create('database_tester_categories', function ($table) { + $table->engine = 'InnoDB'; + $table->increments('id'); + $table->integer('parent_id')->nullable(); + $table->string('name')->nullable(); + $table->string('slug')->nullable()->index()->unique(); + $table->string('description')->nullable(); + $table->integer('company_id')->unsigned()->nullable(); + $table->string('language', 3)->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public static function migrateDown(Builder $builder): void + { + if (!$builder->hasTable('database_tester_categories')) { + return; + } + + $builder->drop('database_tester_categories'); + } +} diff --git a/tests/Database/Fixtures/CategoryNested.php b/tests/Database/Fixtures/CategoryNested.php new file mode 100644 index 000000000..0a568201a --- /dev/null +++ b/tests/Database/Fixtures/CategoryNested.php @@ -0,0 +1,47 @@ +hasTable('database_tester_categories_nested')) { + return; + } + + $builder->create('database_tester_categories_nested', function ($table) { + $table->engine = 'InnoDB'; + $table->increments('id'); + $table->integer('parent_id')->nullable(); + $table->integer('nest_left')->nullable(); + $table->integer('nest_right')->nullable(); + $table->integer('nest_depth')->nullable(); + $table->string('name')->nullable(); + $table->string('slug')->nullable()->index()->unique(); + $table->string('description')->nullable(); + $table->integer('company_id')->unsigned()->nullable(); + $table->string('language', 3)->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public static function migrateDown(Builder $builder): void + { + if (!$builder->hasTable('database_tester_categories_nested')) { + return; + } + + $builder->dropIfExists('database_tester_categories_nested'); + } +} diff --git a/tests/Database/Fixtures/CategorySimple.php b/tests/Database/Fixtures/CategorySimple.php new file mode 100644 index 000000000..bad509796 --- /dev/null +++ b/tests/Database/Fixtures/CategorySimple.php @@ -0,0 +1,8 @@ + + * @copyright Winter CMS + */ +trait MigratesForTest +{ + /** + * Store the models that have been migrated. + */ + public static $migrated = false; + + /** + * Migrate the schema up for the model. + */ + public static function migrateUp(Builder $builder): void + { + } + + /** + * Migrate the schema down for the model. + */ + public static function migrateDown(Builder $builder): void + { + } +} diff --git a/tests/Database/Fixtures/Post.php b/tests/Database/Fixtures/Post.php new file mode 100644 index 000000000..2b6f7ea43 --- /dev/null +++ b/tests/Database/Fixtures/Post.php @@ -0,0 +1,98 @@ + Author::class, + ]; + + public $belongsToMany = [ + 'categories' => [ + 'Database\Tester\Models\Category', + 'table' => 'database_tester_categories_posts', + 'pivot' => ['category_name', 'post_name'] + ] + ]; + + public $morphMany = [ + 'event_log' => ['Database\Tester\Models\EventLog', 'name' => 'related', 'delete' => true, 'softDelete' => true], + ]; + + public $morphOne = [ + 'meta' => ['Database\Tester\Models\Meta', 'name' => 'taggable'], + ]; + + public $morphToMany = [ + 'tags' => [ + 'Database\Tester\Models\Tag', + 'name' => 'taggable', + 'table' => 'database_tester_taggables', + 'pivot' => ['added_by'] + ], + ]; + + /** + * @return \Winter\Storm\Database\Relations\BelongsTo + */ + public function writer(): BelongsTo + { + return $this->belongsTo(Author::class, 'author_id'); + } + + public static function migrateUp(Builder $builder): void + { + if ($builder->hasTable('database_tester_posts')) { + return; + } + + $builder->create('database_tester_posts', function ($table) { + $table->engine = 'InnoDB'; + $table->increments('id'); + $table->string('title')->nullable(); + $table->string('slug')->nullable()->index(); + $table->text('long_slug')->nullable(); + $table->text('description')->nullable(); + $table->boolean('is_published')->default(false); + $table->timestamp('published_at')->nullable(); + $table->integer('author_id')->unsigned()->index()->nullable(); + $table->string('author_nickname')->default('Winter')->nullable(); + $table->softDeletes(); + $table->timestamps(); + }); + } + + public static function migrateDown(Builder $builder): void + { + if (!$builder->hasTable('database_tester_posts')) { + return; + } + + $builder->dropIfExists('database_tester_posts'); + } +} diff --git a/tests/Database/Relations/BelongsToTest.php b/tests/Database/Relations/BelongsToTest.php new file mode 100644 index 000000000..614eaa0b0 --- /dev/null +++ b/tests/Database/Relations/BelongsToTest.php @@ -0,0 +1,179 @@ + 'First post', 'description' => 'Yay!!']); + $author1 = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $author2 = Author::create(['name' => 'Louie', 'email' => 'louie@example.com']); + $author3 = Author::make(['name' => 'Charlie', 'email' => 'charlie@example.com']); + Model::reguard(); + + // Set by Model object + $post->author = $author1; + $this->assertEquals($author1->id, $post->author_id); + $this->assertEquals('Stevie', $post->author->name); + + // Set by primary key + $post->author = $author2->id; + $this->assertEquals($author2->id, $post->author_id); + $this->assertEquals('Louie', $post->author->name); + + // Nullify + $post->author = null; + $this->assertNull($post->author_id); + $this->assertNull($post->author); + + // Deferred in memory + $post->author = $author3; + $this->assertEquals('Charlie', $post->author->name); + $this->assertNull($post->author_id); + $author3->save(); + $this->assertEquals($author3->id, $post->author_id); + } + + /** + * Tests a belongsTo relation being specified with the Laravel format - ie. a public method that returns a relation + * instance. + */ + public function testSetRelationValueLaravelRelation() + { + Model::unguard(); + $post = Post::create(['title' => 'First post', 'description' => 'Yay!!']); + $author1 = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $author2 = Author::create(['name' => 'Louie', 'email' => 'louie@example.com']); + $author3 = Author::make(['name' => 'Charlie', 'email' => 'charlie@example.com']); + Model::reguard(); + + // Set by Model object + $post->writer = $author1; + $this->assertEquals($author1->id, $post->author_id); + $this->assertEquals('Stevie', $post->writer->name); + + // Set by primary key + $post->writer = $author2->id; + $this->assertEquals($author2->id, $post->author_id); + $this->assertEquals('Louie', $post->writer->name); + + // Nullify + $post->writer = null; + $this->assertNull($post->author_id); + $this->assertNull($post->writer); + + // Deferred in memory + $post->writer = $author3; + $this->assertEquals('Charlie', $post->writer->name); + $this->assertNull($post->author_id); + $author3->save(); + $this->assertEquals($author3->id, $post->author_id); + } + + public function testGetRelationValue() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $post = Post::make(['title' => "First post", 'author_id' => $author->id]); + Model::reguard(); + + $this->assertEquals($author->id, $post->getRelationValue('author')); + } + + public function testGetRelationValueLaravelRelation() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $post = Post::make(['title' => "First post", 'author_id' => $author->id]); + Model::reguard(); + + $this->assertEquals($author->id, $post->getRelationValue('writer')); + } + + public function testDeferredBinding() + { + $sessionKey = uniqid('session_key', true); + + Model::unguard(); + $post = Post::make(['title' => "First post"]); + $author = Author::create(['name' => 'Stevie']); + Model::reguard(); + + // Deferred add + $post->author()->add($author, $sessionKey); + $this->assertNull($post->author_id); + $this->assertNull($post->author); + + $this->assertEquals(0, $post->author()->count()); + $this->assertEquals(1, $post->author()->withDeferred($sessionKey)->count()); + + // Commit deferred + $post->save(null, $sessionKey); + $this->assertEquals(1, $post->author()->count()); + $this->assertEquals($author->id, $post->author_id); + $this->assertEquals('Stevie', $post->author->name); + + // New session + $sessionKey = uniqid('session_key', true); + + // Deferred remove + $post->author()->remove($author, $sessionKey); + $this->assertEquals(1, $post->author()->count()); + $this->assertEquals(0, $post->author()->withDeferred($sessionKey)->count()); + $this->assertEquals($author->id, $post->author_id); + $this->assertEquals('Stevie', $post->author->name); + + // Commit deferred + $post->save(null, $sessionKey); + $this->assertEquals(0, $post->author()->count()); + $this->assertNull($post->author_id); + $this->assertNull($post->author); + } + + public function testDeferredBindingLaravelRelation() + { + $sessionKey = uniqid('session_key', true); + + Model::unguard(); + $post = Post::make(['title' => "First post"]); + $author = Author::create(['name' => 'Stevie']); + Model::reguard(); + + // Deferred add + $post->writer()->add($author, $sessionKey); + $this->assertNull($post->author_id); + $this->assertNull($post->writer); + + $this->assertEquals(0, $post->writer()->count()); + $this->assertEquals(1, $post->writer()->withDeferred($sessionKey)->count()); + + // Commit deferred + $post->save(null, $sessionKey); + $this->assertEquals(1, $post->writer()->count()); + $this->assertEquals($author->id, $post->author_id); + $this->assertEquals('Stevie', $post->writer->name); + + // New session + $sessionKey = uniqid('session_key', true); + + // Deferred remove + $post->writer()->remove($author, $sessionKey); + $this->assertEquals(1, $post->writer()->count()); + $this->assertEquals(0, $post->writer()->withDeferred($sessionKey)->count()); + $this->assertEquals($author->id, $post->author_id); + $this->assertEquals('Stevie', $post->writer->name); + + // Commit deferred + $post->save(null, $sessionKey); + $this->assertEquals(0, $post->writer()->count()); + $this->assertNull($post->author_id); + $this->assertNull($post->writer); + } +} diff --git a/tests/DbTestCase.php b/tests/DbTestCase.php index 3698e4bfc..9d05a48ba 100644 --- a/tests/DbTestCase.php +++ b/tests/DbTestCase.php @@ -1,14 +1,34 @@ make($config, 'testing'); DB::setDefaultConnection('testing'); + Artisan::call('migrate', ['--database' => 'testing', '--path' => '../../../../src/Database/Migrations']); - Model::setEventDispatcher(new Dispatcher()); + Model::setEventDispatcher($this->modelDispatcher()); } public function tearDown(): void { $this->flushModelEventListeners(); + $this->rollbackModels(); + Artisan::call('migrate:rollback', ['--database' => 'testing', '--path' => '../../../../src/Database/Migrations']); parent::tearDown(); } + /** + * Creates a dispatcher for the model events. + */ + protected function modelDispatcher(): Dispatcher + { + $dispatcher = new Dispatcher(); + + $callback = function ($eventName, $params) { + if (!str_starts_with($eventName, 'eloquent.booted')) { + return; + } + + $model = $params[0]; + + if (!in_array('Winter\Storm\Tests\Database\Fixtures\MigratesForTest', class_uses_recursive($model))) { + return; + } + + if ($model::$migrated === false) { + $model::migrateUp($this->getBuilder()); + $model::$migrated = true; + $this->migratedModels[] = $model; + } + }; + + $dispatcher->listen('*', $callback); + + return $dispatcher; + } + /** * Returns an instance of the schema builder for the test database. - * - * @return \Illuminate\Database\Schema\Builder */ - protected function getBuilder() + protected function getBuilder(): Builder { return DB::connection()->getSchemaBuilder(); } + /** + * Rolls back all migrated models. + * + * This should be fired in the teardown process. + */ + protected function rollbackModels(): void + { + foreach ($this->migratedModels as $model) { + $model::migrateDown($this->getBuilder()); + $model::$migrated = false; + } + + $this->migratedModels = []; + } + /** * The models in Winter use a static property to store their events, these * will need to be targeted and reset ready for a new test cycle. diff --git a/tests/TestCase.php b/tests/TestCase.php index 6a59eba7d..53a58f40b 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -1,5 +1,8 @@ Date: Mon, 19 Feb 2024 22:27:07 +0800 Subject: [PATCH 02/59] Discard filesystem test case. We shouldn't test completely third-party code. --- tests/FilesystemAdapterTest.php | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 tests/FilesystemAdapterTest.php diff --git a/tests/FilesystemAdapterTest.php b/tests/FilesystemAdapterTest.php deleted file mode 100644 index 8dbe8ec8a..000000000 --- a/tests/FilesystemAdapterTest.php +++ /dev/null @@ -1,17 +0,0 @@ -expectException(RuntimeException::class); - - $adapter = new LocalFilesystemAdapter('/tmp/app'); - (new FilesystemAdapter(new Flysystem($adapter), $adapter)) - ->temporaryUrl('test.jpg', \Carbon\Carbon::now()->addMinutes(5)); - } -} From f2c68cefd6b059d2d9b472024fce2dbe8b64dd69 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Mon, 19 Feb 2024 23:18:19 +0800 Subject: [PATCH 03/59] Add BelongsToMany, HasMany and HasOne tests --- tests/Database/Fixtures/Author.php | 36 +- tests/Database/Fixtures/EventLog.php | 60 +++ tests/Database/Fixtures/NullablePost.php | 20 + tests/Database/Fixtures/Phone.php | 57 +++ tests/Database/Fixtures/Post.php | 10 + tests/Database/Fixtures/RevisionablePost.php | 53 +++ tests/Database/Fixtures/Role.php | 79 ++++ tests/Database/Fixtures/SluggablePost.php | 21 + tests/Database/Fixtures/SoftDeleteAuthor.php | 8 + tests/Database/Fixtures/Tag.php | 72 ++++ tests/Database/Fixtures/User.php | 70 ++++ tests/Database/Fixtures/ValidateablePost.php | 18 + .../Database/Relations/BelongsToManyTest.php | 372 ++++++++++++++++++ tests/Database/Relations/HasManyTest.php | 220 +++++++++++ tests/Database/Relations/HasOneTest.php | 245 ++++++++++++ 15 files changed, 1332 insertions(+), 9 deletions(-) create mode 100644 tests/Database/Fixtures/EventLog.php create mode 100644 tests/Database/Fixtures/NullablePost.php create mode 100644 tests/Database/Fixtures/Phone.php create mode 100644 tests/Database/Fixtures/RevisionablePost.php create mode 100644 tests/Database/Fixtures/Role.php create mode 100644 tests/Database/Fixtures/SluggablePost.php create mode 100644 tests/Database/Fixtures/SoftDeleteAuthor.php create mode 100644 tests/Database/Fixtures/Tag.php create mode 100644 tests/Database/Fixtures/User.php create mode 100644 tests/Database/Fixtures/ValidateablePost.php create mode 100644 tests/Database/Relations/BelongsToManyTest.php create mode 100644 tests/Database/Relations/HasManyTest.php create mode 100644 tests/Database/Relations/HasOneTest.php diff --git a/tests/Database/Fixtures/Author.php b/tests/Database/Fixtures/Author.php index f0c0efce1..160e1596f 100644 --- a/tests/Database/Fixtures/Author.php +++ b/tests/Database/Fixtures/Author.php @@ -4,6 +4,9 @@ use Illuminate\Database\Schema\Builder; use Winter\Storm\Database\Model; +use Winter\Storm\Database\Relations\BelongsToMany; +use Winter\Storm\Database\Relations\HasMany; +use Winter\Storm\Database\Relations\HasOne; class Author extends Model { @@ -33,23 +36,18 @@ class Author extends Model ]; public $hasOne = [ - 'phone' => 'Database\Tester\Models\Phone', + 'phone' => Phone::class, ]; public $belongsToMany = [ 'roles' => [ - 'Database\Tester\Models\Role', + 'Winter\Storm\Tests\Database\Fixtures\Role', 'table' => 'database_tester_authors_roles' ], - 'executive_authors' => [ - 'Database\Tester\Models\Role', - 'table' => 'database_tester_authors_roles', - 'conditions' => 'is_executive = 1' - ], ]; public $morphMany = [ - 'event_log' => ['Database\Tester\Models\EventLog', 'name' => 'related', 'delete' => true, 'softDelete' => true], + 'event_log' => [EventLog::class, 'name' => 'related', 'delete' => true, 'softDelete' => true], ]; public $morphOne = [ @@ -58,13 +56,33 @@ class Author extends Model public $morphToMany = [ 'tags' => [ - 'Database\Tester\Models\Tag', + Tag::class, 'name' => 'taggable', 'table' => 'database_tester_taggables', 'pivot' => ['added_by'] ], ]; + public function contactNumber(): HasOne + { + return $this->hasOne(Phone::class); + } + + public function messages(): HasMany + { + return $this->hasMany(Post::class); + } + + public function scopes(): BelongsToMany + { + return $this->belongsToMany(Role::class, 'database_tester_authors_roles'); + } + + public function executiveAuthors(): BelongsToMany + { + return $this->belongsToMany(Role::class, 'database_tester_authors_roles')->wherePivot('is_executive', 1); + } + public static function migrateUp(Builder $builder): void { if ($builder->hasTable('database_tester_authors')) { diff --git a/tests/Database/Fixtures/EventLog.php b/tests/Database/Fixtures/EventLog.php new file mode 100644 index 000000000..24e0450a2 --- /dev/null +++ b/tests/Database/Fixtures/EventLog.php @@ -0,0 +1,60 @@ + [] + ]; + + public static function migrateUp(Builder $builder): void + { + if ($builder->hasTable('database_tester_event_log')) { + return; + } + + $builder->create('database_tester_event_log', function ($table) { + $table->engine = 'InnoDB'; + $table->increments('id'); + $table->string('action', 30)->nullable(); + $table->string('related_id')->index()->nullable(); + $table->string('related_type')->index()->nullable(); + $table->softDeletes(); + $table->timestamps(); + }); + } + + public static function migrateDown(Builder $builder): void + { + if (!$builder->hasTable('database_tester_event_log')) { + return; + } + + $builder->dropIfExists('database_tester_event_log'); + } +} diff --git a/tests/Database/Fixtures/NullablePost.php b/tests/Database/Fixtures/NullablePost.php new file mode 100644 index 000000000..bd9a8b1e8 --- /dev/null +++ b/tests/Database/Fixtures/NullablePost.php @@ -0,0 +1,20 @@ + Author::class, + ]; + + public static function migrateUp(Builder $builder): void + { + if ($builder->hasTable('database_tester_phones')) { + return; + } + + $builder->create('database_tester_phones', function ($table) { + $table->engine = 'InnoDB'; + $table->increments('id'); + $table->string('number')->nullable(); + $table->integer('author_id')->unsigned()->index()->nullable(); + $table->timestamps(); + }); + } + + public static function migrateDown(Builder $builder): void + { + if (!$builder->hasTable('database_tester_phones')) { + return; + } + + $builder->dropIfExists('database_tester_phones'); + } +} diff --git a/tests/Database/Fixtures/Post.php b/tests/Database/Fixtures/Post.php index 2b6f7ea43..d3d456c5a 100644 --- a/tests/Database/Fixtures/Post.php +++ b/tests/Database/Fixtures/Post.php @@ -85,6 +85,15 @@ public static function migrateUp(Builder $builder): void $table->softDeletes(); $table->timestamps(); }); + + $builder->create('database_tester_categories_posts', function ($table) { + $table->engine = 'InnoDB'; + $table->integer('category_id')->unsigned(); + $table->integer('post_id')->unsigned(); + $table->primary(['category_id', 'post_id']); + $table->string('category_name')->nullable(); + $table->string('post_name')->nullable(); + }); } public static function migrateDown(Builder $builder): void @@ -93,6 +102,7 @@ public static function migrateDown(Builder $builder): void return; } + $builder->dropIfExists('database_tester_categories_posts'); $builder->dropIfExists('database_tester_posts'); } } diff --git a/tests/Database/Fixtures/RevisionablePost.php b/tests/Database/Fixtures/RevisionablePost.php new file mode 100644 index 000000000..2d0dc7056 --- /dev/null +++ b/tests/Database/Fixtures/RevisionablePost.php @@ -0,0 +1,53 @@ + [Revision::class, 'name' => 'revisionable'] + ]; + + /** + * The user who made the revision. + */ + public function getRevisionableUser() + { + return 7; + } +} diff --git a/tests/Database/Fixtures/Role.php b/tests/Database/Fixtures/Role.php new file mode 100644 index 000000000..6487ad5a5 --- /dev/null +++ b/tests/Database/Fixtures/Role.php @@ -0,0 +1,79 @@ + [ + User::class, + 'table' => 'database_tester_authors_roles' + ], + ]; + + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'database_tester_authors_roles'); + } + + public static function migrateUp(Builder $builder): void + { + if ($builder->hasTable('database_tester_roles')) { + return; + } + + $builder->create('database_tester_roles', function ($table) { + $table->engine = 'InnoDB'; + $table->increments('id'); + $table->string('name')->nullable(); + $table->text('description')->nullable(); + $table->timestamps(); + }); + + $builder->create('database_tester_authors_roles', function ($table) { + $table->engine = 'InnoDB'; + $table->integer('author_id')->unsigned(); + $table->integer('role_id')->unsigned(); + $table->primary(['author_id', 'role_id']); + $table->string('clearance_level')->nullable(); + $table->boolean('is_executive')->default(false); + }); + } + + public static function migrateDown(Builder $builder): void + { + if (!$builder->hasTable('database_tester_roles')) { + return; + } + + $builder->dropIfExists('database_tester_roles'); + $builder->dropIfExists('database_tester_authors_roles'); + } +} diff --git a/tests/Database/Fixtures/SluggablePost.php b/tests/Database/Fixtures/SluggablePost.php new file mode 100644 index 000000000..b3387b955 --- /dev/null +++ b/tests/Database/Fixtures/SluggablePost.php @@ -0,0 +1,21 @@ + 'title', + 'long_slug' => ['title', 'description'] + ]; +} diff --git a/tests/Database/Fixtures/SoftDeleteAuthor.php b/tests/Database/Fixtures/SoftDeleteAuthor.php new file mode 100644 index 000000000..88c51dce0 --- /dev/null +++ b/tests/Database/Fixtures/SoftDeleteAuthor.php @@ -0,0 +1,8 @@ + [ + Author::class, + 'name' => 'taggable', + 'table' => 'database_tester_taggables', + 'pivot' => ['added_by'], + ], + 'posts' => [ + Post::class, + 'name' => 'taggable', + 'table' => 'database_tester_taggables', + 'pivot' => ['added_by'], + ], + ]; + + public static function migrateUp(Builder $builder): void + { + if ($builder->hasTable('database_tester_tags')) { + return; + } + + $builder->create('database_tester_tags', function ($table) { + $table->engine = 'InnoDB'; + $table->increments('id'); + $table->string('name'); + $table->timestamps(); + }); + + $builder->create('database_tester_taggables', function ($table) { + $table->engine = 'InnoDB'; + $table->unsignedInteger('tag_id'); + $table->morphs('taggable', 'testings_taggable'); + $table->unsignedInteger('added_by')->nullable(); + }); + } + + public static function migrateDown(Builder $builder): void + { + if (!$builder->hasTable('database_tester_tags')) { + return; + } + + $builder->dropIfExists('database_tester_taggables'); + $builder->dropIfExists('database_tester_tags'); + } +} diff --git a/tests/Database/Fixtures/User.php b/tests/Database/Fixtures/User.php new file mode 100644 index 000000000..1333d2235 --- /dev/null +++ b/tests/Database/Fixtures/User.php @@ -0,0 +1,70 @@ + [ + Author::class, + ] + ]; + + public $hasOneThrough = [ + 'phone' => [ + Phone::class, + 'through' => Author::class, + ], + ]; + + public $attachOne = [ + 'avatar' => 'System\Models\File' + ]; + + public $attachMany = [ + 'photos' => 'System\Models\File' + ]; + + public static function migrateUp(Builder $builder): void + { + if ($builder->hasTable('database_tester_users')) { + return; + } + + $builder->create('database_tester_users', function ($table) { + $table->engine = 'InnoDB'; + $table->increments('id'); + $table->string('name')->nullable(); + $table->string('email')->nullable(); + $table->softDeletes(); + $table->timestamps(); + }); + } + + public static function migrateDown(Builder $builder): void + { + if (!$builder->hasTable('database_tester_users')) { + return; + } + + $builder->dropIfExists('database_tester_users'); + } +} diff --git a/tests/Database/Fixtures/ValidateablePost.php b/tests/Database/Fixtures/ValidateablePost.php new file mode 100644 index 000000000..cee4a49ce --- /dev/null +++ b/tests/Database/Fixtures/ValidateablePost.php @@ -0,0 +1,18 @@ + 'required|min:3|max:255', + 'slug' => ['required', 'regex:/^[a-z0-9\/\:_\-\*\[\]\+\?\|]*$/i', 'unique:database_tester_posts'], + ]; +} diff --git a/tests/Database/Relations/BelongsToManyTest.php b/tests/Database/Relations/BelongsToManyTest.php new file mode 100644 index 000000000..f149f54ca --- /dev/null +++ b/tests/Database/Relations/BelongsToManyTest.php @@ -0,0 +1,372 @@ + 'Stevie', 'email' => 'stevie@example.com']); + $role1 = Role::create(['name' => "Designer", 'description' => "Quality"]); + $role2 = Role::create(['name' => "Programmer", 'description' => "Speed"]); + $role3 = Role::create(['name' => "Manager", 'description' => "Budget"]); + Model::reguard(); + + // Add/remove to collection + $this->assertFalse($author->roles->contains($role1->id)); + $author->roles()->add($role1); + $author->roles()->add($role2); + $this->assertTrue($author->roles->contains($role1->id)); + $this->assertTrue($author->roles->contains($role2->id)); + + // Set by Model object + $author->roles = $role1; + $this->assertEquals(1, $author->roles->count()); + $this->assertEquals('Designer', $author->roles->first()->name); + + $author->roles = [$role1, $role2, $role3]; + $this->assertEquals(3, $author->roles->count()); + + // Set by primary key + $author->roles = $role2->id; + $this->assertEquals(1, $author->roles->count()); + $this->assertEquals('Programmer', $author->roles->first()->name); + + $author->roles = [$role2->id, $role3->id]; + $this->assertEquals(2, $author->roles->count()); + + // Nullify + $author->roles = null; + $this->assertEquals(0, $author->roles->count()); + + // Extra nullify checks (still exists in DB until saved) + $author->reloadRelations('roles'); + $this->assertEquals(2, $author->roles->count()); + $author->save(); + $author->reloadRelations('roles'); + $this->assertEquals(0, $author->roles->count()); + + // Deferred in memory + $author->roles = [$role2->id, $role3->id]; + $this->assertEquals(2, $author->roles->count()); + $this->assertEquals('Programmer', $author->roles->first()->name); + } + + public function testSetRelationValueLaravelRelation() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $role1 = Role::create(['name' => "Designer", 'description' => "Quality"]); + $role2 = Role::create(['name' => "Programmer", 'description' => "Speed"]); + $role3 = Role::create(['name' => "Manager", 'description' => "Budget"]); + Model::reguard(); + + // Add/remove to collection + $this->assertFalse($author->scopes->contains($role1->id)); + $author->scopes()->add($role1); + $author->scopes()->add($role2); + $this->assertTrue($author->scopes->contains($role1->id)); + $this->assertTrue($author->scopes->contains($role2->id)); + + // Set by Model object + $author->scopes = $role1; + $this->assertEquals(1, $author->scopes->count()); + $this->assertEquals('Designer', $author->scopes->first()->name); + + $author->scopes = [$role1, $role2, $role3]; + $this->assertEquals(3, $author->scopes->count()); + + // Set by primary key + $author->scopes = $role2->id; + $this->assertEquals(1, $author->scopes->count()); + $this->assertEquals('Programmer', $author->scopes->first()->name); + + $author->scopes = [$role2->id, $role3->id]; + $this->assertEquals(2, $author->scopes->count()); + + // Nullify + $author->scopes = null; + $this->assertEquals(0, $author->scopes->count()); + + // Extra nullify checks (still exists in DB until saved) + $author->reloadRelations('scopes'); + $this->assertEquals(2, $author->scopes->count()); + $author->save(); + $author->reloadRelations('scopes'); + $this->assertEquals(0, $author->scopes->count()); + + // Deferred in memory + $author->scopes = [$role2->id, $role3->id]; + $this->assertEquals(2, $author->scopes->count()); + $this->assertEquals('Programmer', $author->scopes->first()->name); + } + + public function testGetRelationValue() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $role1 = Role::create(['name' => "Designer", 'description' => "Quality"]); + $role2 = Role::create(['name' => "Programmer", 'description' => "Speed"]); + Model::reguard(); + + $author->roles()->add($role1); + $author->roles()->add($role2); + + $this->assertEquals([$role1->id, $role2->id], $author->getRelationValue('roles')); + } + + public function testGetRelationValueLaravelRelation() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $role1 = Role::create(['name' => "Designer", 'description' => "Quality"]); + $role2 = Role::create(['name' => "Programmer", 'description' => "Speed"]); + Model::reguard(); + + $author->scopes()->add($role1); + $author->scopes()->add($role2); + + $this->assertEquals([$role1->id, $role2->id], $author->getRelationValue('scopes')); + } + + public function testDeferredBinding() + { + $sessionKey = uniqid('session_key', true); + + Model::unguard(); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $role1 = Role::create(['name' => "Designer", 'description' => "Quality"]); + $role2 = Role::create(['name' => "Programmer", 'description' => "Speed"]); + + $category = Category::create(['name' => 'News']); + $post1 = Post::create(['title' => 'First post']); + $post2 = Post::create(['title' => 'Second post']); + Model::reguard(); + + // Deferred add + $author->roles()->add($role1, $sessionKey); + $author->roles()->add($role2, $sessionKey); + $category->posts()->add($post1, $sessionKey, [ + 'category_name' => $category->name . ' in pivot', + 'post_name' => $post1->title . ' in pivot', + ]); + $category->posts()->add($post2, $sessionKey, [ + 'category_name' => $category->name . ' in pivot', + 'post_name' => $post2->title . ' in pivot', + ]); + $this->assertEmpty($author->roles); + $this->assertEmpty($category->posts); + + $this->assertEquals(0, $author->roles()->count()); + $this->assertEquals(2, $author->roles()->withDeferred($sessionKey)->count()); + $this->assertEquals(0, $category->posts()->count()); + $this->assertEquals(2, $category->posts()->withDeferred($sessionKey)->count()); + + // Get simple value (implicit) + $author->reloadRelations(); + $author->sessionKey = $sessionKey; + $this->assertEquals([$role1->id, $role2->id], $author->getRelationValue('roles')); + $category->reloadRelations(); + $category->sessionKey = $sessionKey; + $this->assertEquals([$post1->id, $post2->id], $category->getRelationValue('posts')); + + // Get simple value (explicit) + $relatedIds = $author->roles()->allRelatedIds($sessionKey)->all(); + $this->assertEquals([$role1->id, $role2->id], $relatedIds); + $relatedIds = $category->posts()->allRelatedIds($sessionKey)->all(); + $this->assertEquals([$post1->id, $post2->id], $relatedIds); + + // Commit deferred + $author->save(null, $sessionKey); + $category->save(null, $sessionKey); + $this->assertEquals(2, $author->roles()->count()); + $this->assertEquals('Designer', $author->roles->first()->name); + $this->assertEquals(2, $category->posts()->count()); + $this->assertEquals('First post', $category->posts->first()->title); + $this->assertEquals('Second post', $category->posts->last()->title); + $this->assertEquals('First post in pivot', $category->posts->first()->pivot->post_name); + $this->assertEquals('Second post in pivot', $category->posts->last()->pivot->post_name); + $this->assertEquals('News in pivot', $category->posts->first()->pivot->category_name); + $this->assertEquals('News in pivot', $category->posts->last()->pivot->category_name); + + // New session + $sessionKey = uniqid('session_key', true); + + // Deferred remove + $author->roles()->remove($role1, $sessionKey); + $author->roles()->remove($role2, $sessionKey); + $category->posts()->remove($post1, $sessionKey); + $category->posts()->remove($post2, $sessionKey); + $this->assertEquals(2, $author->roles()->count()); + $this->assertEquals(0, $author->roles()->withDeferred($sessionKey)->count()); + $this->assertEquals(2, $category->posts()->count()); + $this->assertEquals(0, $category->posts()->withDeferred($sessionKey)->count()); + $this->assertEquals('Designer', $author->roles->first()->name); + $this->assertEquals('First post', $category->posts->first()->title); + $this->assertEquals('Second post', $category->posts->last()->title); + $this->assertEquals('First post in pivot', $category->posts->first()->pivot->post_name); + $this->assertEquals('Second post in pivot', $category->posts->last()->pivot->post_name); + $this->assertEquals('News in pivot', $category->posts->first()->pivot->category_name); + $this->assertEquals('News in pivot', $category->posts->last()->pivot->category_name); + + // Commit deferred + $author->save(null, $sessionKey); + $category->save(null, $sessionKey); + $this->assertEquals(0, $author->roles()->count()); + $this->assertEquals(0, $author->roles->count()); + $this->assertEquals(0, $category->posts()->count()); + $this->assertEquals(0, $category->posts->count()); + } + + public function testDeferredBindingLaravelRelation() + { + $sessionKey = uniqid('session_key', true); + + Model::unguard(); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $role1 = Role::create(['name' => "Designer", 'description' => "Quality"]); + $role2 = Role::create(['name' => "Programmer", 'description' => "Speed"]); + + $category = Category::create(['name' => 'News']); + $post1 = Post::create(['title' => 'First post']); + $post2 = Post::create(['title' => 'Second post']); + Model::reguard(); + + // Deferred add + $author->scopes()->add($role1, $sessionKey); + $author->scopes()->add($role2, $sessionKey); + $category->posts()->add($post1, $sessionKey, [ + 'category_name' => $category->name . ' in pivot', + 'post_name' => $post1->title . ' in pivot', + ]); + $category->posts()->add($post2, $sessionKey, [ + 'category_name' => $category->name . ' in pivot', + 'post_name' => $post2->title . ' in pivot', + ]); + $this->assertEmpty($author->scopes); + $this->assertEmpty($category->posts); + + $this->assertEquals(0, $author->scopes()->count()); + $this->assertEquals(2, $author->scopes()->withDeferred($sessionKey)->count()); + $this->assertEquals(0, $category->posts()->count()); + $this->assertEquals(2, $category->posts()->withDeferred($sessionKey)->count()); + + // Get simple value (implicit) + $author->reloadRelations(); + $author->sessionKey = $sessionKey; + $this->assertEquals([$role1->id, $role2->id], $author->getRelationValue('scopes')); + $category->reloadRelations(); + $category->sessionKey = $sessionKey; + $this->assertEquals([$post1->id, $post2->id], $category->getRelationValue('posts')); + + // Get simple value (explicit) + $relatedIds = $author->scopes()->allRelatedIds($sessionKey)->all(); + $this->assertEquals([$role1->id, $role2->id], $relatedIds); + $relatedIds = $category->posts()->allRelatedIds($sessionKey)->all(); + $this->assertEquals([$post1->id, $post2->id], $relatedIds); + + // Commit deferred + $author->save(null, $sessionKey); + $category->save(null, $sessionKey); + $this->assertEquals(2, $author->scopes()->count()); + $this->assertEquals('Designer', $author->scopes->first()->name); + $this->assertEquals(2, $category->posts()->count()); + $this->assertEquals('First post', $category->posts->first()->title); + $this->assertEquals('Second post', $category->posts->last()->title); + $this->assertEquals('First post in pivot', $category->posts->first()->pivot->post_name); + $this->assertEquals('Second post in pivot', $category->posts->last()->pivot->post_name); + $this->assertEquals('News in pivot', $category->posts->first()->pivot->category_name); + $this->assertEquals('News in pivot', $category->posts->last()->pivot->category_name); + + // New session + $sessionKey = uniqid('session_key', true); + + // Deferred remove + $author->scopes()->remove($role1, $sessionKey); + $author->scopes()->remove($role2, $sessionKey); + $category->posts()->remove($post1, $sessionKey); + $category->posts()->remove($post2, $sessionKey); + $this->assertEquals(2, $author->scopes()->count()); + $this->assertEquals(0, $author->scopes()->withDeferred($sessionKey)->count()); + $this->assertEquals(2, $category->posts()->count()); + $this->assertEquals(0, $category->posts()->withDeferred($sessionKey)->count()); + $this->assertEquals('Designer', $author->scopes->first()->name); + $this->assertEquals('First post', $category->posts->first()->title); + $this->assertEquals('Second post', $category->posts->last()->title); + $this->assertEquals('First post in pivot', $category->posts->first()->pivot->post_name); + $this->assertEquals('Second post in pivot', $category->posts->last()->pivot->post_name); + $this->assertEquals('News in pivot', $category->posts->first()->pivot->category_name); + $this->assertEquals('News in pivot', $category->posts->last()->pivot->category_name); + + // Commit deferred + $author->save(null, $sessionKey); + $category->save(null, $sessionKey); + $this->assertEquals(0, $author->scopes()->count()); + $this->assertEquals(0, $author->scopes->count()); + $this->assertEquals(0, $category->posts()->count()); + $this->assertEquals(0, $category->posts->count()); + } + + public function testDetachAfterDelete() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $role1 = Role::create(['name' => "Designer", 'description' => "Quality"]); + $role2 = Role::create(['name' => "Programmer", 'description' => "Speed"]); + $role3 = Role::create(['name' => "Manager", 'description' => "Budget"]); + Model::reguard(); + + $author->roles()->add($role1); + $author->roles()->add($role2); + $author->roles()->add($role3); + $this->assertEquals(3, DB::table('database_tester_authors_roles')->where('author_id', $author->id)->count()); + + $author->delete(); + $this->assertEquals(0, DB::table('database_tester_authors_roles')->where('author_id', $author->id)->count()); + } + + public function testDetachAfterDeleteLaravelRelation() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $role1 = Role::create(['name' => "Designer", 'description' => "Quality"]); + $role2 = Role::create(['name' => "Programmer", 'description' => "Speed"]); + $role3 = Role::create(['name' => "Manager", 'description' => "Budget"]); + Model::reguard(); + + $author->scopes()->add($role1); + $author->scopes()->add($role2); + $author->scopes()->add($role3); + $this->assertEquals(3, DB::table('database_tester_authors_roles')->where('author_id', $author->id)->count()); + + $author->delete(); + $this->assertEquals(0, DB::table('database_tester_authors_roles')->where('author_id', $author->id)->count()); + } + + public function testConditionsWithPivotAttributes() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $role1 = Role::create(['name' => "Designer", 'description' => "Quality"]); + $role2 = Role::create(['name' => "Programmer", 'description' => "Speed"]); + $role3 = Role::create(['name' => "Manager", 'description' => "Budget"]); + Model::reguard(); + + $author->roles()->add($role1, null, ['is_executive' => 1]); + $author->roles()->add($role2, null, ['is_executive' => 1]); + $author->roles()->add($role3, null, ['is_executive' => 0]); + + $this->assertEquals([1, 2], $author->executiveAuthors->lists('id')); + $this->assertEquals([1, 2], $author->executiveAuthors()->lists('id')); + $this->assertEquals([1, 2], $author->executiveAuthors()->get()->lists('id')); + } +} diff --git a/tests/Database/Relations/HasManyTest.php b/tests/Database/Relations/HasManyTest.php new file mode 100644 index 000000000..564684f2e --- /dev/null +++ b/tests/Database/Relations/HasManyTest.php @@ -0,0 +1,220 @@ + 'Stevie', 'email' => 'stevie@example.com']); + $post1 = Post::create(['title' => "First post", 'description' => "Yay!!"]); + $post2 = Post::create(['title' => "Second post", 'description' => "Woohoo!!"]); + $post3 = Post::create(['title' => "Third post", 'description' => "Yipiee!!"]); + $post4 = Post::make(['title' => "Fourth post", 'description' => "Hooray!!"]); + Model::reguard(); + + // Set by Model object + $author->posts = new Collection([$post1, $post2]); + $author->save(); + $this->assertEquals($author->id, $post1->author_id); + $this->assertEquals($author->id, $post2->author_id); + $this->assertEquals([ + 'First post', + 'Second post' + ], $author->posts->lists('title')); + + // Set by primary key + $postId = $post3->id; + $author->posts = $postId; + $author->save(); + $post3 = Post::find($postId); + $this->assertEquals($author->id, $post3->author_id); + $this->assertEquals([ + 'Third post' + ], $author->posts->lists('title')); + + // Nullify + $author->posts = null; + $author->save(); + $post3 = Post::find($postId); + $this->assertNull($post3->author_id); + $this->assertNull($post3->author); + + // Deferred in memory + $author->posts = $post4; + $this->assertEquals($author->id, $post4->author_id); + $this->assertEquals([ + 'Fourth post' + ], $author->posts->lists('title')); + } + + public function testSetRelationValueLaravelRelation() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $post1 = Post::create(['title' => "First post", 'description' => "Yay!!"]); + $post2 = Post::create(['title' => "Second post", 'description' => "Woohoo!!"]); + $post3 = Post::create(['title' => "Third post", 'description' => "Yipiee!!"]); + $post4 = Post::make(['title' => "Fourth post", 'description' => "Hooray!!"]); + Model::reguard(); + + // Set by Model object + $author->messages = new Collection([$post1, $post2]); + $author->save(); + $this->assertEquals($author->id, $post1->author_id); + $this->assertEquals($author->id, $post2->author_id); + $this->assertEquals([ + 'First post', + 'Second post' + ], $author->messages->lists('title')); + + // Set by primary key + $postId = $post3->id; + $author->messages = $postId; + $author->save(); + $post3 = Post::find($postId); + $this->assertEquals($author->id, $post3->author_id); + $this->assertEquals([ + 'Third post' + ], $author->messages->lists('title')); + + // Nullify + $author->messages = null; + $author->save(); + $post3 = Post::find($postId); + $this->assertNull($post3->author_id); + $this->assertNull($post3->author); + + // Deferred in memory + $author->messages = $post4; + $this->assertEquals($author->id, $post4->author_id); + $this->assertEquals([ + 'Fourth post' + ], $author->messages->lists('title')); + } + + public function testGetRelationValue() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $post1 = Post::create(['title' => "First post", 'author_id' => $author->id]); + $post2 = Post::create(['title' => "Second post", 'author_id' => $author->id]); + Model::reguard(); + + $this->assertEquals([$post1->id, $post2->id], $author->getRelationValue('posts')); + } + + public function testGetRelationValueLaravelRelation() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $post1 = Post::create(['title' => "First post", 'author_id' => $author->id]); + $post2 = Post::create(['title' => "Second post", 'author_id' => $author->id]); + Model::reguard(); + + $this->assertEquals([$post1->id, $post2->id], $author->getRelationValue('messages')); + } + + public function testDeferredBinding() + { + $sessionKey = uniqid('session_key', true); + + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $post = Post::create(['title' => "First post", 'description' => "Yay!!"]); + Model::reguard(); + + $postId = $post->id; + + // Deferred add + $author->posts()->add($post, $sessionKey); + $this->assertNull($post->author_id); + $this->assertEmpty($author->posts); + + $this->assertEquals(0, $author->posts()->count()); + $this->assertEquals(1, $author->posts()->withDeferred($sessionKey)->count()); + + // Commit deferred + $author->save(null, $sessionKey); + $post = Post::find($postId); + $this->assertEquals(1, $author->posts()->count()); + $this->assertEquals($author->id, $post->author_id); + $this->assertEquals([ + 'First post' + ], $author->posts->lists('title')); + + // New session + $sessionKey = uniqid('session_key', true); + + // Deferred remove + $author->posts()->remove($post, $sessionKey); + $this->assertEquals(1, $author->posts()->count()); + $this->assertEquals(0, $author->posts()->withDeferred($sessionKey)->count()); + $this->assertEquals($author->id, $post->author_id); + $this->assertEquals([ + 'First post' + ], $author->posts->lists('title')); + + // Commit deferred + $author->save(null, $sessionKey); + $post = Post::find($postId); + $this->assertEquals(0, $author->posts()->count()); + $this->assertNull($post->author_id); + $this->assertEmpty($author->posts); + } + + public function testDeferredBindingLaravelRelation() + { + $sessionKey = uniqid('session_key', true); + + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $post = Post::create(['title' => "First post", 'description' => "Yay!!"]); + Model::reguard(); + + $postId = $post->id; + + // Deferred add + $author->messages()->add($post, $sessionKey); + $this->assertNull($post->author_id); + $this->assertEmpty($author->messages); + + $this->assertEquals(0, $author->messages()->count()); + $this->assertEquals(1, $author->messages()->withDeferred($sessionKey)->count()); + + // Commit deferred + $author->save(null, $sessionKey); + $post = Post::find($postId); + $this->assertEquals(1, $author->messages()->count()); + $this->assertEquals($author->id, $post->author_id); + $this->assertEquals([ + 'First post' + ], $author->messages->lists('title')); + + // New session + $sessionKey = uniqid('session_key', true); + + // Deferred remove + $author->messages()->remove($post, $sessionKey); + $this->assertEquals(1, $author->messages()->count()); + $this->assertEquals(0, $author->messages()->withDeferred($sessionKey)->count()); + $this->assertEquals($author->id, $post->author_id); + $this->assertEquals([ + 'First post' + ], $author->messages->lists('title')); + + // Commit deferred + $author->save(null, $sessionKey); + $post = Post::find($postId); + $this->assertEquals(0, $author->messages()->count()); + $this->assertNull($post->author_id); + $this->assertEmpty($author->messages); + } +} diff --git a/tests/Database/Relations/HasOneTest.php b/tests/Database/Relations/HasOneTest.php new file mode 100644 index 000000000..fe76b67a4 --- /dev/null +++ b/tests/Database/Relations/HasOneTest.php @@ -0,0 +1,245 @@ + 'Stevie', 'email' => 'stevie@example.com']); + $phone1 = Phone::create(['number' => '0404040404']); + $phone2 = Phone::create(['number' => '0505050505']); + $phone3 = Phone::make(['number' => '0606060606']); + Model::reguard(); + + // Set by Model object + $author->phone = $phone1; + $author->save(); + $this->assertEquals($author->id, $phone1->author_id); + $this->assertEquals('0404040404', $author->phone->number); + + // Double check + $phone1 = Phone::find($phone1->id); + $this->assertEquals($author->id, $phone1->author_id); + + // Set by primary key + $phoneId = $phone2->id; + $author->phone = $phoneId; + $author->save(); + $phone2 = Phone::find($phoneId); + $this->assertEquals($author->id, $phone2->author_id); + $this->assertEquals('0505050505', $author->phone->number); + + // Ensure relationship is "stolen" from first model + $phone1 = Phone::find($phone1->id); + $this->assertNotEquals($author->id, $phone1->author_id); + + // Nullify + $author->phone = null; + $author->save(); + $phone2 = Phone::find($phoneId); + $this->assertNull($phone2->author_id); + $this->assertNull($phone2->author); + + // Deferred in memory + $author->phone = $phone3; + $this->assertEquals('0606060606', $author->phone->number); + $this->assertEquals($author->id, $phone3->author_id); + } + + public function testSetRelationValueLaravelRelation() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $phone1 = Phone::create(['number' => '0404040404']); + $phone2 = Phone::create(['number' => '0505050505']); + $phone3 = Phone::make(['number' => '0606060606']); + Model::reguard(); + + // Set by Model object + $author->contactNumber = $phone1; + $author->save(); + $this->assertEquals($author->id, $phone1->author_id); + $this->assertEquals('0404040404', $author->contactNumber->number); + + // Double check + $phone1 = Phone::find($phone1->id); + $this->assertEquals($author->id, $phone1->author_id); + + // Set by primary key + $phoneId = $phone2->id; + $author->contactNumber = $phoneId; + $author->save(); + $phone2 = Phone::find($phoneId); + $this->assertEquals($author->id, $phone2->author_id); + $this->assertEquals('0505050505', $author->contactNumber->number); + + // Ensure relationship is "stolen" from first model + $phone1 = Phone::find($phone1->id); + $this->assertNotEquals($author->id, $phone1->author_id); + + // Nullify + $author->contactNumber = null; + $author->save(); + $phone2 = Phone::find($phoneId); + $this->assertNull($phone2->author_id); + $this->assertNull($phone2->author); + + // Deferred in memory + $author->contactNumber = $phone3; + $this->assertEquals('0606060606', $author->contactNumber->number); + $this->assertEquals($author->id, $phone3->author_id); + } + + public function testSetRelationValueTwice() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $phone = Phone::create(['number' => '0505050505']); + Model::reguard(); + + $phoneId = $phone->id; + $author->phone = $phoneId; + $author->save(); + + $author->phone = $phoneId; + $author->save(); + + $phone = Phone::find($phoneId); + $this->assertEquals($author->id, $phone->author_id); + $this->assertEquals('0505050505', $author->phone->number); + } + + public function testSetRelationValueTwiceLaravelRelation() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $phone = Phone::create(['number' => '0505050505']); + Model::reguard(); + + $phoneId = $phone->id; + $author->contactNumber = $phoneId; + $author->save(); + + $author->contactNumber = $phoneId; + $author->save(); + + $phone = Phone::find($phoneId); + $this->assertEquals($author->id, $phone->author_id); + $this->assertEquals('0505050505', $author->contactNumber->number); + } + + public function testGetRelationValue() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $phone = Phone::create(['number' => '0404040404', 'author_id' => $author->id]); + Model::reguard(); + + $this->assertEquals($phone->id, $author->getRelationValue('phone')); + } + + public function testGetRelationValueLaravelRelation() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $phone = Phone::create(['number' => '0404040404', 'author_id' => $author->id]); + Model::reguard(); + + $this->assertEquals($phone->id, $author->getRelationValue('contactNumber')); + } + + public function testDeferredBinding() + { + $sessionKey = uniqid('session_key', true); + + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $phone = Phone::create(['number' => '0404040404']); + Model::reguard(); + + $phoneId = $phone->id; + + // Deferred add + $author->phone()->add($phone, $sessionKey); + $this->assertNull($phone->author_id); + $this->assertNull($author->phone); + + $this->assertEquals(0, $author->phone()->count()); + $this->assertEquals(1, $author->phone()->withDeferred($sessionKey)->count()); + + // Commit deferred + $author->save(null, $sessionKey); + $phone = Phone::find($phoneId); + $this->assertEquals(1, $author->phone()->count()); + $this->assertEquals($author->id, $phone->author_id); + $this->assertEquals('0404040404', $author->phone->number); + + // New session + $sessionKey = uniqid('session_key', true); + + // Deferred remove + $author->phone()->remove($phone, $sessionKey); + $this->assertEquals(1, $author->phone()->count()); + $this->assertEquals(0, $author->phone()->withDeferred($sessionKey)->count()); + $this->assertEquals($author->id, $phone->author_id); + $this->assertEquals('0404040404', $author->phone->number); + + // Commit deferred + $author->save(null, $sessionKey); + $phone = Phone::find($phoneId); + $this->assertEquals(0, $author->phone()->count()); + $this->assertNull($phone->author_id); + $this->assertNull($author->phone); + } + + public function testDeferredBindingLaravelRelation() + { + $sessionKey = uniqid('session_key', true); + + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $phone = Phone::create(['number' => '0404040404']); + Model::reguard(); + + $phoneId = $phone->id; + + // Deferred add + $author->contactNumber()->add($phone, $sessionKey); + $this->assertNull($phone->author_id); + $this->assertNull($author->contactNumber); + + $this->assertEquals(0, $author->contactNumber()->count()); + $this->assertEquals(1, $author->contactNumber()->withDeferred($sessionKey)->count()); + + // Commit deferred + $author->save(null, $sessionKey); + $phone = Phone::find($phoneId); + $this->assertEquals(1, $author->contactNumber()->count()); + $this->assertEquals($author->id, $phone->author_id); + $this->assertEquals('0404040404', $author->contactNumber->number); + + // New session + $sessionKey = uniqid('session_key', true); + + // Deferred remove + $author->contactNumber()->remove($phone, $sessionKey); + $this->assertEquals(1, $author->contactNumber()->count()); + $this->assertEquals(0, $author->contactNumber()->withDeferred($sessionKey)->count()); + $this->assertEquals($author->id, $phone->author_id); + $this->assertEquals('0404040404', $author->contactNumber->number); + + // Commit deferred + $author->save(null, $sessionKey); + $phone = Phone::find($phoneId); + $this->assertEquals(0, $author->contactNumber()->count()); + $this->assertNull($phone->author_id); + $this->assertNull($author->contactNumber); + } +} From 49949fa88afacc5d68792e19fd55a4f76103e792 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Thu, 22 Feb 2024 10:45:47 +0800 Subject: [PATCH 04/59] Remove unneeded relation class. We no longer extend this class, and it doesn't seem to provide any functionality anyway. --- src/Database/Relations/Relation.php | 16 ---------------- src/Support/aliases.php | 1 - 2 files changed, 17 deletions(-) delete mode 100644 src/Database/Relations/Relation.php diff --git a/src/Database/Relations/Relation.php b/src/Database/Relations/Relation.php deleted file mode 100644 index a8c9913a7..000000000 --- a/src/Database/Relations/Relation.php +++ /dev/null @@ -1,16 +0,0 @@ - 'App\Post', - * 'videos' => 'App\Video', - * ]); - * - */ -abstract class Relation extends RelationBase -{ -} diff --git a/src/Support/aliases.php b/src/Support/aliases.php index 2a2ac773f..04521f06d 100644 --- a/src/Support/aliases.php +++ b/src/Support/aliases.php @@ -155,7 +155,6 @@ class_alias(\Winter\Storm\Database\Relations\MorphOne::class, \October\Rain\Data class_alias(\Winter\Storm\Database\Relations\Concerns\MorphOneOrMany::class, \October\Rain\Database\Relations\MorphOneOrMany::class); class_alias(\Winter\Storm\Database\Relations\MorphTo::class, \October\Rain\Database\Relations\MorphTo::class); class_alias(\Winter\Storm\Database\Relations\MorphToMany::class, \October\Rain\Database\Relations\MorphToMany::class); -class_alias(\Winter\Storm\Database\Relations\Relation::class, \October\Rain\Database\Relations\Relation::class); class_alias(\Winter\Storm\Database\Schema\Blueprint::class, \October\Rain\Database\Schema\Blueprint::class); class_alias(\Winter\Storm\Database\SortableScope::class, \October\Rain\Database\SortableScope::class); class_alias(\Winter\Storm\Database\Traits\DeferredBinding::class, \October\Rain\Database\Traits\DeferredBinding::class); From 80a853a3f3b40522173862dfe99959a9bc20dac7 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Thu, 22 Feb 2024 13:07:17 +0800 Subject: [PATCH 05/59] Add HasManyThrough, HasOneThrough, MorphMany and MorphOne tests --- tests/Database/Fixtures/Author.php | 20 +- tests/Database/Fixtures/Country.php | 64 ++++ tests/Database/Fixtures/Meta.php | 59 +++ tests/Database/Fixtures/Post.php | 17 +- tests/Database/Fixtures/SoftDeleteCountry.php | 8 + tests/Database/Fixtures/SoftDeleteUser.php | 8 + tests/Database/Fixtures/User.php | 6 + .../Database/Relations/HasManyThroughTest.php | 81 ++++ .../Database/Relations/HasOneThroughTest.php | 52 +++ tests/Database/Relations/MorphManyTest.php | 310 ++++++++++++++++ tests/Database/Relations/MorphOneTest.php | 348 ++++++++++++++++++ 11 files changed, 962 insertions(+), 11 deletions(-) create mode 100644 tests/Database/Fixtures/Country.php create mode 100644 tests/Database/Fixtures/Meta.php create mode 100644 tests/Database/Fixtures/SoftDeleteCountry.php create mode 100644 tests/Database/Fixtures/SoftDeleteUser.php create mode 100644 tests/Database/Relations/HasManyThroughTest.php create mode 100644 tests/Database/Relations/HasOneThroughTest.php create mode 100644 tests/Database/Relations/MorphManyTest.php create mode 100644 tests/Database/Relations/MorphOneTest.php diff --git a/tests/Database/Fixtures/Author.php b/tests/Database/Fixtures/Author.php index 160e1596f..59ab67cce 100644 --- a/tests/Database/Fixtures/Author.php +++ b/tests/Database/Fixtures/Author.php @@ -7,6 +7,8 @@ use Winter\Storm\Database\Relations\BelongsToMany; use Winter\Storm\Database\Relations\HasMany; use Winter\Storm\Database\Relations\HasOne; +use Winter\Storm\Database\Relations\MorphMany; +use Winter\Storm\Database\Relations\MorphOne; class Author extends Model { @@ -26,9 +28,9 @@ class Author extends Model * @var array Relations */ public $belongsTo = [ - 'user' => ['Database\Tester\Models\User', 'delete' => true], - 'country' => ['Database\Tester\Models\Country'], - 'user_soft' => ['Database\Tester\Models\SoftDeleteUser', 'key' => 'user_id', 'softDelete' => true], + 'user' => [User::class, 'delete' => true], + 'country' => Country::class, + 'user_soft' => [SoftDeleteUser::class, 'key' => 'user_id', 'softDelete' => true], ]; public $hasMany = [ @@ -51,7 +53,7 @@ class Author extends Model ]; public $morphOne = [ - 'meta' => ['Database\Tester\Models\Meta', 'name' => 'taggable'], + 'meta' => [Meta::class, 'name' => 'taggable'], ]; public $morphToMany = [ @@ -83,6 +85,16 @@ public function executiveAuthors(): BelongsToMany return $this->belongsToMany(Role::class, 'database_tester_authors_roles')->wherePivot('is_executive', 1); } + public function info(): MorphOne + { + return $this->morphOne(Meta::class, 'taggable'); + } + + public function auditLogs(): MorphMany + { + return $this->morphMany(EventLog::class, 'related'); + } + public static function migrateUp(Builder $builder): void { if ($builder->hasTable('database_tester_authors')) { diff --git a/tests/Database/Fixtures/Country.php b/tests/Database/Fixtures/Country.php new file mode 100644 index 000000000..9dd7ac6da --- /dev/null +++ b/tests/Database/Fixtures/Country.php @@ -0,0 +1,64 @@ + [ + User::class, + ], + ]; + + public $hasManyThrough = [ + 'posts' => [ + Post::class, + 'through' => Author::class, + ] + ]; + + public function messages(): HasManyThrough + { + return $this->hasManyThrough(Post::class, Author::class); + } + + public static function migrateUp(Builder $builder): void + { + if ($builder->hasTable('database_tester_countries')) { + return; + } + + $builder->create('database_tester_countries', function ($table) { + $table->engine = 'InnoDB'; + $table->increments('id'); + $table->string('name')->nullable(); + $table->softDeletes(); + $table->timestamps(); + }); + } + + public static function migrateDown(Builder $builder): void + { + if (!$builder->hasTable('database_tester_countries')) { + return; + } + + $builder->dropIfExists('database_tester_countries'); + } +} diff --git a/tests/Database/Fixtures/Meta.php b/tests/Database/Fixtures/Meta.php new file mode 100644 index 000000000..789d355b6 --- /dev/null +++ b/tests/Database/Fixtures/Meta.php @@ -0,0 +1,59 @@ + [] + ]; + + public $fillable = [ + 'meta_title', + 'meta_description', + 'meta_keywords', + 'canonical_url', + 'redirect_url', + 'robot_index', + 'robot_follow' + ]; + + public static function migrateUp(Builder $builder): void + { + if ($builder->hasTable('database_tester_meta')) { + return; + } + + $builder->create('database_tester_meta', function ($table) { + $table->engine = 'InnoDB'; + $table->increments('id')->unsigned(); + $table->integer('taggable_id')->unsigned()->index()->nullable(); + $table->string('taggable_type')->nullable(); + $table->string('meta_title')->nullable(); + $table->string('meta_description')->nullable(); + $table->string('meta_keywords')->nullable(); + $table->string('canonical_url')->nullable(); + $table->string('redirect_url')->nullable(); + $table->string('robot_index')->nullable(); + $table->string('robot_follow')->nullable(); + }); + } + + public static function migrateDown(Builder $builder): void + { + if (!$builder->hasTable('database_tester_meta')) { + return; + } + + $builder->dropIfExists('database_tester_meta'); + } +} diff --git a/tests/Database/Fixtures/Post.php b/tests/Database/Fixtures/Post.php index d3d456c5a..d26398f1b 100644 --- a/tests/Database/Fixtures/Post.php +++ b/tests/Database/Fixtures/Post.php @@ -5,6 +5,7 @@ use Illuminate\Database\Schema\Builder; use Winter\Storm\Database\Model; use Winter\Storm\Database\Relations\BelongsTo; +use Winter\Storm\Database\Relations\MorphOne; class Post extends Model { @@ -34,37 +35,39 @@ class Post extends Model public $belongsToMany = [ 'categories' => [ - 'Database\Tester\Models\Category', + Category::class, 'table' => 'database_tester_categories_posts', 'pivot' => ['category_name', 'post_name'] ] ]; public $morphMany = [ - 'event_log' => ['Database\Tester\Models\EventLog', 'name' => 'related', 'delete' => true, 'softDelete' => true], + 'event_log' => [EventLog::class, 'name' => 'related', 'delete' => true, 'softDelete' => true], ]; public $morphOne = [ - 'meta' => ['Database\Tester\Models\Meta', 'name' => 'taggable'], + 'meta' => [Meta::class, 'name' => 'taggable'], ]; public $morphToMany = [ 'tags' => [ - 'Database\Tester\Models\Tag', + Tag::class, 'name' => 'taggable', 'table' => 'database_tester_taggables', 'pivot' => ['added_by'] ], ]; - /** - * @return \Winter\Storm\Database\Relations\BelongsTo - */ public function writer(): BelongsTo { return $this->belongsTo(Author::class, 'author_id'); } + public function info(): MorphOne + { + return $this->morphOne(Meta::class, 'taggable'); + } + public static function migrateUp(Builder $builder): void { if ($builder->hasTable('database_tester_posts')) { diff --git a/tests/Database/Fixtures/SoftDeleteCountry.php b/tests/Database/Fixtures/SoftDeleteCountry.php new file mode 100644 index 000000000..8a63046ad --- /dev/null +++ b/tests/Database/Fixtures/SoftDeleteCountry.php @@ -0,0 +1,8 @@ + 'System\Models\File' ]; + public function contactNumber(): HasOneThrough + { + return $this->hasOneThrough(Phone::class, Author::class); + } + public static function migrateUp(Builder $builder): void { if ($builder->hasTable('database_tester_users')) { diff --git a/tests/Database/Relations/HasManyThroughTest.php b/tests/Database/Relations/HasManyThroughTest.php new file mode 100644 index 000000000..a73879b9a --- /dev/null +++ b/tests/Database/Relations/HasManyThroughTest.php @@ -0,0 +1,81 @@ + 'Australia']); + $author1 = Author::create(['name' => 'Stevie', 'email' => 'stevie@email.tld']); + $author2 = Author::create(['name' => 'Louie', 'email' => 'louie@email.tld']); + $post1 = Post::create(['title' => "First post", 'description' => "Yay!!"]); + $post2 = Post::create(['title' => "Second post", 'description' => "Woohoo!!"]); + $post3 = Post::create(['title' => "Third post", 'description' => "Yipiee!!"]); + $post4 = Post::make(['title' => "Fourth post", 'description' => "Hooray!!"]); + Model::reguard(); + + // Set data + $author1->country = $country; + $author2->country = $country; + + $author1->posts = new Collection([$post1, $post2]); + $author2->posts = new Collection([$post3, $post4]); + + $author1->save(); + $author2->save(); + + $country = Country::with([ + 'posts' + ])->find($country->id); + + $this->assertEquals([ + $post1->id, + $post2->id, + $post3->id, + $post4->id + ], $country->posts->pluck('id')->toArray()); + } + + public function testGetLaravelRelation() + { + Model::unguard(); + $country = Country::create(['name' => 'Australia']); + $author1 = Author::create(['name' => 'Stevie', 'email' => 'stevie@email.tld']); + $author2 = Author::create(['name' => 'Louie', 'email' => 'louie@email.tld']); + $post1 = Post::create(['title' => "First post", 'description' => "Yay!!"]); + $post2 = Post::create(['title' => "Second post", 'description' => "Woohoo!!"]); + $post3 = Post::create(['title' => "Third post", 'description' => "Yipiee!!"]); + $post4 = Post::make(['title' => "Fourth post", 'description' => "Hooray!!"]); + Model::reguard(); + + // Set data + $author1->country = $country; + $author2->country = $country; + + $author1->messages = new Collection([$post1, $post2]); + $author2->messages = new Collection([$post3, $post4]); + + $author1->save(); + $author2->save(); + + $country = Country::with([ + 'messages' + ])->find($country->id); + + $this->assertEquals([ + $post1->id, + $post2->id, + $post3->id, + $post4->id + ], $country->messages->pluck('id')->toArray()); + } +} diff --git a/tests/Database/Relations/HasOneThroughTest.php b/tests/Database/Relations/HasOneThroughTest.php new file mode 100644 index 000000000..d27c680fa --- /dev/null +++ b/tests/Database/Relations/HasOneThroughTest.php @@ -0,0 +1,52 @@ + '08 1234 5678']); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@email.tld']); + $user = User::create(['name' => 'Stevie', 'email' => 'stevie@email.tld']); + Model::reguard(); + + // Set data + $author->phone = $phone; + $author->user = $user; + $author->save(); + + $user = User::with([ + 'phone' + ])->find($user->id); + + $this->assertEquals($phone->id, $user->phone->id); + } + + public function testGetLaravelRelation() + { + Model::unguard(); + $phone = Phone::create(['number' => '08 1234 5678']); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@email.tld']); + $user = User::create(['name' => 'Stevie', 'email' => 'stevie@email.tld']); + Model::reguard(); + + // Set data + $author->phone = $phone; + $author->user = $user; + $author->save(); + + $user = User::with([ + 'contactNumber' + ])->find($user->id); + + $this->assertEquals($phone->id, $user->contactNumber->id); + } +} diff --git a/tests/Database/Relations/MorphManyTest.php b/tests/Database/Relations/MorphManyTest.php new file mode 100644 index 000000000..b0bb8d492 --- /dev/null +++ b/tests/Database/Relations/MorphManyTest.php @@ -0,0 +1,310 @@ + 'Stevie', 'email' => 'stevie@example.com']); + $event1 = EventLog::create(['action' => "user-created"]); + $event2 = EventLog::create(['action' => "user-updated"]); + $event3 = EventLog::create(['action' => "user-deleted"]); + $event4 = EventLog::make(['action' => "user-restored"]); + Model::reguard(); + + // Set by Model object + $author->event_log = new Collection([$event1, $event2]); + $author->save(); + $this->assertEquals($author->id, $event1->related_id); + $this->assertEquals($author->id, $event2->related_id); + $this->assertEquals('Winter\Storm\Tests\Database\Fixtures\Author', $event1->related_type); + $this->assertEquals('Winter\Storm\Tests\Database\Fixtures\Author', $event2->related_type); + $this->assertEquals([ + 'user-created', + 'user-updated' + ], $author->event_log->lists('action')); + + // Set by primary key + $eventId = $event3->id; + $author->event_log = $eventId; + $author->save(); + $event3 = EventLog::find($eventId); + $this->assertEquals($author->id, $event3->related_id); + $this->assertEquals('Winter\Storm\Tests\Database\Fixtures\Author', $event3->related_type); + $this->assertEquals([ + 'user-deleted' + ], $author->event_log->lists('action')); + + // Nullify + $author->event_log = null; + $author->save(); + $event3 = EventLog::find($eventId); + $this->assertNull($event3->related_type); + $this->assertNull($event3->related_id); + $this->assertNull($event3->related); + + // Deferred in memory + $author->event_log = $event4; + $this->assertEquals($author->id, $event4->related_id); + $this->assertEquals('Winter\Storm\Tests\Database\Fixtures\Author', $event4->related_type); + $this->assertEquals([ + 'user-restored' + ], $author->event_log->lists('action')); + } + + public function testSetRelationValueLaravelRelation() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $event1 = EventLog::create(['action' => "user-created"]); + $event2 = EventLog::create(['action' => "user-updated"]); + $event3 = EventLog::create(['action' => "user-deleted"]); + $event4 = EventLog::make(['action' => "user-restored"]); + Model::reguard(); + + // Set by Model object + $author->auditLogs = new Collection([$event1, $event2]); + $author->save(); + $this->assertEquals($author->id, $event1->related_id); + $this->assertEquals($author->id, $event2->related_id); + $this->assertEquals('Winter\Storm\Tests\Database\Fixtures\Author', $event1->related_type); + $this->assertEquals('Winter\Storm\Tests\Database\Fixtures\Author', $event2->related_type); + $this->assertEquals([ + 'user-created', + 'user-updated' + ], $author->auditLogs->lists('action')); + + // Set by primary key + $eventId = $event3->id; + $author->auditLogs = $eventId; + $author->save(); + $event3 = EventLog::find($eventId); + $this->assertEquals($author->id, $event3->related_id); + $this->assertEquals('Winter\Storm\Tests\Database\Fixtures\Author', $event3->related_type); + $this->assertEquals([ + 'user-deleted' + ], $author->auditLogs->lists('action')); + + // Nullify + $author->auditLogs = null; + $author->save(); + $event3 = EventLog::find($eventId); + $this->assertNull($event3->related_type); + $this->assertNull($event3->related_id); + $this->assertNull($event3->related); + + // Deferred in memory + $author->auditLogs = $event4; + $this->assertEquals($author->id, $event4->related_id); + $this->assertEquals('Winter\Storm\Tests\Database\Fixtures\Author', $event4->related_type); + $this->assertEquals([ + 'user-restored' + ], $author->auditLogs->lists('action')); + } + + public function testGetRelationValue() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $event1 = EventLog::create(['action' => "user-created", 'related_id' => $author->id, 'related_type' => 'Winter\Storm\Tests\Database\Fixtures\Author']); + $event2 = EventLog::create(['action' => "user-updated", 'related_id' => $author->id, 'related_type' => 'Winter\Storm\Tests\Database\Fixtures\Author']); + Model::reguard(); + + $this->assertEquals([$event1->id, $event2->id], $author->getRelationValue('event_log')); + } + + public function testGetRelationValueLaravelRelation() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $event1 = EventLog::create(['action' => "user-created", 'related_id' => $author->id, 'related_type' => 'Winter\Storm\Tests\Database\Fixtures\Author']); + $event2 = EventLog::create(['action' => "user-updated", 'related_id' => $author->id, 'related_type' => 'Winter\Storm\Tests\Database\Fixtures\Author']); + Model::reguard(); + + $this->assertEquals([$event1->id, $event2->id], $author->getRelationValue('auditLogs')); + } + + public function testDeferredBinding() + { + $sessionKey = uniqid('session_key', true); + + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $event = EventLog::create(['action' => "user-created"]); + $post = Post::create(['title' => 'Hello world!']); + $tagForAuthor = Tag::create(['name' => 'ForAuthor']); + $tagForPost = Tag::create(['name' => 'ForPost']); + Model::reguard(); + + $eventId = $event->id; + + // Deferred add + $author->event_log()->add($event, $sessionKey); + $this->assertNull($event->related_id); + $this->assertEmpty($author->event_log); + + $this->assertEquals(0, $author->event_log()->count()); + $this->assertEquals(1, $author->event_log()->withDeferred($sessionKey)->count()); + + $author->tags()->add($tagForAuthor, $sessionKey, ['added_by' => 99]); + $this->assertEmpty($author->tags); + + $this->assertEquals(0, $author->tags()->count()); + $this->assertEquals(1, $author->tags()->withDeferred($sessionKey)->count()); + + $tagForPost->posts()->add($post, $sessionKey, ['added_by' => 88]); + $this->assertEmpty($tagForPost->posts); + + $this->assertEquals(0, $tagForPost->posts()->count()); + $this->assertEquals(1, $tagForPost->posts()->withDeferred($sessionKey)->count()); + + // Commit deferred + $author->save(null, $sessionKey); + $event = EventLog::find($eventId); + $this->assertEquals(1, $author->event_log()->count()); + $this->assertEquals($author->id, $event->related_id); + $this->assertEquals([ + 'user-created' + ], $author->event_log->lists('action')); + + $this->assertEquals(1, $author->tags()->count()); + $this->assertEquals([$tagForAuthor->id], $author->tags->lists('id')); + $this->assertEquals(99, $author->tags->first()->pivot->added_by); + + $tagForPost->save(null, $sessionKey); + $this->assertEquals(1, $tagForPost->posts()->count()); + $this->assertEquals([$post->id], $tagForPost->posts->lists('id')); + $this->assertEquals(88, $tagForPost->posts->first()->pivot->added_by); + + // New session + $sessionKey = uniqid('session_key', true); + + // Deferred remove + $author->event_log()->remove($event, $sessionKey); + $this->assertEquals(1, $author->event_log()->count()); + $this->assertEquals(0, $author->event_log()->withDeferred($sessionKey)->count()); + $this->assertEquals($author->id, $event->related_id); + $this->assertEquals([ + 'user-created' + ], $author->event_log->lists('action')); + + $author->tags()->remove($tagForAuthor, $sessionKey); + $this->assertEquals(1, $author->tags()->count()); + $this->assertEquals(0, $author->tags()->withDeferred($sessionKey)->count()); + $this->assertEquals([$tagForAuthor->id], $author->tags->lists('id')); + $this->assertEquals(99, $author->tags->first()->pivot->added_by); + + $tagForPost->posts()->remove($post, $sessionKey); + $this->assertEquals(1, $tagForPost->posts()->count()); + $this->assertEquals(0, $tagForPost->posts()->withDeferred($sessionKey)->count()); + $this->assertEquals([$post->id], $tagForPost->posts->lists('id')); + $this->assertEquals(88, $tagForPost->posts->first()->pivot->added_by); + + // Commit deferred (model is deleted as per definition) + $author->save(null, $sessionKey); + $event = EventLog::find($eventId); + $this->assertEquals(0, $author->event_log()->count()); + $this->assertNull($event); + $this->assertEmpty($author->event_log); + + $this->assertEmpty(0, $author->tags); + $this->assertEmpty(0, $tagForPost->posts); + } + + public function testDeferredBindingLaravelRelation() + { + $sessionKey = uniqid('session_key', true); + + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $event = EventLog::create(['action' => "user-created"]); + $post = Post::create(['title' => 'Hello world!']); + $tagForAuthor = Tag::create(['name' => 'ForAuthor']); + $tagForPost = Tag::create(['name' => 'ForPost']); + Model::reguard(); + + $eventId = $event->id; + + // Deferred add + $author->auditLogs()->add($event, $sessionKey); + $this->assertNull($event->related_id); + $this->assertEmpty($author->auditLogs); + + $this->assertEquals(0, $author->auditLogs()->count()); + $this->assertEquals(1, $author->auditLogs()->withDeferred($sessionKey)->count()); + + $author->tags()->add($tagForAuthor, $sessionKey, ['added_by' => 99]); + $this->assertEmpty($author->tags); + + $this->assertEquals(0, $author->tags()->count()); + $this->assertEquals(1, $author->tags()->withDeferred($sessionKey)->count()); + + $tagForPost->posts()->add($post, $sessionKey, ['added_by' => 88]); + $this->assertEmpty($tagForPost->posts); + + $this->assertEquals(0, $tagForPost->posts()->count()); + $this->assertEquals(1, $tagForPost->posts()->withDeferred($sessionKey)->count()); + + // Commit deferred + $author->save(null, $sessionKey); + $event = EventLog::find($eventId); + $this->assertEquals(1, $author->auditLogs()->count()); + $this->assertEquals($author->id, $event->related_id); + $this->assertEquals([ + 'user-created' + ], $author->auditLogs->lists('action')); + + $this->assertEquals(1, $author->tags()->count()); + $this->assertEquals([$tagForAuthor->id], $author->tags->lists('id')); + $this->assertEquals(99, $author->tags->first()->pivot->added_by); + + $tagForPost->save(null, $sessionKey); + $this->assertEquals(1, $tagForPost->posts()->count()); + $this->assertEquals([$post->id], $tagForPost->posts->lists('id')); + $this->assertEquals(88, $tagForPost->posts->first()->pivot->added_by); + + // New session + $sessionKey = uniqid('session_key', true); + + // Deferred remove + $author->auditLogs()->remove($event, $sessionKey); + $this->assertEquals(1, $author->auditLogs()->count()); + $this->assertEquals(0, $author->auditLogs()->withDeferred($sessionKey)->count()); + $this->assertEquals($author->id, $event->related_id); + $this->assertEquals([ + 'user-created' + ], $author->auditLogs->lists('action')); + + $author->tags()->remove($tagForAuthor, $sessionKey); + $this->assertEquals(1, $author->tags()->count()); + $this->assertEquals(0, $author->tags()->withDeferred($sessionKey)->count()); + $this->assertEquals([$tagForAuthor->id], $author->tags->lists('id')); + $this->assertEquals(99, $author->tags->first()->pivot->added_by); + + $tagForPost->posts()->remove($post, $sessionKey); + $this->assertEquals(1, $tagForPost->posts()->count()); + $this->assertEquals(0, $tagForPost->posts()->withDeferred($sessionKey)->count()); + $this->assertEquals([$post->id], $tagForPost->posts->lists('id')); + $this->assertEquals(88, $tagForPost->posts->first()->pivot->added_by); + + // Commit deferred (model is deleted as per definition) + $author->save(null, $sessionKey); + $event = EventLog::find($eventId); + $this->assertEquals(0, $author->auditLogs()->count()); + $this->assertNull($event); + $this->assertEmpty($author->auditLogs); + + $this->assertEmpty(0, $author->tags); + $this->assertEmpty(0, $tagForPost->posts); + } +} diff --git a/tests/Database/Relations/MorphOneTest.php b/tests/Database/Relations/MorphOneTest.php new file mode 100644 index 000000000..4cb8205f2 --- /dev/null +++ b/tests/Database/Relations/MorphOneTest.php @@ -0,0 +1,348 @@ + "First post", 'description' => "Yay!!"]); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $meta1 = Meta::create([ + 'meta_title' => 'Question', + 'meta_description' => 'Industry', + 'meta_keywords' => 'major', + 'canonical_url' => 'http://google.com/search/jobs', + 'redirect_url' => 'http://google.com', + 'robot_index' => 'index', + 'robot_follow' => 'follow', + ]); + $meta2 = Meta::create([ + 'meta_title' => 'Comment', + 'meta_description' => 'Social', + 'meta_keywords' => 'startup', + 'canonical_url' => 'http://facebook.com/search/users', + 'redirect_url' => 'http://facebook.com', + 'robot_index' => 'index', + 'robot_follow' => 'follow', + ]); + $meta3 = Meta::make([ + 'meta_title' => 'Answer', + 'meta_description' => 'Employment', + 'meta_keywords' => 'minor', + 'canonical_url' => 'http://yahoo.com/search/stats', + 'redirect_url' => 'http://yahoo.com', + 'robot_index' => 'index', + 'robot_follow' => 'follow', + ]); + Model::reguard(); + + // Set by Model object + $post->meta = $meta1; + $post->save(); + $this->assertEquals($post->id, $meta1->taggable_id); + $this->assertEquals(get_class($post), $meta1->taggable_type); + $this->assertEquals('Question', $post->meta->meta_title); + + // Double check + $meta1 = Meta::find($meta1->id); + $this->assertEquals($post->id, $meta1->taggable_id); + $this->assertEquals(get_class($post), $meta1->taggable_type); + + // Set by primary key + $metaId = $meta2->id; + $author->meta = $metaId; + $author->save(); + $meta2 = Meta::find($metaId); + $this->assertEquals($author->id, $meta2->taggable_id); + $this->assertEquals(get_class($author), $meta2->taggable_type); + $this->assertEquals('Comment', $author->meta->meta_title); + + // Nullify + $author->meta = null; + $author->save(); + $meta = Meta::find($metaId); + $this->assertNull($meta->taggable_type); + $this->assertNull($meta->taggable_id); + $this->assertNull($meta->taggable); + + // Deferred in memory + $author->meta = $meta3; + $this->assertEquals('Answer', $author->meta->meta_title); + $this->assertEquals($author->id, $meta3->taggable_id); + } + + public function testSetRelationValueLaravelRelation() + { + Model::unguard(); + $post = Post::create(['title' => "First post", 'description' => "Yay!!"]); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $meta1 = Meta::create([ + 'meta_title' => 'Question', + 'meta_description' => 'Industry', + 'meta_keywords' => 'major', + 'canonical_url' => 'http://google.com/search/jobs', + 'redirect_url' => 'http://google.com', + 'robot_index' => 'index', + 'robot_follow' => 'follow', + ]); + $meta2 = Meta::create([ + 'meta_title' => 'Comment', + 'meta_description' => 'Social', + 'meta_keywords' => 'startup', + 'canonical_url' => 'http://facebook.com/search/users', + 'redirect_url' => 'http://facebook.com', + 'robot_index' => 'index', + 'robot_follow' => 'follow', + ]); + $meta3 = Meta::make([ + 'meta_title' => 'Answer', + 'meta_description' => 'Employment', + 'meta_keywords' => 'minor', + 'canonical_url' => 'http://yahoo.com/search/stats', + 'redirect_url' => 'http://yahoo.com', + 'robot_index' => 'index', + 'robot_follow' => 'follow', + ]); + Model::reguard(); + + // Set by Model object + $post->info = $meta1; + $post->save(); + $this->assertEquals($post->id, $meta1->taggable_id); + $this->assertEquals(get_class($post), $meta1->taggable_type); + $this->assertEquals('Question', $post->info->meta_title); + + // Double check + $meta1 = Meta::find($meta1->id); + $this->assertEquals($post->id, $meta1->taggable_id); + $this->assertEquals(get_class($post), $meta1->taggable_type); + + // Set by primary key + $metaId = $meta2->id; + $author->info = $metaId; + $author->save(); + $meta2 = Meta::find($metaId); + $this->assertEquals($author->id, $meta2->taggable_id); + $this->assertEquals(get_class($author), $meta2->taggable_type); + $this->assertEquals('Comment', $author->info->meta_title); + + // Nullify + $author->info = null; + $author->save(); + $meta = Meta::find($metaId); + $this->assertNull($meta->taggable_type); + $this->assertNull($meta->taggable_id); + $this->assertNull($meta->taggable); + + // Deferred in memory + $author->info = $meta3; + $this->assertEquals('Answer', $author->info->meta_title); + $this->assertEquals($author->id, $meta3->taggable_id); + } + + public function testSetRelationValueTwice() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $meta = Meta::create([ + 'meta_title' => 'Question', + 'meta_description' => 'Industry', + 'meta_keywords' => 'major', + 'canonical_url' => 'http://google.com/search/jobs', + 'redirect_url' => 'http://google.com', + 'robot_index' => 'index', + 'robot_follow' => 'follow', + ]); + Model::reguard(); + + $metaId = $meta->id; + $author->meta = $metaId; + $author->save(); + + $author->meta = $metaId; + $author->save(); + + $meta = Meta::find($metaId); + $this->assertEquals($author->id, $meta->taggable_id); + $this->assertEquals(get_class($author), $meta->taggable_type); + } + + public function testSetRelationValueTwiceLaravelRelation() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $meta = Meta::create([ + 'meta_title' => 'Question', + 'meta_description' => 'Industry', + 'meta_keywords' => 'major', + 'canonical_url' => 'http://google.com/search/jobs', + 'redirect_url' => 'http://google.com', + 'robot_index' => 'index', + 'robot_follow' => 'follow', + ]); + Model::reguard(); + + $metaId = $meta->id; + $author->info = $metaId; + $author->save(); + + $author->info = $metaId; + $author->save(); + + $meta = Meta::find($metaId); + $this->assertEquals($author->id, $meta->taggable_id); + $this->assertEquals(get_class($author), $meta->taggable_type); + } + + public function testGetRelationValue() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $meta = Meta::create([ + 'taggable_id' => $author->id, + 'taggable_type' => get_class($author), + 'meta_title' => 'Question', + 'meta_description' => 'Industry', + 'meta_keywords' => 'major', + 'canonical_url' => 'http://google.com/search/jobs', + 'redirect_url' => 'http://google.com', + 'robot_index' => 'index', + 'robot_follow' => 'follow', + ]); + Model::reguard(); + + $this->assertEquals($meta->id, $author->getRelationValue('meta')); + } + + public function testGetRelationValueLaravelRelation() + { + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $meta = Meta::create([ + 'taggable_id' => $author->id, + 'taggable_type' => get_class($author), + 'meta_title' => 'Question', + 'meta_description' => 'Industry', + 'meta_keywords' => 'major', + 'canonical_url' => 'http://google.com/search/jobs', + 'redirect_url' => 'http://google.com', + 'robot_index' => 'index', + 'robot_follow' => 'follow', + ]); + Model::reguard(); + + $this->assertEquals($meta->id, $author->getRelationValue('info')); + } + + public function testDeferredBinding() + { + $sessionKey = uniqid('session_key', true); + + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $meta = Meta::create([ + 'meta_title' => 'Comment', + 'meta_description' => 'Social', + 'meta_keywords' => 'startup', + 'canonical_url' => 'http://facebook.com/search/users', + 'redirect_url' => 'http://facebook.com', + 'robot_index' => 'index', + 'robot_follow' => 'follow', + ]); + Model::reguard(); + + $metaId = $meta->id; + + // Deferred add + $author->meta()->add($meta, $sessionKey); + $this->assertNull($meta->taggable_id); + $this->assertNull($author->meta); + + $this->assertEquals(0, $author->meta()->count()); + $this->assertEquals(1, $author->meta()->withDeferred($sessionKey)->count()); + + // Commit deferred + $author->save(null, $sessionKey); + $meta = Meta::find($metaId); + $this->assertEquals(1, $author->meta()->count()); + $this->assertEquals($author->id, $meta->taggable_id); + $this->assertEquals('Comment', $author->meta->meta_title); + + // New session + $sessionKey = uniqid('session_key', true); + + // Deferred remove + $author->meta()->remove($meta, $sessionKey); + $this->assertEquals(1, $author->meta()->count()); + $this->assertEquals(0, $author->meta()->withDeferred($sessionKey)->count()); + $this->assertEquals($author->id, $meta->taggable_id); + $this->assertEquals('Comment', $author->meta->meta_title); + + // Commit deferred + $author->save(null, $sessionKey); + $meta = Meta::find($metaId); + $this->assertEquals(0, $author->meta()->count()); + $this->assertNull($meta->taggable_id); + $this->assertNull($author->meta); + } + + public function testDeferredBindingLaravelRelation() + { + $sessionKey = uniqid('session_key', true); + + Model::unguard(); + $author = Author::create(['name' => 'Stevie']); + $meta = Meta::create([ + 'meta_title' => 'Comment', + 'meta_description' => 'Social', + 'meta_keywords' => 'startup', + 'canonical_url' => 'http://facebook.com/search/users', + 'redirect_url' => 'http://facebook.com', + 'robot_index' => 'index', + 'robot_follow' => 'follow', + ]); + Model::reguard(); + + $metaId = $meta->id; + + // Deferred add + $author->info()->add($meta, $sessionKey); + $this->assertNull($meta->taggable_id); + $this->assertNull($author->info); + + $this->assertEquals(0, $author->info()->count()); + $this->assertEquals(1, $author->info()->withDeferred($sessionKey)->count()); + + // Commit deferred + $author->save(null, $sessionKey); + $meta = Meta::find($metaId); + $this->assertEquals(1, $author->info()->count()); + $this->assertEquals($author->id, $meta->taggable_id); + $this->assertEquals('Comment', $author->info->meta_title); + + // New session + $sessionKey = uniqid('session_key', true); + + // Deferred remove + $author->info()->remove($meta, $sessionKey); + $this->assertEquals(1, $author->info()->count()); + $this->assertEquals(0, $author->info()->withDeferred($sessionKey)->count()); + $this->assertEquals($author->id, $meta->taggable_id); + $this->assertEquals('Comment', $author->info->meta_title); + + // Commit deferred + $author->save(null, $sessionKey); + $meta = Meta::find($metaId); + $this->assertEquals(0, $author->info()->count()); + $this->assertNull($meta->taggable_id); + $this->assertNull($author->info); + } +} From a79bbb44402ef31b804413c8f0159fc11a4621d4 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Thu, 22 Feb 2024 13:08:47 +0800 Subject: [PATCH 06/59] Move performDeleteOnRelations method into HasRelationship concern This method is strictly a relationship method and should be grouped with the other relationship methods. --- src/Database/Concerns/HasRelationships.php | 46 ++++++++++++++++++++++ src/Database/Model.php | 45 +-------------------- 2 files changed, 47 insertions(+), 44 deletions(-) diff --git a/src/Database/Concerns/HasRelationships.php b/src/Database/Concerns/HasRelationships.php index a40dd3c8a..1f7dcc4cb 100644 --- a/src/Database/Concerns/HasRelationships.php +++ b/src/Database/Concerns/HasRelationships.php @@ -1,6 +1,8 @@ getName(); } + + /** + * Locates relations with delete flag and cascades the delete event. + * For pivot relations, detach the pivot record unless the detach flag is false. + * @return void + */ + protected function performDeleteOnRelations() + { + $definitions = $this->getRelationDefinitions(); + foreach ($definitions as $type => $relations) { + /* + * Hard 'delete' definition + */ + foreach ($relations as $name => $options) { + if (in_array($type, ['belongsToMany', 'morphToMany', 'morphedByMany'])) { + // we want to remove the pivot record, not the actual relation record + if (Arr::get($options, 'detach', true)) { + $this->{$name}()->detach(); + } + } elseif (in_array($type, ['belongsTo', 'hasOneThrough', 'hasManyThrough', 'morphTo'])) { + // the model does not own the related record, we should not remove it. + continue; + } elseif (in_array($type, ['attachOne', 'attachMany', 'hasOne', 'hasMany', 'morphOne', 'morphMany'])) { + if (!Arr::get($options, 'delete', false)) { + continue; + } + + // Attempt to load the related record(s) + 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/Model.php b/src/Database/Model.php index 403905965..cfa25b35b 100644 --- a/src/Database/Model.php +++ b/src/Database/Model.php @@ -992,50 +992,7 @@ protected function performDeleteOnModel() { $this->performDeleteOnRelations(); - $this->setKeysForSaveQuery($this->newQueryWithoutScopes())->delete(); - } - - /** - * Locates relations with delete flag and cascades the delete event. - * For pivot relations, detach the pivot record unless the detach flag is false. - * @return void - */ - protected function performDeleteOnRelations() - { - $definitions = $this->getRelationDefinitions(); - foreach ($definitions as $type => $relations) { - /* - * Hard 'delete' definition - */ - foreach ($relations as $name => $options) { - if (in_array($type, ['belongsToMany', 'morphToMany', 'morphedByMany'])) { - // we want to remove the pivot record, not the actual relation record - if (Arr::get($options, 'detach', true)) { - $this->{$name}()->detach(); - } - } elseif (in_array($type, ['belongsTo', 'hasOneThrough', 'hasManyThrough', 'morphTo'])) { - // the model does not own the related record, we should not remove it. - continue; - } elseif (in_array($type, ['attachOne', 'attachMany', 'hasOne', 'hasMany', 'morphOne', 'morphMany'])) { - if (!Arr::get($options, 'delete', false)) { - continue; - } - - // Attempt to load the related record(s) - if (!$relation = $this->{$name}) { - continue; - } - - if ($relation instanceof EloquentModel) { - $relation->forceDelete(); - } elseif ($relation instanceof CollectionBase) { - $relation->each(function ($model) { - $model->forceDelete(); - }); - } - } - } - } + parent::performDeleteOnModel(); } // From 6c5c7ada5f86ad25c173e8ded93aa63bb2e57bdc Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Thu, 22 Feb 2024 15:28:32 +0800 Subject: [PATCH 07/59] Significantly refactor relation construction. In order to maintain some semblance of parity with Laravel, we'll now use Laravel's relation constructors and simply overwrite the "new" relation methods to create Winter's relation instances. Winter's relation name and defined constraints functionality will now be called in our own handler after the relation has been instantiated. This will allow us to review the relation directly before any Winter code runs. --- phpstan-baseline.neon | 30 - src/Database/Concerns/HasRelationships.php | 556 +++++++----------- src/Database/Relations/AttachMany.php | 43 +- src/Database/Relations/AttachOne.php | 41 +- src/Database/Relations/BelongsTo.php | 30 +- src/Database/Relations/BelongsToMany.php | 88 ++- .../Relations/Concerns/AttachOneOrMany.php | 5 - .../Concerns/BelongsOrMorphsToMany.php | 122 +--- .../Relations/Concerns/HasOneOrMany.php | 5 - .../Relations/Concerns/HasRelationName.php | 39 ++ src/Database/Relations/HasMany.php | 29 +- src/Database/Relations/HasManyThrough.php | 25 +- src/Database/Relations/HasOne.php | 25 +- src/Database/Relations/HasOneThrough.php | 25 +- src/Database/Relations/MorphMany.php | 29 +- src/Database/Relations/MorphOne.php | 29 +- src/Database/Relations/MorphTo.php | 30 +- src/Database/Relations/MorphToMany.php | 131 +++-- src/Database/Relations/Relation.php | 35 ++ 19 files changed, 557 insertions(+), 760 deletions(-) create mode 100644 src/Database/Relations/Concerns/HasRelationName.php create mode 100644 src/Database/Relations/Relation.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index ace12018d..a52ec5ebc 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -535,21 +535,6 @@ parameters: count: 1 path: src/Database/Relations/MorphMany.php - - - message: "#^Parameter \\#1 \\$query of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\MorphOneOrMany\\\\:\\:__construct\\(\\) expects Illuminate\\\\Database\\\\Eloquent\\\\Builder\\, Illuminate\\\\Database\\\\Eloquent\\\\Builder\\ given\\.$#" - count: 1 - path: src/Database/Relations/MorphMany.php - - - - message: "#^Parameter \\$parent of method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphMany\\:\\:__construct\\(\\) has invalid type Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\TRelatedModel\\.$#" - count: 1 - path: src/Database/Relations/MorphMany.php - - - - message: "#^Parameter \\$query of method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphMany\\:\\:__construct\\(\\) has invalid type Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\TRelatedModel\\.$#" - count: 1 - path: src/Database/Relations/MorphMany.php - - message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\:\\:\\$countMode\\.$#" count: 1 @@ -585,21 +570,6 @@ parameters: count: 2 path: src/Database/Relations/MorphOne.php - - - message: "#^Parameter \\#1 \\$query of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\MorphOneOrMany\\\\:\\:__construct\\(\\) expects Illuminate\\\\Database\\\\Eloquent\\\\Builder\\, Illuminate\\\\Database\\\\Eloquent\\\\Builder\\ given\\.$#" - count: 1 - path: src/Database/Relations/MorphOne.php - - - - message: "#^Parameter \\$parent of method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphOne\\:\\:__construct\\(\\) has invalid type Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\TRelatedModel\\.$#" - count: 1 - path: src/Database/Relations/MorphOne.php - - - - message: "#^Parameter \\$query of method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphOne\\:\\:__construct\\(\\) has invalid type Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\TRelatedModel\\.$#" - count: 1 - path: src/Database/Relations/MorphOne.php - - message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\:\\:\\$countMode\\.$#" count: 1 diff --git a/src/Database/Concerns/HasRelationships.php b/src/Database/Concerns/HasRelationships.php index 1f7dcc4cb..2ad1d2373 100644 --- a/src/Database/Concerns/HasRelationships.php +++ b/src/Database/Concerns/HasRelationships.php @@ -1,9 +1,9 @@ setRelationName($relationName); + + // Add defined constraints + $relationObj->addDefinedConstraints(); + return $relationObj; } @@ -438,322 +443,291 @@ protected function validateRelationArgs($relationName, $optional, $required = [] return $relation; } + /** - * Define a one-to-one relationship. - * This code is a duplicate of Eloquent but uses a Storm relation class. - * @return \Winter\Storm\Database\Relations\HasOne + * Finds the calling function name from the stack trace. */ - public function hasOne($related, $primaryKey = null, $localKey = null, $relationName = null) + protected function getRelationCaller() { - if (is_null($relationName)) { - $relationName = $this->getRelationCaller(); - } + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); - $instance = $this->newRelatedInstance($related); + $handled = Arr::first($trace, function ($trace) { + return $trace['function'] === 'handleRelation'; + }); - $primaryKey = $primaryKey ?: $this->getForeignKey(); + if (!is_null($handled)) { + return null; + } - $localKey = $localKey ?: $this->getKeyName(); + $caller = Arr::first($trace, function ($trace) { + return !in_array( + $trace['class'], + [ + \Illuminate\Database\Eloquent\Model::class, + \Winter\Storm\Database\Model::class, + ] + ); + }); - return new HasOne($instance->newQuery(), $this, $instance->getTable().'.'.$primaryKey, $localKey, $relationName); + return !is_null($caller) ? $caller['function'] : null; } /** - * Define a polymorphic one-to-one relationship. - * This code is a duplicate of Eloquent but uses a Storm relation class. - * @return \Winter\Storm\Database\Relations\MorphOne + * Returns a relation key value(s), not as an object. */ - public function morphOne($related, $name, $type = null, $id = null, $localKey = null, $relationName = null) + public function getRelationValue($relationName) { - if (is_null($relationName)) { - $relationName = $this->getRelationCaller(); - } - - $instance = $this->newRelatedInstance($related); - - list($type, $id) = $this->getMorphs($name, $type, $id); - - $table = $instance->getTable(); - - $localKey = $localKey ?: $this->getKeyName(); + return $this->$relationName()->getSimpleValue(); + } - return new MorphOne($instance->newQuery(), $this, $table.'.'.$type, $table.'.'.$id, $localKey, $relationName); + /** + * Sets a relation value directly from its attribute. + */ + protected function setRelationValue($relationName, $value) + { + $this->$relationName()->setSimpleValue($value); } /** - * Define 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. - * @return \Winter\Storm\Database\Relations\BelongsTo + * Get the polymorphic relationship columns. + * + * @param string $name + * @param string|null $type + * @param string|null $id + * @return array */ - public function belongsTo($related, $foreignKey = null, $parentKey = null, $relationName = null) + protected function getMorphs($name, $type = null, $id = null) { - if (is_null($relationName)) { - $relationName = $this->getRelationCaller(); - } + return [$type ?: $name.'_type', $id ?: $name.'_id']; + } - $instance = $this->newRelatedInstance($related); + /** + * Determines if a method returns a relation class. + * + * This is used to determine Laravel-style relation methods in a way that won't cause issues with current Winter + * code that may be defining attributes with the same name as a relation method, as the method must specifically + * define a return type of a Relation class in order to qualify as a relation. + */ + protected function returnsRelation(\ReflectionMethod $method): ?string + { + $returnType = $method->getReturnType(); - if (is_null($foreignKey)) { - $foreignKey = snake_case($relationName).'_id'; + if ($returnType === null || $returnType instanceof \ReflectionNamedType === false) { + return null; } - $parentKey = $parentKey ?: $instance->getKeyName(); + if (!is_subclass_of($returnType->getName(), 'Illuminate\Database\Eloquent\Relations\Relation')) { + return null; + } - return new BelongsTo($instance->newQuery(), $this, $foreignKey, $parentKey, $relationName); + return $returnType->getName(); } /** - * Define an 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. - * @return \Winter\Storm\Database\Relations\MorphTo + * Locates relations with delete flag and cascades the delete event. + * For pivot relations, detach the pivot record unless the detach flag is false. + * @return void */ - public function morphTo($name = null, $type = null, $id = null, $ownerKey = null) + protected function performDeleteOnRelations() { - if (is_null($name)) { - $name = $this->getRelationCaller(); - } + $definitions = $this->getRelationDefinitions(); + foreach ($definitions as $type => $relations) { + /* + * Hard 'delete' definition + */ + foreach ($relations as $name => $options) { + if (in_array($type, ['belongsToMany', 'morphToMany', 'morphedByMany'])) { + // we want to remove the pivot record, not the actual relation record + if (Arr::get($options, 'detach', true)) { + $this->{$name}()->detach(); + } + } elseif (in_array($type, ['belongsTo', 'hasOneThrough', 'hasManyThrough', 'morphTo'])) { + // the model does not own the related record, we should not remove it. + continue; + } elseif (in_array($type, ['attachOne', 'attachMany', 'hasOne', 'hasMany', 'morphOne', 'morphMany'])) { + if (!Arr::get($options, 'delete', false)) { + continue; + } - list($type, $id) = $this->getMorphs(Str::snake($name), $type, $id); + // Attempt to load the related record(s) + if (!$relation = $this->{$name}) { + continue; + } - return empty($class = $this->{$type}) - ? $this->morphEagerTo($name, $type, $id, $ownerKey) - : $this->morphInstanceTo($class, $name, $type, $id, $ownerKey); + if ($relation instanceof Model) { + $relation->forceDelete(); + } elseif ($relation instanceof CollectionBase) { + $relation->each(function ($model) { + $model->forceDelete(); + }); + } + } + } + } } /** - * Define a polymorphic, inverse one-to-one or many relationship. - * - * @param string $name - * @param string $type - * @param string $id - * @param string $ownerKey - * @return \Winter\Storm\Database\Relations\MorphTo + * {@inheritDoc} */ - protected function morphEagerTo($name, $type, $id, $ownerKey) + protected function newHasOne(Builder $query, Model $parent, $foreignKey, $localKey) { - return new MorphTo( - $this->newQuery()->setEagerLoads([]), - $this, - $id, - $ownerKey, - $type, - $name - ); + $relation = new HasOne($query, $parent, $foreignKey, $localKey); + $caller = $this->getRelationCaller(); + if (!is_null($caller)) { + $relation->setRelationName($caller); + } + return $relation; } /** - * Define a polymorphic, inverse one-to-one or many relationship. - * - * @param string $target - * @param string $name - * @param string $type - * @param string $id - * @param string|null $ownerKey - * @return \Winter\Storm\Database\Relations\MorphTo + * {@inheritDoc} */ - protected function morphInstanceTo($target, $name, $type, $id, $ownerKey = null) + protected function newHasOneThrough(Builder $query, Model $farParent, Model $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey) { - $instance = $this->newRelatedInstance( - static::getActualClassNameForMorph($target) - ); - - return new MorphTo( - $instance->newQuery(), - $this, - $id, - $ownerKey ?? $instance->getKeyName(), - $type, - $name - ); + $relation = new HasOneThrough($query, $farParent, $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey); + $caller = $this->getRelationCaller(); + if (!is_null($caller)) { + $relation->setRelationName($caller); + } + return $relation; } /** - * Define a one-to-many relationship. - * This code is a duplicate of Eloquent but uses a Storm relation class. - * @return \Winter\Storm\Database\Relations\HasMany + * {@inheritDoc} */ - public function hasMany($related, $primaryKey = null, $localKey = null, $relationName = null) + protected function newMorphOne(Builder $query, Model $parent, $type, $id, $localKey) { - if (is_null($relationName)) { - $relationName = $this->getRelationCaller(); + $relation = new MorphOne($query, $parent, $type, $id, $localKey); + $caller = $this->getRelationCaller(); + if (!is_null($caller)) { + $relation->setRelationName($caller); } - - $instance = $this->newRelatedInstance($related); - - $primaryKey = $primaryKey ?: $this->getForeignKey(); - - $localKey = $localKey ?: $this->getKeyName(); - - return new HasMany($instance->newQuery(), $this, $instance->getTable().'.'.$primaryKey, $localKey, $relationName); + return $relation; } /** - * Define a has-many-through relationship. - * This code is a duplicate of Eloquent but uses a Storm relation class. - * @return \Winter\Storm\Database\Relations\HasManyThrough + * {@inheritDoc} */ - public function hasManyThrough($related, $through, $primaryKey = null, $throughKey = null, $localKey = null, $secondLocalKey = null, $relationName = null) + public function guessBelongsToRelation() { - if (is_null($relationName)) { - $relationName = $this->getRelationCaller(); - } - - $throughInstance = new $through; - - $primaryKey = $primaryKey ?: $this->getForeignKey(); - - $throughKey = $throughKey ?: $throughInstance->getForeignKey(); - - $localKey = $localKey ?: $this->getKeyName(); - - $secondLocalKey = $secondLocalKey ?: $throughInstance->getKeyName(); - - $instance = $this->newRelatedInstance($related); + return $this->getRelationCaller(); + } - return new HasManyThrough($instance->newQuery(), $this, $throughInstance, $primaryKey, $throughKey, $localKey, $secondLocalKey, $relationName); + /** + * {@inheritDoc} + */ + public function guessBelongsToManyRelation() + { + return $this->getRelationCaller(); } /** - * Define a has-one-through relationship. - * This code is a duplicate of Eloquent but uses a Storm relation class. - * @return \Winter\Storm\Database\Relations\HasOneThrough + * {@inheritDoc} */ - public function hasOneThrough($related, $through, $primaryKey = null, $throughKey = null, $localKey = null, $secondLocalKey = null, $relationName = null) + protected function newBelongsTo(Builder $query, Model $child, $foreignKey, $ownerKey, $relation) { - if (is_null($relationName)) { - $relationName = $this->getRelationCaller(); + $relation = new BelongsTo($query, $child, $foreignKey, $ownerKey, $relation); + $caller = $this->getRelationCaller(); + if (!is_null($caller)) { + $relation->setRelationName($caller); } - - $throughInstance = new $through; - - $primaryKey = $primaryKey ?: $this->getForeignKey(); - - $throughKey = $throughKey ?: $throughInstance->getForeignKey(); - - $localKey = $localKey ?: $this->getKeyName(); - - $secondLocalKey = $secondLocalKey ?: $throughInstance->getKeyName(); - - $instance = $this->newRelatedInstance($related); - - return new HasOneThrough($instance->newQuery(), $this, $throughInstance, $primaryKey, $throughKey, $localKey, $secondLocalKey, $relationName); + return $relation; } /** - * Define a polymorphic one-to-many relationship. - * This code is a duplicate of Eloquent but uses a Storm relation class. - * @return \Winter\Storm\Database\Relations\MorphMany + * {@inheritDoc} */ - public function morphMany($related, $name, $type = null, $id = null, $localKey = null, $relationName = null) + protected function newMorphTo(Builder $query, Model $parent, $foreignKey, $ownerKey, $type, $relation) { - if (is_null($relationName)) { - $relationName = $this->getRelationCaller(); + $relation = new MorphTo($query, $parent, $foreignKey, $ownerKey, $type, $relation); + $caller = $this->getRelationCaller(); + if (!is_null($caller)) { + $relation->setRelationName($caller); } - - $instance = $this->newRelatedInstance($related); - - list($type, $id) = $this->getMorphs($name, $type, $id); - - $table = $instance->getTable(); - - $localKey = $localKey ?: $this->getKeyName(); - - return new MorphMany($instance->newQuery(), $this, $table.'.'.$type, $table.'.'.$id, $localKey, $relationName); + return $relation; } /** - * Define a many-to-many relationship. - * This code is almost a duplicate of Eloquent but uses a Storm relation class. - * @return \Winter\Storm\Database\Relations\BelongsToMany + * {@inheritDoc} */ - public function belongsToMany($related, $table = null, $primaryKey = null, $foreignKey = null, $parentKey = null, $relatedKey = null, $relationName = null) + protected function newHasMany(Builder $query, Model $parent, $foreignKey, $localKey) { - if (is_null($relationName)) { - $relationName = $this->getRelationCaller(); + $relation = new HasMany($query, $parent, $foreignKey, $localKey); + $caller = $this->getRelationCaller(); + if (!is_null($caller)) { + $relation->setRelationName($caller); } + return $relation; + } - $instance = $this->newRelatedInstance($related); - - $primaryKey = $primaryKey ?: $this->getForeignKey(); - - $foreignKey = $foreignKey ?: $instance->getForeignKey(); - - if (is_null($table)) { - $table = $this->joiningTable($related); + /** + * {@inheritDoc} + */ + protected function newHasManyThrough(Builder $query, Model $farParent, Model $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey) + { + $relation = new HasManyThrough($query, $farParent, $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey); + $caller = $this->getRelationCaller(); + if (!is_null($caller)) { + $relation->setRelationName($caller); } - - return new BelongsToMany( - $instance->newQuery(), - $this, - $table, - $primaryKey, - $foreignKey, - $parentKey ?: $this->getKeyName(), - $relatedKey ?: $instance->getKeyName(), - $relationName - ); + return $relation; } /** - * Define a polymorphic many-to-many relationship. - * This code is almost a duplicate of Eloquent but uses a Storm relation class. - * @return \Winter\Storm\Database\Relations\MorphToMany + * {@inheritDoc} */ - public function morphToMany($related, $name, $table = null, $primaryKey = null, $foreignKey = null, $parentKey = null, $relatedKey = null, $inverse = false, $relationName = null) + protected function newMorphMany(Builder $query, Model $parent, $type, $id, $localKey) { - if (is_null($relationName)) { - $relationName = $this->getRelationCaller(); + $relation = new MorphMany($query, $parent, $type, $id, $localKey); + $caller = $this->getRelationCaller(); + if (!is_null($caller)) { + $relation->setRelationName($caller); } - - $instance = $this->newRelatedInstance($related); - - $primaryKey = $primaryKey ?: $name.'_id'; - - $foreignKey = $foreignKey ?: $instance->getForeignKey(); - - $table = $table ?: Str::plural($name); - - return new MorphToMany( - $instance->newQuery(), - $this, - $name, - $table, - $primaryKey, - $foreignKey, - $parentKey ?: $this->getKeyName(), - $relatedKey ?: $instance->getKeyName(), - $relationName, - $inverse - ); + return $relation; } /** - * Define a polymorphic many-to-many inverse relationship. - * This code is almost a duplicate of Eloquent but uses a Storm relation class. - * @return \Winter\Storm\Database\Relations\MorphToMany + * {@inheritDoc} */ - public function morphedByMany($related, $name, $table = null, $primaryKey = null, $foreignKey = null, $parentKey = null, $relatedKey = null, $relationName = null) - { - if (is_null($relationName)) { - $relationName = $this->getRelationCaller(); + protected function newBelongsToMany( + Builder $query, + Model $parent, + $table, + $foreignPivotKey, + $relatedPivotKey, + $parentKey, + $relatedKey, + $relationName = null + ) { + $relation = new BelongsToMany($query, $parent, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, $relationName); + $caller = $this->getRelationCaller(); + if (!is_null($caller)) { + $relation->setRelationName($caller); } + return $relation; + } - $primaryKey = $primaryKey ?: $this->getForeignKey(); - - $foreignKey = $foreignKey ?: $name.'_id'; - - return $this->morphToMany( - $related, - $name, - $table, - $primaryKey, - $foreignKey, - $parentKey, - $relatedKey, - true, - $relationName - ); + /** + * {@inheritDoc} + */ + protected function newMorphToMany( + Builder $query, + Model $parent, + $name, + $table, + $foreignPivotKey, + $relatedPivotKey, + $parentKey, + $relatedKey, + $relationName = null, + $inverse = false + ) { + $relation = new MorphToMany($query, $parent, $name, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, $relationName, $inverse); + $caller = $this->getRelationCaller(); + if (!is_null($caller)) { + $relation->setRelationName($caller); + } + return $relation; } /** @@ -775,7 +749,9 @@ public function attachOne($related, $isPublic = true, $localKey = null, $relatio $localKey = $localKey ?: $this->getKeyName(); - return new AttachOne($instance->newQuery(), $this, $table . '.' . $type, $table . '.' . $id, $isPublic, $localKey, $relationName); + $relation = new AttachOne($instance->newQuery(), $this, $table . '.' . $type, $table . '.' . $id, $isPublic, $localKey); + $relation->setRelationName($this->getRelationCaller()); + return $relation; } /** @@ -797,33 +773,9 @@ public function attachMany($related, $isPublic = null, $localKey = null, $relati $localKey = $localKey ?: $this->getKeyName(); - return new AttachMany($instance->newQuery(), $this, $table . '.' . $type, $table . '.' . $id, $isPublic, $localKey, $relationName); - } - - /** - * Finds the calling function name from the stack trace. - */ - protected function getRelationCaller() - { - $backtrace = debug_backtrace(0); - $caller = ($backtrace[2]['function'] == 'handleRelation') ? $backtrace[4] : $backtrace[2]; - return $caller['function']; - } - - /** - * Returns a relation key value(s), not as an object. - */ - public function getRelationValue($relationName) - { - return $this->$relationName()->getSimpleValue(); - } - - /** - * Sets a relation value directly from its attribute. - */ - protected function setRelationValue($relationName, $value) - { - $this->$relationName()->setSimpleValue($value); + $relation = new AttachMany($instance->newQuery(), $this, $table . '.' . $type, $table . '.' . $id, $isPublic, $localKey); + $relation->setRelationName($this->getRelationCaller()); + return $relation; } /** @@ -986,82 +938,4 @@ public function addHasManyThroughRelation(string $name, array $config): void { $this->addRelation('hasManyThrough', $name, $config); } - - /** - * Get the polymorphic relationship columns. - * - * @param string $name - * @param string|null $type - * @param string|null $id - * @return array - */ - protected function getMorphs($name, $type = null, $id = null) - { - return [$type ?: $name.'_type', $id ?: $name.'_id']; - } - - /** - * Determines if a method returns a relation class. - * - * This is used to determine Laravel-style relation methods in a way that won't cause issues with current Winter - * code that may be defining attributes with the same name as a relation method, as the method must specifically - * define a return type of a Relation class in order to qualify as a relation. - */ - protected function returnsRelation(\ReflectionMethod $method): ?string - { - $returnType = $method->getReturnType(); - - if ($returnType === null || $returnType instanceof \ReflectionNamedType === false) { - return null; - } - - if (!is_subclass_of($returnType->getName(), 'Illuminate\Database\Eloquent\Relations\Relation')) { - return null; - } - - return $returnType->getName(); - } - - /** - * Locates relations with delete flag and cascades the delete event. - * For pivot relations, detach the pivot record unless the detach flag is false. - * @return void - */ - protected function performDeleteOnRelations() - { - $definitions = $this->getRelationDefinitions(); - foreach ($definitions as $type => $relations) { - /* - * Hard 'delete' definition - */ - foreach ($relations as $name => $options) { - if (in_array($type, ['belongsToMany', 'morphToMany', 'morphedByMany'])) { - // we want to remove the pivot record, not the actual relation record - if (Arr::get($options, 'detach', true)) { - $this->{$name}()->detach(); - } - } elseif (in_array($type, ['belongsTo', 'hasOneThrough', 'hasManyThrough', 'morphTo'])) { - // the model does not own the related record, we should not remove it. - continue; - } elseif (in_array($type, ['attachOne', 'attachMany', 'hasOne', 'hasMany', 'morphOne', 'morphMany'])) { - if (!Arr::get($options, 'delete', false)) { - continue; - } - - // Attempt to load the related record(s) - 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/Relations/AttachMany.php b/src/Database/Relations/AttachMany.php index 06e0005b1..62447f8cc 100644 --- a/src/Database/Relations/AttachMany.php +++ b/src/Database/Relations/AttachMany.php @@ -1,4 +1,6 @@ -relationName = $relationName; - - $this->public = $isPublic; - parent::__construct($query, $parent, $type, $id, $localKey); - - $this->addDefinedConstraints(); + $this->public = $isPublic; } /** - * Helper for setting this relationship using various expected - * values. For example, $model->relation = $value; + * {@inheritDoc} */ - public function setSimpleValue($value) + public function setSimpleValue($value): void { /* * Newly uploaded file(s) @@ -72,10 +70,9 @@ public function setSimpleValue($value) } /** - * Helper for getting this relationship simple value, - * generally useful with form values. + * {@inheritDoc} */ - public function getSimpleValue() + public function getSimpleValue(): mixed { $value = null; diff --git a/src/Database/Relations/AttachOne.php b/src/Database/Relations/AttachOne.php index 33a676b72..f36025f50 100644 --- a/src/Database/Relations/AttachOne.php +++ b/src/Database/Relations/AttachOne.php @@ -1,4 +1,6 @@ -relationName = $relationName; - - $this->public = $isPublic; - parent::__construct($query, $parent, $type, $id, $localKey); - - $this->addDefinedConstraints(); + $this->public = $isPublic; } /** - * Helper for setting this relationship using various expected - * values. For example, $model->relation = $value; + * {@inheritDoc} */ - public function setSimpleValue($value) + public function setSimpleValue($value): void { if (is_array($value)) { $value = reset($value); @@ -69,8 +67,7 @@ public function setSimpleValue($value) } /** - * Helper for getting this relationship simple value, - * generally useful with form values. + * {@inheritDoc} */ public function getSimpleValue() { diff --git a/src/Database/Relations/BelongsTo.php b/src/Database/Relations/BelongsTo.php index f708d56a2..4805a0bde 100644 --- a/src/Database/Relations/BelongsTo.php +++ b/src/Database/Relations/BelongsTo.php @@ -1,32 +1,20 @@ -relationName = $relationName; - - parent::__construct($query, $child, $foreignKey, $ownerKey, $relationName); - - $this->addDefinedConstraints(); - } + use Concerns\HasRelationName; /** * Adds a model to this relationship type. @@ -53,10 +41,9 @@ public function remove(Model $model, $sessionKey = null) } /** - * Helper for setting this relationship using various expected - * values. For example, $model->relation = $value; + * {@inheritDoc} */ - public function setSimpleValue($value) + public function setSimpleValue($value): void { // Nulling the relationship if (!$value) { @@ -83,8 +70,7 @@ public function setSimpleValue($value) } /** - * Helper for getting this relationship simple value, - * generally useful with form values. + * {@inheritDoc} */ public function getSimpleValue() { diff --git a/src/Database/Relations/BelongsToMany.php b/src/Database/Relations/BelongsToMany.php index ec140847c..432c12435 100644 --- a/src/Database/Relations/BelongsToMany.php +++ b/src/Database/Relations/BelongsToMany.php @@ -1,10 +1,94 @@ -getRelated(); + + /* + * Nulling the relationship + */ + if (!$value) { + // Disassociate in memory immediately + $this->parent->setRelation($this->relationName, $relationModel->newCollection()); + + // Perform sync when the model is saved + $this->parent->bindEventOnce('model.afterSave', function () { + $this->detach(); + }); + return; + } + + /* + * Convert models to keys + */ + if ($value instanceof Model) { + $value = $value->getKey(); + } + elseif (is_array($value)) { + foreach ($value as $_key => $_value) { + if ($_value instanceof Model) { + $value[$_key] = $_value->getKey(); + } + } + } + + /* + * Convert scalar to array + */ + if (!is_array($value) && !$value instanceof Collection) { + $value = [$value]; + } + + /* + * Setting the relationship + */ + $relationCollection = $value instanceof Collection + ? $value + : $relationModel->whereIn($relationModel->getKeyName(), $value)->get(); + + // Associate in memory immediately + $this->parent->setRelation($this->relationName, $relationCollection); + + // Perform sync when the model is saved + $this->parent->bindEventOnce('model.afterSave', function () use ($value) { + $this->sync($value); + }); + } + + /** + * {@inheritDoc} + */ + public function getSimpleValue() + { + $value = []; + + $sessionKey = $this->parent->sessionKey; + + if ($this->parent->relationLoaded($this->relationName)) { + $related = $this->getRelated(); + + $value = $this->parent->getRelation($this->relationName)->pluck($related->getKeyName())->all(); + } + else { + $value = $this->allRelatedIds($sessionKey)->all(); + } + + return $value; + } } diff --git a/src/Database/Relations/Concerns/AttachOneOrMany.php b/src/Database/Relations/Concerns/AttachOneOrMany.php index 760cd1d6b..935d1ac52 100644 --- a/src/Database/Relations/Concerns/AttachOneOrMany.php +++ b/src/Database/Relations/Concerns/AttachOneOrMany.php @@ -11,11 +11,6 @@ trait AttachOneOrMany { use DeferOneOrMany; - /** - * @var string The "name" of the relationship. - */ - protected $relationName; - /** * @var ?boolean Default value for file public or protected state. */ diff --git a/src/Database/Relations/Concerns/BelongsOrMorphsToMany.php b/src/Database/Relations/Concerns/BelongsOrMorphsToMany.php index e07d38281..5e191c001 100644 --- a/src/Database/Relations/Concerns/BelongsOrMorphsToMany.php +++ b/src/Database/Relations/Concerns/BelongsOrMorphsToMany.php @@ -1,4 +1,6 @@ -addDefinedConstraints(); - } - /** * Get the select columns for the relation query. * @@ -306,89 +273,6 @@ public function newPivot(array $attributes = [], $exists = false) return $pivot->setPivotKeys($this->foreignPivotKey, $this->relatedPivotKey); } - /** - * Helper for setting this relationship using various expected - * values. For example, $model->relation = $value; - */ - public function setSimpleValue($value) - { - $relationModel = $this->getRelated(); - - /* - * Nulling the relationship - */ - if (!$value) { - // Disassociate in memory immediately - $this->parent->setRelation($this->relationName, $relationModel->newCollection()); - - // Perform sync when the model is saved - $this->parent->bindEventOnce('model.afterSave', function () { - $this->detach(); - }); - return; - } - - /* - * Convert models to keys - */ - if ($value instanceof Model) { - $value = $value->getKey(); - } - elseif (is_array($value)) { - foreach ($value as $_key => $_value) { - if ($_value instanceof Model) { - $value[$_key] = $_value->getKey(); - } - } - } - - /* - * Convert scalar to array - */ - if (!is_array($value) && !$value instanceof Collection) { - $value = [$value]; - } - - /* - * Setting the relationship - */ - $relationCollection = $value instanceof Collection - ? $value - : $relationModel->whereIn($relationModel->getKeyName(), $value)->get(); - - // Associate in memory immediately - $this->parent->setRelation($this->relationName, $relationCollection); - - // Perform sync when the model is saved - $this->parent->bindEventOnce('model.afterSave', function () use ($value) { - $this->sync($value); - }); - } - - /** - * Helper for getting this relationship simple value, - * generally useful with form values. - */ - public function getSimpleValue() - { - $value = []; - - $relationName = $this->relationName; - - $sessionKey = $this->parent->sessionKey; - - if ($this->parent->relationLoaded($relationName)) { - $related = $this->getRelated(); - - $value = $this->parent->getRelation($relationName)->pluck($related->getKeyName())->all(); - } - else { - $value = $this->allRelatedIds($sessionKey)->all(); - } - - return $value; - } - /** * Get all of the IDs for the related models, with deferred binding support * diff --git a/src/Database/Relations/Concerns/HasOneOrMany.php b/src/Database/Relations/Concerns/HasOneOrMany.php index b6fe905b1..f8af52484 100644 --- a/src/Database/Relations/Concerns/HasOneOrMany.php +++ b/src/Database/Relations/Concerns/HasOneOrMany.php @@ -7,11 +7,6 @@ trait HasOneOrMany { use DeferOneOrMany; - /** - * @var string The "name" of the relationship. - */ - protected $relationName; - /** * Save the supplied related model with deferred binding support. */ diff --git a/src/Database/Relations/Concerns/HasRelationName.php b/src/Database/Relations/Concerns/HasRelationName.php new file mode 100644 index 000000000..bfc3161c0 --- /dev/null +++ b/src/Database/Relations/Concerns/HasRelationName.php @@ -0,0 +1,39 @@ + + * @copyright Winter CMS Maintainers. + */ +trait HasRelationName +{ + /** + * @var string The name of the relation. + */ + protected $relationName; + + /** + * Sets the name of the relation. + */ + public function setRelationName(string $name): void + { + $this->relationName = $name; + } + + /** + * Gets the relation name. + */ + public function getRelationName(): string + { + return $this->relationName; + } +} diff --git a/src/Database/Relations/HasMany.php b/src/Database/Relations/HasMany.php index 6db531455..e69c7355e 100644 --- a/src/Database/Relations/HasMany.php +++ b/src/Database/Relations/HasMany.php @@ -1,37 +1,25 @@ -relationName = $relationName; - - parent::__construct($query, $parent, $foreignKey, $localKey); - - $this->addDefinedConstraints(); - } - - /** - * Helper for setting this relationship using various expected - * values. For example, $model->relation = $value; + * {@inheritDoc} */ - public function setSimpleValue($value) + public function setSimpleValue($value): void { // Nulling the relationship if (!$value) { @@ -75,8 +63,7 @@ public function setSimpleValue($value) } /** - * Helper for getting this relationship simple value, - * generally useful with form values. + * {@inheritDoc} */ public function getSimpleValue() { diff --git a/src/Database/Relations/HasManyThrough.php b/src/Database/Relations/HasManyThrough.php index 85c975d45..051c52d84 100644 --- a/src/Database/Relations/HasManyThrough.php +++ b/src/Database/Relations/HasManyThrough.php @@ -1,7 +1,7 @@ -relationName = $relationName; - - parent::__construct($query, $farParent, $parent, $firstKey, $secondKey, $localKey, $secondLocalKey); - - $this->addDefinedConstraints(); - } + use Concerns\HasRelationName; /** * Determine whether close parent of the relation uses Soft Deletes. diff --git a/src/Database/Relations/HasOne.php b/src/Database/Relations/HasOne.php index d45e93825..fed1cf12d 100644 --- a/src/Database/Relations/HasOne.php +++ b/src/Database/Relations/HasOne.php @@ -1,35 +1,21 @@ relationName = $relationName; - - parent::__construct($query, $parent, $foreignKey, $localKey); - - $this->addDefinedConstraints(); - } - - /** - * Helper for setting this relationship using various expected - * values. For example, $model->relation = $value; - */ - public function setSimpleValue($value) + public function setSimpleValue($value): void { if (is_array($value)) { return; @@ -75,8 +61,7 @@ public function setSimpleValue($value) } /** - * Helper for getting this relationship simple value, - * generally useful with form values. + * {@inheritDoc} */ public function getSimpleValue() { diff --git a/src/Database/Relations/HasOneThrough.php b/src/Database/Relations/HasOneThrough.php index efcfcfab4..2fcbaef24 100644 --- a/src/Database/Relations/HasOneThrough.php +++ b/src/Database/Relations/HasOneThrough.php @@ -1,7 +1,7 @@ -relationName = $relationName; - - parent::__construct($query, $farParent, $parent, $firstKey, $secondKey, $localKey, $secondLocalKey); - - $this->addDefinedConstraints(); - } + use Concerns\HasRelationName; /** * Determine whether close parent of the relation uses Soft Deletes. diff --git a/src/Database/Relations/MorphMany.php b/src/Database/Relations/MorphMany.php index dca6564d9..f1751e0e2 100644 --- a/src/Database/Relations/MorphMany.php +++ b/src/Database/Relations/MorphMany.php @@ -1,37 +1,25 @@ -relationName = $relationName; - - parent::__construct($query, $parent, $type, $id, $localKey); - - $this->addDefinedConstraints(); - } - - /** - * Helper for setting this relationship using various expected - * values. For example, $model->relation = $value; + * {@inheritDoc} */ - public function setSimpleValue($value) + public function setSimpleValue($value): void { // Nulling the relationship if (!$value) { @@ -83,8 +71,7 @@ public function setSimpleValue($value) } /** - * Helper for getting this relationship simple value, - * generally useful with form values. + * {@inheritDoc} */ public function getSimpleValue() { diff --git a/src/Database/Relations/MorphOne.php b/src/Database/Relations/MorphOne.php index 42bbabfaf..a623d6a17 100644 --- a/src/Database/Relations/MorphOne.php +++ b/src/Database/Relations/MorphOne.php @@ -1,35 +1,23 @@ -relationName = $relationName; - - parent::__construct($query, $parent, $type, $id, $localKey); - - $this->addDefinedConstraints(); - } - - /** - * Helper for setting this relationship using various expected - * values. For example, $model->relation = $value; + * {@inheritDoc} */ - public function setSimpleValue($value) + public function setSimpleValue($value): void { if (is_array($value)) { return; @@ -86,8 +74,7 @@ public function setSimpleValue($value) } /** - * Helper for getting this relationship simple value, - * generally useful with form values. + * {@inheritDoc} */ public function getSimpleValue() { diff --git a/src/Database/Relations/MorphTo.php b/src/Database/Relations/MorphTo.php index 229c70efb..82ab21b1f 100644 --- a/src/Database/Relations/MorphTo.php +++ b/src/Database/Relations/MorphTo.php @@ -1,37 +1,24 @@ -relationName = $relationName; - - parent::__construct($query, $parent, $foreignKey, $otherKey, $type, $relationName); - - $this->addDefinedConstraints(); - } - - /** - * Helper for setting this relationship using various expected - * values. For example, $model->relation = $value; + * {@inheritDoc} */ - public function setSimpleValue($value) + public function setSimpleValue($value): void { // Nulling the relationship if (!$value) { @@ -65,8 +52,7 @@ public function setSimpleValue($value) } /** - * Helper for getting this relationship simple value, - * generally useful with form values. + * {@inheritDoc} */ public function getSimpleValue() { diff --git a/src/Database/Relations/MorphToMany.php b/src/Database/Relations/MorphToMany.php index 2487139c4..588a9e8e0 100644 --- a/src/Database/Relations/MorphToMany.php +++ b/src/Database/Relations/MorphToMany.php @@ -1,8 +1,10 @@ -addDefinedConstraints(); - } + use Concerns\HasRelationName; /** * Create a new query builder for the pivot table. @@ -91,4 +53,85 @@ public function newPivot(array $attributes = [], $exists = false) return $pivot; } + + /** + * {@inheritDoc} + */ + public function setSimpleValue($value): void + { + $relationModel = $this->getRelated(); + + /* + * Nulling the relationship + */ + if (!$value) { + // Disassociate in memory immediately + $this->parent->setRelation($this->relationName, $relationModel->newCollection()); + + // Perform sync when the model is saved + $this->parent->bindEventOnce('model.afterSave', function () { + $this->detach(); + }); + return; + } + + /* + * Convert models to keys + */ + if ($value instanceof Model) { + $value = $value->getKey(); + } + elseif (is_array($value)) { + foreach ($value as $_key => $_value) { + if ($_value instanceof Model) { + $value[$_key] = $_value->getKey(); + } + } + } + + /* + * Convert scalar to array + */ + if (!is_array($value) && !$value instanceof Collection) { + $value = [$value]; + } + + /* + * Setting the relationship + */ + $relationCollection = $value instanceof Collection + ? $value + : $relationModel->whereIn($relationModel->getKeyName(), $value)->get(); + + // Associate in memory immediately + $this->parent->setRelation($this->relationName, $relationCollection); + + // Perform sync when the model is saved + $this->parent->bindEventOnce('model.afterSave', function () use ($value) { + $this->sync($value); + }); + } + + /** + * {@inheritDoc} + */ + public function getSimpleValue() + { + $value = []; + + $relationName = $this->relationName; + + $sessionKey = $this->parent->sessionKey; + + if ($this->parent->relationLoaded($relationName)) { + $related = $this->getRelated(); + + $value = $this->parent->getRelation($relationName)->pluck($related->getKeyName())->all(); + } + else { + $value = $this->allRelatedIds($sessionKey)->all(); + } + + return $value; + } } diff --git a/src/Database/Relations/Relation.php b/src/Database/Relations/Relation.php new file mode 100644 index 000000000..4ecd24c0f --- /dev/null +++ b/src/Database/Relations/Relation.php @@ -0,0 +1,35 @@ + + * @copyright Winter CMS Maintainers + */ +interface Relation +{ + /** + * Gets the simple representation of the relation. + * + * Retrieving this value will allow Winter to display or use the relation in forms, JavaScript, error messages + * and other contexts. + */ + public function getSimpleValue(); + + /** + * Creates or modifies the relation using its simple value format. + */ + public function setSimpleValue($value): void; +} From 55f03dc44f2c25544941afb75004895a0f299821 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Thu, 22 Feb 2024 16:30:50 +0800 Subject: [PATCH 08/59] Add ability to define relations (through methods) as dependent. This allows people using the relation method format to define the "delete" attribute available to relation definition arrays. --- src/Database/Attributes/Relation.php | 10 + src/Database/Concerns/HasRelationships.php | 138 ++++-- src/Database/Relations/AttachMany.php | 12 + src/Database/Relations/AttachOne.php | 12 + src/Database/Relations/BelongsTo.php | 8 + src/Database/Relations/BelongsToMany.php | 8 + .../Relations/Concerns/CanBeDependent.php | 63 +++ src/Database/Relations/HasMany.php | 9 + src/Database/Relations/HasManyThrough.php | 8 + src/Database/Relations/HasOne.php | 9 + src/Database/Relations/HasOneThrough.php | 8 + src/Database/Relations/MorphMany.php | 9 + src/Database/Relations/MorphOne.php | 9 + src/Database/Relations/MorphTo.php | 8 + src/Database/Relations/MorphToMany.php | 8 + src/Database/Relations/Relation.php | 5 + tests/Database/Fixtures/Author.php | 16 +- tests/Database/Relations/MorphManyTest.php | 2 +- tests/Database/Relations/MorphOneTest.php | 2 +- tests/Database/RelationsTest.php | 411 ++---------------- 20 files changed, 334 insertions(+), 421 deletions(-) create mode 100644 src/Database/Attributes/Relation.php create mode 100644 src/Database/Relations/Concerns/CanBeDependent.php diff --git a/src/Database/Attributes/Relation.php b/src/Database/Attributes/Relation.php new file mode 100644 index 000000000..e988f49a9 --- /dev/null +++ b/src/Database/Attributes/Relation.php @@ -0,0 +1,10 @@ +returnsRelation(new \ReflectionMethod($this, $name)); - return static::$resolvedRelations[$name] !== null; + if (method_exists($this, $name) && $this->isRelationMethod($name)) { + return true; } return $this->getRelationDefinition($name) !== null; @@ -170,6 +166,10 @@ public function hasRelation($name): bool */ public function getRelationDefinition($name): ?array { + if (method_exists($this, $name) && $this->isRelationMethod($name)) { + return $this->relationMethodDefinition($name); + } + if (($type = $this->getRelationType($name)) !== null) { return (array) $this->getRelationTypeDefinition($type, $name) + $this->getRelationDefaults($type); } @@ -237,17 +237,7 @@ public function getRelationDefinitions(): array public function getRelationType(string $name): ?string { if (method_exists($this, $name)) { - if (array_key_exists($name, static::$resolvedRelations)) { - return array_search(static::$resolvedRelations[$name], static::$relationTypes) ?: null; - } - - static::$resolvedRelations[$name] = $this->returnsRelation(new \ReflectionMethod($this, $name)); - - if (static::$resolvedRelations[$name] !== null) { - return array_search(static::$resolvedRelations[$name], static::$relationTypes) ?: null; - } - - return null; + return array_search(get_class($this->{$name}()), static::$relationTypes) ?: null; } foreach (array_keys(static::$relationTypes) as $type) { @@ -339,7 +329,13 @@ protected function handleRelation($relationName) case 'hasOne': case 'hasMany': $relation = $this->validateRelationArgs($relationName, ['key', 'otherKey']); + /** @var HasOne|HasMany */ $relationObj = $this->$relationType($relation[0], $relation['key'], $relation['otherKey'], $relationName); + + if ($relation['delete'] ?? false) { + $relationObj->dependent(); + } + break; case 'belongsTo': @@ -365,7 +361,13 @@ protected function handleRelation($relationName) case 'morphOne': case 'morphMany': $relation = $this->validateRelationArgs($relationName, ['type', 'id', 'key'], ['name']); + /** @var MorphOne|MorphMany */ $relationObj = $this->$relationType($relation[0], $relation['name'], $relation['type'], $relation['id'], $relation['key'], $relationName); + + if ($relation['delete'] ?? false) { + $relationObj->dependent(); + } + break; case 'morphToMany': @@ -386,7 +388,13 @@ protected function handleRelation($relationName) case 'attachOne': case 'attachMany': $relation = $this->validateRelationArgs($relationName, ['public', 'key']); + /** @var AttachOne|AttachMany */ $relationObj = $this->$relationType($relation[0], $relation['public'], $relation['key'], $relationName); + + if ($relation['delete'] ?? false) { + $relationObj->dependent(); + } + break; case 'hasOneThrough': @@ -501,28 +509,6 @@ protected function getMorphs($name, $type = null, $id = null) return [$type ?: $name.'_type', $id ?: $name.'_id']; } - /** - * Determines if a method returns a relation class. - * - * This is used to determine Laravel-style relation methods in a way that won't cause issues with current Winter - * code that may be defining attributes with the same name as a relation method, as the method must specifically - * define a return type of a Relation class in order to qualify as a relation. - */ - protected function returnsRelation(\ReflectionMethod $method): ?string - { - $returnType = $method->getReturnType(); - - if ($returnType === null || $returnType instanceof \ReflectionNamedType === false) { - return null; - } - - if (!is_subclass_of($returnType->getName(), 'Illuminate\Database\Eloquent\Relations\Relation')) { - return null; - } - - return $returnType->getName(); - } - /** * Locates relations with delete flag and cascades the delete event. * For pivot relations, detach the pivot record unless the detach flag is false. @@ -564,6 +550,78 @@ protected function performDeleteOnRelations() } } } + + // Find relation methods + foreach ($this->getRelationMethods() as $relation) { + $relationObj = $this->{$relation}(); + + if (method_exists($relationObj, 'isDependent')) { + if ($relationObj->isDependent()) { + $relationObj->forceDelete(); + } + } + } + } + + /** + * Retrieves all methods that either contain the `Relation` attribute or have a return type that matches a relation. + */ + public function getRelationMethods(): array + { + $relationMethods = []; + + foreach (get_class_methods($this) as $method) { + if ($this->isRelationMethod($method)) { + $relationMethods[] = $method; + } + } + + return $relationMethods; + } + + /** + * Determines if the provided method name is a relation method. + * + * A relation method either specifies the `Relation` attribute or has a return type that matches a relation. + */ + public function isRelationMethod(string $name): bool + { + if (!method_exists($this, $name)) { + return false; + } + + $method = new \ReflectionMethod($this, $name); + + if (count($method->getAttributes(Relation::class))) { + return true; + } + + $returnType = $method->getReturnType(); + + if (is_null($returnType)) { + return false; + } + + if ( + $returnType instanceof \ReflectionNamedType + && in_array($returnType->getName(), array_values(static::$relationTypes)) + ) { + return true; + } + + return false; + } + + /** + * Generates a definition array for a relation method. + */ + protected function relationMethodDefinition(string $name): array + { + if (!$this->isRelationMethod($name)) { + return []; + } + + return $this->{$name}()->getArrayDefinition(); } /** diff --git a/src/Database/Relations/AttachMany.php b/src/Database/Relations/AttachMany.php index 62447f8cc..1bcf3a4e9 100644 --- a/src/Database/Relations/AttachMany.php +++ b/src/Database/Relations/AttachMany.php @@ -13,6 +13,7 @@ class AttachMany extends MorphManyBase implements Relation { use Concerns\AttachOneOrMany; + use Concerns\CanBeDependent; use Concerns\DefinedConstraints; use Concerns\HasRelationName; @@ -125,4 +126,15 @@ protected function getSimpleValueInternal() return $value; } + + /** + * {@inheritDoc} + */ + public function getArrayDefinition(): array + { + return [ + get_class($this->query->getModel()), + 'delete' => $this->isDependent(), + ]; + } } diff --git a/src/Database/Relations/AttachOne.php b/src/Database/Relations/AttachOne.php index f36025f50..e90a130ff 100644 --- a/src/Database/Relations/AttachOne.php +++ b/src/Database/Relations/AttachOne.php @@ -13,6 +13,7 @@ class AttachOne extends MorphOneBase implements Relation { use Concerns\AttachOneOrMany; + use Concerns\CanBeDependent; use Concerns\DefinedConstraints; use Concerns\HasRelationName; @@ -107,4 +108,15 @@ protected function getSimpleValueInternal() return $value; } + + /** + * {@inheritDoc} + */ + public function getArrayDefinition(): array + { + return [ + get_class($this->query->getModel()), + 'delete' => $this->isDependent(), + ]; + } } diff --git a/src/Database/Relations/BelongsTo.php b/src/Database/Relations/BelongsTo.php index 4805a0bde..4be2daafa 100644 --- a/src/Database/Relations/BelongsTo.php +++ b/src/Database/Relations/BelongsTo.php @@ -85,4 +85,12 @@ public function getOtherKey() { return $this->ownerKey; } + + /** + * {@inheritDoc} + */ + public function getArrayDefinition(): array + { + return []; + } } diff --git a/src/Database/Relations/BelongsToMany.php b/src/Database/Relations/BelongsToMany.php index 432c12435..553f82b5b 100644 --- a/src/Database/Relations/BelongsToMany.php +++ b/src/Database/Relations/BelongsToMany.php @@ -91,4 +91,12 @@ public function getSimpleValue() return $value; } + + /** + * {@inheritDoc} + */ + public function getArrayDefinition(): array + { + return []; + } } diff --git a/src/Database/Relations/Concerns/CanBeDependent.php b/src/Database/Relations/Concerns/CanBeDependent.php new file mode 100644 index 000000000..f724a8869 --- /dev/null +++ b/src/Database/Relations/Concerns/CanBeDependent.php @@ -0,0 +1,63 @@ +dependent()` to the + * relationship definition method. For example: + * + * ```php + * public function messages() + * { + * return $this->hasMany(Message::class)->dependent(); + * } + * ``` + * + * If you are using the array-style definition, you can use the `delete` key to mark the relationship as dependent. + * + * ```php + * public $hasMany = [ + * 'messages' => [Message::class, 'delete' => true] + * ]; + * ``` + * + * @author Ben Thomson + * @copyright Winter CMS Maintainers + */ +trait CanBeDependent +{ + /** + * Is this relation dependent on the primary model? + */ + protected bool $dependent = false; + + /** + * Mark the relationship as dependent on the primary model. + */ + public function dependent(): static + { + $this->dependent = true; + + return $this; + } + + /** + * Determine if the relationship is dependent on the primary model. + */ + public function isDependent(): bool + { + return $this->dependent; + } +} diff --git a/src/Database/Relations/HasMany.php b/src/Database/Relations/HasMany.php index e69c7355e..0ba7299b9 100644 --- a/src/Database/Relations/HasMany.php +++ b/src/Database/Relations/HasMany.php @@ -13,6 +13,7 @@ class HasMany extends HasManyBase implements Relation { use Concerns\HasOneOrMany; + use Concerns\CanBeDependent; use Concerns\DefinedConstraints; use Concerns\HasRelationName; @@ -76,4 +77,12 @@ public function getSimpleValue() return $value; } + + /** + * {@inheritDoc} + */ + public function getArrayDefinition(): array + { + return []; + } } diff --git a/src/Database/Relations/HasManyThrough.php b/src/Database/Relations/HasManyThrough.php index 051c52d84..0b3061f0e 100644 --- a/src/Database/Relations/HasManyThrough.php +++ b/src/Database/Relations/HasManyThrough.php @@ -25,4 +25,12 @@ public function parentSoftDeletes() return in_array('Winter\Storm\Database\Traits\SoftDelete', $uses) || in_array('Illuminate\Database\Eloquent\SoftDeletes', $uses); } + + /** + * {@inheritDoc} + */ + public function getArrayDefinition(): array + { + return []; + } } diff --git a/src/Database/Relations/HasOne.php b/src/Database/Relations/HasOne.php index fed1cf12d..3d5cee6d5 100644 --- a/src/Database/Relations/HasOne.php +++ b/src/Database/Relations/HasOne.php @@ -9,6 +9,7 @@ class HasOne extends HasOneBase implements Relation { use Concerns\HasOneOrMany; + use Concerns\CanBeDependent; use Concerns\DefinedConstraints; use Concerns\HasRelationName; @@ -75,4 +76,12 @@ public function getSimpleValue() return $value; } + + /** + * {@inheritDoc} + */ + public function getArrayDefinition(): array + { + return []; + } } diff --git a/src/Database/Relations/HasOneThrough.php b/src/Database/Relations/HasOneThrough.php index 2fcbaef24..eb6ac51d4 100644 --- a/src/Database/Relations/HasOneThrough.php +++ b/src/Database/Relations/HasOneThrough.php @@ -25,4 +25,12 @@ public function parentSoftDeletes() return in_array('Winter\Storm\Database\Traits\SoftDelete', $uses) || in_array('Illuminate\Database\Eloquent\SoftDeletes', $uses); } + + /** + * {@inheritDoc} + */ + public function getArrayDefinition(): array + { + return []; + } } diff --git a/src/Database/Relations/MorphMany.php b/src/Database/Relations/MorphMany.php index f1751e0e2..3aab970fa 100644 --- a/src/Database/Relations/MorphMany.php +++ b/src/Database/Relations/MorphMany.php @@ -13,6 +13,7 @@ class MorphMany extends MorphManyBase implements Relation { use Concerns\MorphOneOrMany; + use Concerns\CanBeDependent; use Concerns\DefinedConstraints; use Concerns\HasRelationName; @@ -84,4 +85,12 @@ public function getSimpleValue() return $value; } + + /** + * {@inheritDoc} + */ + public function getArrayDefinition(): array + { + return []; + } } diff --git a/src/Database/Relations/MorphOne.php b/src/Database/Relations/MorphOne.php index a623d6a17..eb6cb55bb 100644 --- a/src/Database/Relations/MorphOne.php +++ b/src/Database/Relations/MorphOne.php @@ -11,6 +11,7 @@ class MorphOne extends MorphOneBase implements Relation { use Concerns\MorphOneOrMany; + use Concerns\CanBeDependent; use Concerns\DefinedConstraints; use Concerns\HasRelationName; @@ -88,4 +89,12 @@ public function getSimpleValue() return $value; } + + /** + * {@inheritDoc} + */ + public function getArrayDefinition(): array + { + return []; + } } diff --git a/src/Database/Relations/MorphTo.php b/src/Database/Relations/MorphTo.php index 82ab21b1f..a3f96d260 100644 --- a/src/Database/Relations/MorphTo.php +++ b/src/Database/Relations/MorphTo.php @@ -61,4 +61,12 @@ public function getSimpleValue() $this->parent->getAttribute($this->morphType) ]; } + + /** + * {@inheritDoc} + */ + public function getArrayDefinition(): array + { + return []; + } } diff --git a/src/Database/Relations/MorphToMany.php b/src/Database/Relations/MorphToMany.php index 588a9e8e0..d6a750b36 100644 --- a/src/Database/Relations/MorphToMany.php +++ b/src/Database/Relations/MorphToMany.php @@ -134,4 +134,12 @@ public function getSimpleValue() return $value; } + + /** + * {@inheritDoc} + */ + public function getArrayDefinition(): array + { + return []; + } } diff --git a/src/Database/Relations/Relation.php b/src/Database/Relations/Relation.php index 4ecd24c0f..a1dcf3c34 100644 --- a/src/Database/Relations/Relation.php +++ b/src/Database/Relations/Relation.php @@ -32,4 +32,9 @@ public function getSimpleValue(); * Creates or modifies the relation using its simple value format. */ public function setSimpleValue($value): void; + + /** + * Returns the relation definition in a simple array format. + */ + public function getArrayDefinition(): array; } diff --git a/tests/Database/Fixtures/Author.php b/tests/Database/Fixtures/Author.php index 59ab67cce..aa39e2f13 100644 --- a/tests/Database/Fixtures/Author.php +++ b/tests/Database/Fixtures/Author.php @@ -3,12 +3,10 @@ namespace Winter\Storm\Tests\Database\Fixtures; use Illuminate\Database\Schema\Builder; +use Winter\Storm\Database\Attributes\Relation; use Winter\Storm\Database\Model; -use Winter\Storm\Database\Relations\BelongsToMany; use Winter\Storm\Database\Relations\HasMany; use Winter\Storm\Database\Relations\HasOne; -use Winter\Storm\Database\Relations\MorphMany; -use Winter\Storm\Database\Relations\MorphOne; class Author extends Model { @@ -75,22 +73,26 @@ public function messages(): HasMany return $this->hasMany(Post::class); } - public function scopes(): BelongsToMany + #[Relation] + public function scopes() { return $this->belongsToMany(Role::class, 'database_tester_authors_roles'); } - public function executiveAuthors(): BelongsToMany + #[Relation] + public function executiveAuthors() { return $this->belongsToMany(Role::class, 'database_tester_authors_roles')->wherePivot('is_executive', 1); } - public function info(): MorphOne + #[Relation] + public function info() { return $this->morphOne(Meta::class, 'taggable'); } - public function auditLogs(): MorphMany + #[Relation] + public function auditLogs() { return $this->morphMany(EventLog::class, 'related'); } diff --git a/tests/Database/Relations/MorphManyTest.php b/tests/Database/Relations/MorphManyTest.php index b0bb8d492..9b3dbc2fc 100644 --- a/tests/Database/Relations/MorphManyTest.php +++ b/tests/Database/Relations/MorphManyTest.php @@ -10,7 +10,7 @@ use Winter\Storm\Tests\Database\Fixtures\EventLog; use Winter\Storm\Tests\DbTestCase; -class MorphManyModelTest extends DbTestCase +class MorphManyTest extends DbTestCase { public function testSetRelationValue() { diff --git a/tests/Database/Relations/MorphOneTest.php b/tests/Database/Relations/MorphOneTest.php index 4cb8205f2..005e334ff 100644 --- a/tests/Database/Relations/MorphOneTest.php +++ b/tests/Database/Relations/MorphOneTest.php @@ -8,7 +8,7 @@ use Winter\Storm\Tests\Database\Fixtures\Meta; use Winter\Storm\Tests\DbTestCase; -class MorphOneModelTest extends DbTestCase +class MorphOneTest extends DbTestCase { public function testSetRelationValue() { diff --git a/tests/Database/RelationsTest.php b/tests/Database/RelationsTest.php index ec11308d2..4344edb0a 100644 --- a/tests/Database/RelationsTest.php +++ b/tests/Database/RelationsTest.php @@ -1,386 +1,53 @@ seeded = [ - 'posts' => [], - 'categories' => [], - 'labels' => [], - 'tags' => [] - ]; - - $this->createTables(); - } - - public function testBelongsToManyCount() - { - $post = $this->seeded['posts'][0]; - $this->assertEquals(1, $post->categories()->count()); - $this->assertEquals(2, $post->tags()->count()); - $this->assertEquals(1, $post->labels()->count()); - $this->assertEquals(3, $post->terms()->count()); - - $post = $this->seeded['posts'][1]; - $this->assertEquals(1, $post->categories()->count()); - $this->assertEquals(0, $post->tags()->count()); - $this->assertEquals(1, $post->labels()->count()); - $this->assertEquals(1, $post->terms()->count()); - } - - public function testBelongsToManySyncAll() - { - $post = $this->seeded['posts'][0]; - - $this->assertEquals(1, $post->categories()->count()); - $this->assertEquals(1, $post->labels()->count()); - - $post->categories()->sync([ - $this->seeded['categories'][0]->id, - $this->seeded['categories'][1]->id, - ]); - $post->labels()->sync([ - $this->seeded['labels'][0]->id, - $this->seeded['labels'][1]->id, - ]); - - $this->assertEquals(2, $post->categories()->count()); - $this->assertEquals(2, $post->labels()->count()); - } - - public function testBelongsToManySyncTags() - { - $post = $this->seeded['posts'][0]; - - $this->assertEquals(1, $post->labels()->count()); - $this->assertEquals(2, $post->tags()->count()); - - $post->labels()->detach(); - $post->tags()->sync([ - $this->seeded['tags'][0]->id, - ]); - - $this->assertEquals(0, $post->labels()->count()); - $this->assertEquals(1, $post->tags()->count()); - $this->assertEquals($this->seeded['tags'][0]->id, $post->tags()->first()->id); - - $this->assertEquals(1, $post->terms()->count()); - } - - public function testBelongsToManyDetach() - { - $post = $this->seeded['posts'][0]; - - $post->labels()->detach(); - $post->tags()->detach(); - - $this->assertEquals(0, $post->labels()->count()); - $this->assertEquals(0, $post->tags()->count()); - $this->assertEquals(0, $post->terms()->count()); - } - - public function testBelongsToManyDetachOneTag() - { - $post = $this->seeded['posts'][0]; - - $id = $post->tags()->get()->last()->id; - $post->tags()->detach([$id]); - - $this->assertEquals(1, $post->labels()->count()); - $this->assertEquals(1, $post->tags()->count()); - $this->assertEquals(2, $post->terms()->count()); - } - - public function testBelongsToManyDetachAllWithScope() - { - $category = $this->seeded['categories'][0]; - $post = $this->seeded['posts'][0]; - - $category->posts()->detach(); - $post->reloadRelations(); - - $this->assertEquals(0, $category->posts()->count()); - $this->assertEquals(0, $post->categories()->count()); - } - - public function testPivotData() - { - $data = 'My Pivot Data'; - $post = Post::first(); - - $id = $post->categories()->get()->last()->id; - $updated = $post->categories()->updateExistingPivot($id, ['data' => $data]); - $this->assertTrue($updated === 1); - - $category = $post->categories()->find($id); - $this->assertEquals($data, $category->pivot->data); - } - - public function testAddWithPivotData() - { - $post = Post::first(); - $this->assertEquals(1, count($post->categories)); - - $post->categories()->add($this->seeded['categories'][1], null, ['data'=>'Hello World!']); - - $this->assertEquals(2, count($post->categories)); - $this->assertEquals('Hello World!', $post->categories()->get()->last()->pivot->data); - } - - public function testTerms() - { - $post = Post::create([ - 'title' => 'B Post', - ]); - - $term1 = Term::create(['name' => 'term #1', 'type' => 'any']); - $term2 = Term::create(['name' => 'term #2', 'type' => 'any']); - $term3 = Term::create(['name' => 'term #3', 'type' => 'any']); - - // Add/remove to collection - $this->assertFalse($post->terms->contains($term1->id)); - $post->terms()->add($term1); - $post->terms()->add($term2); - $this->assertTrue($post->terms->contains($term1->id)); - $this->assertTrue($post->terms->contains($term2->id)); - - // Set by Model object - $post->terms = $term1; - $this->assertEquals(1, $post->terms->count()); - $this->assertEquals('term #1', $post->terms->first()->name); - - $post->terms = [$term1, $term2, $term3]; - $this->assertEquals(3, $post->terms->count()); - - // Set by primary key - $post->terms = $term2->id; - $this->assertEquals(1, $post->terms->count()); - $this->assertEquals('term #2', $post->terms->first()->name); - - $post->terms = [$term2->id, $term3->id]; - $this->assertEquals(2, $post->terms->count()); + use MigratesForTest; - // Nullify - $post->terms = null; - $this->assertEquals(0, $post->terms->count()); - - // Extra nullify checks (still exists in DB until saved) - $post->reloadRelations('terms'); - $this->assertEquals(2, $post->terms->count()); - $post->save(); - $post->reloadRelations('terms'); - $this->assertEquals(0, $post->terms->count()); - - // Deferred in memory - $post->terms = [$term2->id, $term3->id]; - $this->assertEquals(2, $post->terms->count()); - $this->assertEquals('term #2', $post->terms->first()->name); - } - - public function testUndefinedMorphsRelation() + public function testGetRelations() { - $this->expectException(BadMethodCallException::class); + Model::unguard(); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $phone = Phone::create(['number' => '0404040404', 'author_id' => $author->id]); + Model::reguard(); - $morphs = new Morphs; - $morphs->unknownRelation(); - } + $authorModel = Author::find($author->id); + $this->assertEmpty($authorModel->getRelations()); - public function testDefinedMorphsRelation() - { - $morphs = new Morphs; - $this->assertNotEmpty($morphs->related()); - } - - protected function createTables() - { - $this->getBuilder()->create('posts', function ($table) { - $table->increments('id'); - $table->string('title')->default(''); - $table->boolean('published')->nullable(); - $table->dateTime('published_at')->nullable(); - $table->timestamps(); - }); - - $this->getBuilder()->create('terms', function ($table) { - $table->increments('id'); - $table->string('type')->index(); - $table->string('name'); - $table->timestamps(); - }); - - $this->getBuilder()->create('posts_terms', function ($table) { - $table->primary(['post_id', 'term_id']); - $table->unsignedInteger('post_id'); - $table->unsignedInteger('term_id'); - $table->string('data')->nullable(); - $table->timestamps(); - }); - - $this->getBuilder()->create('categories', function ($table) { - $table->increments('id'); - $table->string('name'); - $table->timestamps(); - }); + $authorModel = Author::with('phone')->find($author->id); + $this->assertNotEmpty($authorModel->getRelations()); + $this->assertArrayHasKey('phone', $authorModel->getRelations()); - $this->getBuilder()->create('posts_categories', function ($table) { - $table->primary(['post_id', 'category_id']); - $table->unsignedInteger('post_id'); - $table->unsignedInteger('category_id'); - $table->string('data')->nullable(); - $table->timestamps(); - }); - - $this->seedTables(); + $authorModel = Author::with('contactNumber')->find($author->id); + $this->assertNotEmpty($authorModel->getRelations()); + $this->assertArrayHasKey('contactNumber', $authorModel->getRelations()); } - protected function seedTables() + public function testGetRelationMethods() { - $this->seeded['posts'][] = Post::create([ - 'title' => 'A Post', - 'published' => true, - 'published_at' => Carbon::now()->sub('minutes', 10), - ]); - $this->seeded['posts'][] = Post::create([ - 'title' => 'A Second Post', - 'published' => false, - 'published_at' => null - ]); - - $this->seeded['categories'][] = Category::create([ - 'name' => 'Category 1' - ]); - $this->seeded['categories'][] = Category::create([ - 'name' => 'Category 2' - ]); - - $this->seeded['labels'][] = Term::create(['type' => 'label', 'name' => 'Announcement']); - $this->seeded['labels'][] = Term::create(['type' => 'label', 'name' => 'News']); - - $this->seeded['posts'][0]->labels()->attach($this->seeded['labels'][0]); - $this->seeded['posts'][0]->categories()->attach($this->seeded['categories'][0]); - $this->seeded['posts'][1]->labels()->attach($this->seeded['labels'][1]); - $this->seeded['posts'][1]->categories()->attach($this->seeded['categories'][1]); - - $this->seeded['tags'][] = $this->seeded['posts'][0]->tags()->create(['type' => 'tag', 'name' => 'A Tag']); - $this->seeded['tags'][] = $this->seeded['posts'][0]->tags()->create(['type' => 'tag', 'name' => 'Second Tag']); + $author = new Author(); + $this->assertCount(6, $author->getRelationMethods()); + $this->assertEquals([ + 'contactNumber', + 'messages', + 'scopes', + 'executiveAuthors', + 'info', + 'auditLogs', + ], $author->getRelationMethods()); } } - -class Category extends \Winter\Storm\Database\Model -{ - public $table = 'categories'; - - public $fillable = ['name']; - - protected $dates = [ - 'created_at', - 'updated_at', - ]; - - public $belongsToMany = [ - 'posts' => [ - Post::class, - 'table' => 'posts_categories', - 'order' => 'published_at desc', - 'scope' => 'isPublished' - ] - ]; -} - -class Post extends \Winter\Storm\Database\Model -{ - public $table = 'posts'; - - public $fillable = ['title', 'published', 'published_at']; - - protected $dates = [ - 'created_at', - 'updated_at', - 'published_at', - ]; - - public $belongsToMany = [ - 'categories' => [ - Category::class, - 'table' => 'posts_categories', - 'order' => 'name', - 'pivot' => ['data'], - ], - 'tags' => [ - Term::class, - 'table' => 'posts_terms', - 'key' => 'post_id', - 'otherKey' => 'term_id', - 'pivot' => ['data'], - 'timestamps' => true, - 'conditions' => 'type = "tag"', - ], - 'labels' => [ - Term::class, - 'table' => 'posts_terms', - 'key' => 'post_id', - 'otherKey' => 'term_id', - 'pivot' => ['data'], - 'timestamps' => true, - 'conditions' => 'type = "label"', - ], - 'terms' => [ - Term::class, - 'table' => 'posts_terms', - 'key' => 'post_id', - 'otherKey' => 'term_id', - 'timestamps' => true, - ], - ]; - - public function scopeIsPublished($query) - { - return $query - ->whereNotNull('published') - ->where('published', true) - ->whereNotNull('published_at') - ->where('published_at', '<', Carbon::now()) - ; - } -} - -class Term extends \Winter\Storm\Database\Model -{ - public $table = 'terms'; - - public $fillable = ['type', 'name']; - - protected $dates = [ - 'created_at', - 'updated_at', - 'episode_at' - ]; - - public $belongsToMany = [ - 'posts' => [ - 'Post', - 'table' => 'posts_terms', - 'key' => 'term_id', - 'otherKey' => 'post_id', - 'pivot' => ['data'], - 'timestamps' => true, - 'conditions' => 'type = "post"', - ], - ]; -} - -class Morphs extends \Winter\Storm\Database\Model -{ - public $table = 'morphs'; - - public $morphTo = [ - 'related' => [], - ]; -} From e8e093a15257264ae076368c9fbd5c762eaa7440 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Thu, 22 Feb 2024 20:56:19 +0800 Subject: [PATCH 09/59] Fix Stan issue --- src/Database/Concerns/HasRelationships.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Database/Concerns/HasRelationships.php b/src/Database/Concerns/HasRelationships.php index e5f498227..c1e09e7cd 100644 --- a/src/Database/Concerns/HasRelationships.php +++ b/src/Database/Concerns/HasRelationships.php @@ -1,9 +1,12 @@ -getRelationMethods() as $relation) { + /** @var EloquentRelation */ $relationObj = $this->{$relation}(); if (method_exists($relationObj, 'isDependent')) { if ($relationObj->isDependent()) { - $relationObj->forceDelete(); + $relationObj->get()->each(function ($model) { + $model->forceDelete(); + }); } } } From 583051bb57ffe76695ba5c62374f6fc8c558b18a Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Thu, 22 Feb 2024 21:59:25 +0800 Subject: [PATCH 10/59] Add array schema for relations, add custom return types for relation methods --- src/Database/Concerns/HasRelationships.php | 20 +++ src/Database/Relations/AttachMany.php | 2 + src/Database/Relations/AttachOne.php | 2 + src/Database/Relations/BelongsToMany.php | 7 +- src/Database/Relations/HasMany.php | 7 +- src/Database/Relations/HasOne.php | 7 +- src/Database/Relations/MorphMany.php | 6 +- .../Concerns/HasRelationshipsTest.php | 156 +++++++++++------- tests/Database/Fixtures/Author.php | 2 +- tests/Database/RelationsTest.php | 53 ------ 10 files changed, 144 insertions(+), 118 deletions(-) delete mode 100644 tests/Database/RelationsTest.php diff --git a/src/Database/Concerns/HasRelationships.php b/src/Database/Concerns/HasRelationships.php index c1e09e7cd..766ad9005 100644 --- a/src/Database/Concerns/HasRelationships.php +++ b/src/Database/Concerns/HasRelationships.php @@ -22,6 +22,26 @@ use Winter\Storm\Database\Relations\MorphToMany; use Winter\Storm\Support\Arr; +/** + * Model relationship methods. + * + * The following functionality handles custom relationship functionality for Winter CMS models, extending the base + * Laravel Eloquent relationship functionality. + * + * @method \Winter\Storm\Database\Relations\HasOne hasOne(string $related, string $foreignKey = null, string $localKey = null, string $relationName = null) + * @method \Winter\Storm\Database\Relations\HasOneThrough hasOneThrough(string $related, string $through, string $firstKey = null, string $secondKey = null, string $localKey = null, string $secondLocalKey = null) + * @method \Winter\Storm\Database\Relations\MorphOne morphOne(string $related, string $name, string $type = null, string $id = null, string $localKey = null, string $relationName = null) + * @method \Winter\Storm\Database\Relations\BelongsTo belongsTo(string $related, string $foreignKey = null, string $ownerKey = null, string $relationName = null) + * @method \Winter\Storm\Database\Relations\MorphTo morphTo(string $name = null, string $type = null, string $id = null, string $relationName = null) + * @method \Winter\Storm\Database\Relations\HasMany hasMany(string $related, string $foreignKey = null, string $localKey = null, string $relationName = null) + * @method \Winter\Storm\Database\Relations\HasManyThrough hasManyThrough(string $related, string $through, string $firstKey = null, string $secondKey = null, string $localKey = null, string $secondLocalKey = null) + * @method \Winter\Storm\Database\Relations\MorphMany morphMany(string $related, string $name, string $type = null, string $id = null, string $localKey = null, string $relationName = null) + * @method \Winter\Storm\Database\Relations\BelongsToMany belongsToMany(string $related, string $table = null, string $foreignPivotKey = null, string $relatedPivotKey = null, string $parentKey = null, string $relatedKey = null, string $relationName = null) + * @method \Winter\Storm\Database\Relations\MorphToMany morphToMany(string $related, string $name, string $table = null, string $foreignPivotKey = null, string $relatedPivotKey = null, string $parentKey = null, string $relatedKey = null, bool $inverse = false, string $relationName = null) + * @method \Winter\Storm\Database\Relations\MorphToMany morphedByMany(string $related, string $name, string $table = null, string $foreignPivotKey = null, string $relatedPivotKey = null, string $parentKey = null, string $relatedKey = null, string $relationName = null) + * @method \Winter\Storm\Database\Relations\AttachOne attachOne(string $related, bool $isPublic = true, string $localKey = null, string $relationName = null) + * @method \Winter\Storm\Database\Relations\AttachMany attachMany(string $related, bool $isPublic = null, string $localKey = null, string $relationName = null) + */ trait HasRelationships { /** diff --git a/src/Database/Relations/AttachMany.php b/src/Database/Relations/AttachMany.php index 1bcf3a4e9..67fa2afd3 100644 --- a/src/Database/Relations/AttachMany.php +++ b/src/Database/Relations/AttachMany.php @@ -134,7 +134,9 @@ public function getArrayDefinition(): array { return [ get_class($this->query->getModel()), + 'key' => $this->localKey, 'delete' => $this->isDependent(), + 'public' => $this->public, ]; } } diff --git a/src/Database/Relations/AttachOne.php b/src/Database/Relations/AttachOne.php index e90a130ff..0dfd4f726 100644 --- a/src/Database/Relations/AttachOne.php +++ b/src/Database/Relations/AttachOne.php @@ -116,7 +116,9 @@ public function getArrayDefinition(): array { return [ get_class($this->query->getModel()), + 'key' => $this->localKey, 'delete' => $this->isDependent(), + 'public' => $this->public, ]; } } diff --git a/src/Database/Relations/BelongsToMany.php b/src/Database/Relations/BelongsToMany.php index 553f82b5b..359814fe8 100644 --- a/src/Database/Relations/BelongsToMany.php +++ b/src/Database/Relations/BelongsToMany.php @@ -97,6 +97,11 @@ public function getSimpleValue() */ public function getArrayDefinition(): array { - return []; + return [ + get_class($this->getRelated()), + 'table' => $this->getTable(), + 'key' => $this->getForeignPivotKeyName(), + 'otherKey' => $this->getRelatedKeyName(), + ]; } } diff --git a/src/Database/Relations/HasMany.php b/src/Database/Relations/HasMany.php index 0ba7299b9..ae03bdd19 100644 --- a/src/Database/Relations/HasMany.php +++ b/src/Database/Relations/HasMany.php @@ -83,6 +83,11 @@ public function getSimpleValue() */ public function getArrayDefinition(): array { - return []; + return [ + get_class($this->query->getModel()), + 'key' => $this->getForeignKeyName(), + 'otherKey' => $this->getOtherKey(), + 'delete' => $this->isDependent(), + ]; } } diff --git a/src/Database/Relations/HasOne.php b/src/Database/Relations/HasOne.php index 3d5cee6d5..8314e5aa8 100644 --- a/src/Database/Relations/HasOne.php +++ b/src/Database/Relations/HasOne.php @@ -82,6 +82,11 @@ public function getSimpleValue() */ public function getArrayDefinition(): array { - return []; + return [ + get_class($this->query->getModel()), + 'key' => $this->getForeignKeyName(), + 'otherKey' => $this->getOtherKey(), + 'delete' => $this->isDependent(), + ]; } } diff --git a/src/Database/Relations/MorphMany.php b/src/Database/Relations/MorphMany.php index 3aab970fa..3ed913ff2 100644 --- a/src/Database/Relations/MorphMany.php +++ b/src/Database/Relations/MorphMany.php @@ -91,6 +91,10 @@ public function getSimpleValue() */ public function getArrayDefinition(): array { - return []; + return [ + get_class($this->query->getModel()), + 'name' => $this->morphType, + 'delete' => $this->isDependent(), + ]; } } diff --git a/tests/Database/Concerns/HasRelationshipsTest.php b/tests/Database/Concerns/HasRelationshipsTest.php index 6a622eebf..7cd4c28c0 100644 --- a/tests/Database/Concerns/HasRelationshipsTest.php +++ b/tests/Database/Concerns/HasRelationshipsTest.php @@ -1,76 +1,112 @@ assertEquals([], $model->getRelationTypeDefinitions('belongsToMany')); - $this->assertEquals([ - 'relatedModel' => 'TestModelNoRelation', - 'anotherRelatedModel' => [ - 'TestModelNoRelation', - 'order' => 'name desc', - ], - ], $model->getRelationTypeDefinitions('belongsTo')); - } + $author = new Author(); - public function testDynamicGetRelationTypeDefinitions() - { - TestModelBelongsTo::extend(function ($model) { - $model->belongsTo['dynamicRelatedModel'] = 'TestModelNoRelation'; - }); - $model = new TestModelBelongsTo(); - $this->assertEquals([ - 'relatedModel' => 'TestModelNoRelation', - 'anotherRelatedModel' => [ - 'TestModelNoRelation', - 'order' => 'name desc', - ], - 'dynamicRelatedModel' => 'TestModelNoRelation' - ], $model->getRelationTypeDefinitions('belongsTo')); + // Array style + $this->assertTrue($author->hasRelation('user')); + $this->assertTrue($author->hasRelation('country')); + $this->assertTrue($author->hasRelation('posts')); + $this->assertTrue($author->hasRelation('phone')); + $this->assertTrue($author->hasRelation('roles')); + $this->assertTrue($author->hasRelation('event_log')); + $this->assertTrue($author->hasRelation('meta')); + $this->assertTrue($author->hasRelation('tags')); + + // Laravel style + $this->assertTrue($author->hasRelation('contactNumber')); + $this->assertTrue($author->hasRelation('messages')); + $this->assertTrue($author->hasRelation('scopes')); + $this->assertTrue($author->hasRelation('executiveAuthors')); + $this->assertTrue($author->hasRelation('info')); + $this->assertTrue($author->hasRelation('auditLogs')); + + $this->assertFalse($author->hasRelation('invalid')); } - public function testGetRelationTypeDefinition() + public function testGetRelationType() { - $model = new TestModelBelongsTo(); - $this->assertEquals(null, $model->getRelationTypeDefinition('belongsTo', 'nonExistantRelation')); - $this->assertEquals('TestModelNoRelation', $model->getRelationTypeDefinition('belongsTo', 'relatedModel')); - $this->assertEquals(['TestModelNoRelation', 'order' => 'name desc'], $model->getRelationTypeDefinition('belongsTo', 'anotherRelatedModel')); - $this->assertEquals(null, $model->getRelationTypeDefinition('belongsToMany', 'nonExistantRelation')); - $this->assertEquals(null, $model->getRelationTypeDefinition('belongsToMany', 'relatedModel')); + $author = new Author(); + + // Array style + $this->assertEquals('belongsTo', $author->getRelationType('user')); + $this->assertEquals('belongsTo', $author->getRelationType('country')); + $this->assertEquals('hasMany', $author->getRelationType('posts')); + $this->assertEquals('hasOne', $author->getRelationType('phone')); + $this->assertEquals('belongsToMany', $author->getRelationType('roles')); + $this->assertEquals('morphMany', $author->getRelationType('event_log')); + $this->assertEquals('morphOne', $author->getRelationType('meta')); + $this->assertEquals('morphToMany', $author->getRelationType('tags')); + + // Laravel style + $this->assertEquals('hasOne', $author->getRelationType('contactNumber')); + $this->assertEquals('hasMany', $author->getRelationType('messages')); + $this->assertEquals('belongsToMany', $author->getRelationType('scopes')); + $this->assertEquals('belongsToMany', $author->getRelationType('executiveAuthors')); + $this->assertEquals('morphOne', $author->getRelationType('info')); + $this->assertEquals('morphMany', $author->getRelationType('auditLogs')); + + $this->assertNull($author->getRelationType('invalid')); } - public function testDynamicGetRelationTypeDefinition() + public function testGetRelationDefinition() { - TestModelBelongsTo::extend(function ($model) { - $model->belongsTo['dynamicRelatedModel'] = 'TestModelNoRelation'; - }); - $model = new TestModelBelongsTo(); - $this->assertEquals('TestModelNoRelation', $model->getRelationTypeDefinition('belongsTo', 'dynamicRelatedModel')); - } -} + $author = new Author(); -/* - * Class with belongsTo relation - */ -class TestModelBelongsTo extends Model -{ - public $belongsTo = [ - 'relatedModel' => 'TestModelNoRelation', - 'anotherRelatedModel' => [ - 'TestModelNoRelation', - 'order' => 'name desc', - ] - ]; -} + // Array style + $this->assertEquals([User::class, 'delete' => true], $author->getRelationDefinition('user')); + $this->assertEquals([Country::class], $author->getRelationDefinition('country')); + $this->assertEquals([Post::class], $author->getRelationDefinition('posts')); + $this->assertEquals([Phone::class], $author->getRelationDefinition('phone')); + $this->assertEquals([ + Role::class, + 'table' => 'database_tester_authors_roles' + ], $author->getRelationDefinition('roles')); + $this->assertEquals([EventLog::class, 'name' => 'related', 'delete' => true, 'softDelete' => true], $author->getRelationDefinition('event_log')); + $this->assertEquals([Meta::class, 'name' => 'taggable'], $author->getRelationDefinition('meta')); + $this->assertEquals([ + Tag::class, + 'name' => 'taggable', + 'table' => 'database_tester_taggables', + 'pivot' => ['added_by'] + ], $author->getRelationDefinition('tags')); -/* - * Class with no belongsTo relation - */ -class TestModelNoRelation extends Model -{ + // Laravel style + $this->assertEquals([ + Phone::class, + 'key' => 'author_id', + 'otherKey' => 'id', + 'delete' => false, + ], $author->getRelationDefinition('contactNumber')); + $this->assertEquals([ + Post::class, + 'key' => 'author_id', + 'otherKey' => 'id', + 'delete' => false, + ], $author->getRelationDefinition('messages')); + $this->assertEquals([ + Role::class, + 'table' => 'database_tester_authors_roles', + 'key' => 'author_id', + 'otherKey' => 'id' + ], $author->getRelationDefinition('scopes')); + $this->assertNull($author->getRelationDefinition('invalid')); + } } diff --git a/tests/Database/Fixtures/Author.php b/tests/Database/Fixtures/Author.php index aa39e2f13..7e2304372 100644 --- a/tests/Database/Fixtures/Author.php +++ b/tests/Database/Fixtures/Author.php @@ -94,7 +94,7 @@ public function info() #[Relation] public function auditLogs() { - return $this->morphMany(EventLog::class, 'related'); + return $this->morphMany(EventLog::class, 'related')->dependent(); } public static function migrateUp(Builder $builder): void diff --git a/tests/Database/RelationsTest.php b/tests/Database/RelationsTest.php deleted file mode 100644 index 4344edb0a..000000000 --- a/tests/Database/RelationsTest.php +++ /dev/null @@ -1,53 +0,0 @@ - 'Stevie', 'email' => 'stevie@example.com']); - $phone = Phone::create(['number' => '0404040404', 'author_id' => $author->id]); - Model::reguard(); - - $authorModel = Author::find($author->id); - $this->assertEmpty($authorModel->getRelations()); - - $authorModel = Author::with('phone')->find($author->id); - $this->assertNotEmpty($authorModel->getRelations()); - $this->assertArrayHasKey('phone', $authorModel->getRelations()); - - $authorModel = Author::with('contactNumber')->find($author->id); - $this->assertNotEmpty($authorModel->getRelations()); - $this->assertArrayHasKey('contactNumber', $authorModel->getRelations()); - } - - public function testGetRelationMethods() - { - $author = new Author(); - $this->assertCount(6, $author->getRelationMethods()); - $this->assertEquals([ - 'contactNumber', - 'messages', - 'scopes', - 'executiveAuthors', - 'info', - 'auditLogs', - ], $author->getRelationMethods()); - } -} From 173c752bf24a553b7e336d6603455075cd7e7c7e Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Thu, 22 Feb 2024 22:14:13 +0800 Subject: [PATCH 11/59] Fix relation name for attachments --- src/Database/Concerns/HasRelationships.php | 26 +++++++++++----------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/Database/Concerns/HasRelationships.php b/src/Database/Concerns/HasRelationships.php index 766ad9005..a1825156e 100644 --- a/src/Database/Concerns/HasRelationships.php +++ b/src/Database/Concerns/HasRelationships.php @@ -412,7 +412,7 @@ protected function handleRelation($relationName) case 'attachMany': $relation = $this->validateRelationArgs($relationName, ['public', 'key']); /** @var AttachOne|AttachMany */ - $relationObj = $this->$relationType($relation[0], $relation['public'], $relation['key'], $relationName); + $relationObj = $this->$relationType($relation[0], $relation['public'], $relation['key']); if ($relation['delete'] ?? false) { $relationObj->dependent(); @@ -819,12 +819,8 @@ protected function newMorphToMany( * This code is a duplicate of Eloquent but uses a Storm relation class. * @return \Winter\Storm\Database\Relations\AttachOne */ - public function attachOne($related, $isPublic = true, $localKey = null, $relationName = null) + public function attachOne($related, $isPublic = true, $localKey = null) { - if (is_null($relationName)) { - $relationName = $this->getRelationCaller(); - } - $instance = $this->newRelatedInstance($related); list($type, $id) = $this->getMorphs('attachment', null, null); @@ -834,7 +830,11 @@ public function attachOne($related, $isPublic = true, $localKey = null, $relatio $localKey = $localKey ?: $this->getKeyName(); $relation = new AttachOne($instance->newQuery(), $this, $table . '.' . $type, $table . '.' . $id, $isPublic, $localKey); - $relation->setRelationName($this->getRelationCaller()); + $caller = $this->getRelationCaller(); + if (!is_null($caller)) { + $relation->setRelationName($caller); + } + return $relation; } @@ -843,12 +843,8 @@ public function attachOne($related, $isPublic = true, $localKey = null, $relatio * This code is a duplicate of Eloquent but uses a Storm relation class. * @return \Winter\Storm\Database\Relations\AttachMany */ - public function attachMany($related, $isPublic = null, $localKey = null, $relationName = null) + public function attachMany($related, $isPublic = null, $localKey = null) { - if (is_null($relationName)) { - $relationName = $this->getRelationCaller(); - } - $instance = $this->newRelatedInstance($related); list($type, $id) = $this->getMorphs('attachment', null, null); @@ -858,7 +854,11 @@ public function attachMany($related, $isPublic = null, $localKey = null, $relati $localKey = $localKey ?: $this->getKeyName(); $relation = new AttachMany($instance->newQuery(), $this, $table . '.' . $type, $table . '.' . $id, $isPublic, $localKey); - $relation->setRelationName($this->getRelationCaller()); + $caller = $this->getRelationCaller(); + if (!is_null($caller)) { + $relation->setRelationName($caller); + } + return $relation; } From 39453b122195dda176f3a7be13fc042dd7c75c06 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Fri, 23 Feb 2024 10:25:37 +0800 Subject: [PATCH 12/59] Allow dependent model to be marked as not dependent --- src/Database/Relations/Concerns/CanBeDependent.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Database/Relations/Concerns/CanBeDependent.php b/src/Database/Relations/Concerns/CanBeDependent.php index f724a8869..ce547093b 100644 --- a/src/Database/Relations/Concerns/CanBeDependent.php +++ b/src/Database/Relations/Concerns/CanBeDependent.php @@ -53,6 +53,16 @@ public function dependent(): static return $this; } + /** + * Mark the relationship as independent of the primary model. + */ + public function notDependent(): static + { + $this->dependent = false; + + return $this; + } + /** * Determine if the relationship is dependent on the primary model. */ From ce5ddf0bbc110c1936c2d892650e2fcd0c22cdb5 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Fri, 23 Feb 2024 11:28:17 +0800 Subject: [PATCH 13/59] Shore up API, strengthen types, remove validation method The "validateRelationArgs" method does a whole lot of nothing - the most it does is enforce required parameters. These can be picked up in Laravel anyway. --- src/Database/Concerns/HasRelationships.php | 321 ++++++++++----------- 1 file changed, 157 insertions(+), 164 deletions(-) diff --git a/src/Database/Concerns/HasRelationships.php b/src/Database/Concerns/HasRelationships.php index a1825156e..415a07949 100644 --- a/src/Database/Concerns/HasRelationships.php +++ b/src/Database/Concerns/HasRelationships.php @@ -28,19 +28,19 @@ * The following functionality handles custom relationship functionality for Winter CMS models, extending the base * Laravel Eloquent relationship functionality. * - * @method \Winter\Storm\Database\Relations\HasOne hasOne(string $related, string $foreignKey = null, string $localKey = null, string $relationName = null) - * @method \Winter\Storm\Database\Relations\HasOneThrough hasOneThrough(string $related, string $through, string $firstKey = null, string $secondKey = null, string $localKey = null, string $secondLocalKey = null) - * @method \Winter\Storm\Database\Relations\MorphOne morphOne(string $related, string $name, string $type = null, string $id = null, string $localKey = null, string $relationName = null) - * @method \Winter\Storm\Database\Relations\BelongsTo belongsTo(string $related, string $foreignKey = null, string $ownerKey = null, string $relationName = null) - * @method \Winter\Storm\Database\Relations\MorphTo morphTo(string $name = null, string $type = null, string $id = null, string $relationName = null) - * @method \Winter\Storm\Database\Relations\HasMany hasMany(string $related, string $foreignKey = null, string $localKey = null, string $relationName = null) - * @method \Winter\Storm\Database\Relations\HasManyThrough hasManyThrough(string $related, string $through, string $firstKey = null, string $secondKey = null, string $localKey = null, string $secondLocalKey = null) - * @method \Winter\Storm\Database\Relations\MorphMany morphMany(string $related, string $name, string $type = null, string $id = null, string $localKey = null, string $relationName = null) - * @method \Winter\Storm\Database\Relations\BelongsToMany belongsToMany(string $related, string $table = null, string $foreignPivotKey = null, string $relatedPivotKey = null, string $parentKey = null, string $relatedKey = null, string $relationName = null) - * @method \Winter\Storm\Database\Relations\MorphToMany morphToMany(string $related, string $name, string $table = null, string $foreignPivotKey = null, string $relatedPivotKey = null, string $parentKey = null, string $relatedKey = null, bool $inverse = false, string $relationName = null) - * @method \Winter\Storm\Database\Relations\MorphToMany morphedByMany(string $related, string $name, string $table = null, string $foreignPivotKey = null, string $relatedPivotKey = null, string $parentKey = null, string $relatedKey = null, string $relationName = null) - * @method \Winter\Storm\Database\Relations\AttachOne attachOne(string $related, bool $isPublic = true, string $localKey = null, string $relationName = null) - * @method \Winter\Storm\Database\Relations\AttachMany attachMany(string $related, bool $isPublic = null, string $localKey = null, string $relationName = null) + * @method \Winter\Storm\Database\Relations\HasOne hasOne(string $related, string|null $foreignKey = null, string|null $localKey = null) + * @method \Winter\Storm\Database\Relations\HasOneThrough hasOneThrough(string $related, string $through, string|null $firstKey = null, string|null $secondKey = null, string|null $localKey = null, string|null $secondLocalKey = null) + * @method \Winter\Storm\Database\Relations\MorphOne morphOne(string $related, string $name, string|null $type = null, string|null $id = null, string|null $localKey = null) + * @method \Winter\Storm\Database\Relations\BelongsTo belongsTo(string $related, string|null $foreignKey = null, string|null $ownerKey = null, string|null $relation = null) + * @method \Winter\Storm\Database\Relations\MorphTo morphTo(string|null $name = null, string|null $type = null, string|null $id = null, string|null $ownerKey = null) + * @method \Winter\Storm\Database\Relations\HasMany hasMany(string $related, string|null $foreignKey = null, string|null $localKey = null) + * @method \Winter\Storm\Database\Relations\HasManyThrough hasManyThrough(string $related, string $through, string|null $firstKey = null, string|null $secondKey = null, string|null $localKey = null, string|null $secondLocalKey = null) + * @method \Winter\Storm\Database\Relations\MorphMany morphMany(string $related, string $name, string|null $type = null, string|null $id = null, string|null $localKey = null) + * @method \Winter\Storm\Database\Relations\BelongsToMany belongsToMany(string $related, string|null $table = null, string|null $foreignPivotKey = null, string|null $relatedPivotKey = null, string|null $parentKey = null, string|null $relatedKey = null, string|null $relation = null) + * @method \Winter\Storm\Database\Relations\MorphToMany morphToMany(string $related, string $name, string|null $table = null, string|null $foreignPivotKey = null, string|null $relatedPivotKey = null, string|null $parentKey = null, string|null $relatedKey = null, bool $inverse = false) + * @method \Winter\Storm\Database\Relations\MorphToMany morphedByMany(string $related, string $name, string|null $table = null, string|null $foreignPivotKey = null, string|null $relatedPivotKey = null, string|null $parentKey = null, string|null $relatedKey = null) + * @method \Winter\Storm\Database\Relations\AttachOne attachOne(string $related, bool $isPublic = true, string|null $localKey = null) + * @method \Winter\Storm\Database\Relations\AttachMany attachMany(string $related, bool $isPublic = null, string|null $localKey = null) */ trait HasRelationships { @@ -172,9 +172,8 @@ trait HasRelationships /** * Checks if model has a relationship by supplied name. - * @param string $name Relation name */ - public function hasRelation($name): bool + public function hasRelation(string $name): bool { if (method_exists($this, $name) && $this->isRelationMethod($name)) { return true; @@ -185,9 +184,8 @@ public function hasRelation($name): bool /** * Returns relationship details from a supplied name. - * @param string $name Relation name */ - public function getRelationDefinition($name): ?array + public function getRelationDefinition(string $name): ?array { if (method_exists($this, $name) && $this->isRelationMethod($name)) { return $this->relationMethodDefinition($name); @@ -202,13 +200,13 @@ public function getRelationDefinition($name): ?array /** * Returns all defined relations of given type. - * @param string $type Relation type - * @return array|string|null */ - public function getRelationTypeDefinitions($type) + public function getRelationTypeDefinitions(string $type): array { if (in_array($type, array_keys(static::$relationTypes))) { - return $this->{$type}; + return array_map(function ($relation) { + return (is_string($relation)) ? [$relation] : $relation; + }, $this->{$type}); } return []; @@ -216,11 +214,10 @@ public function getRelationTypeDefinitions($type) /** * Returns the given relation definition. - * @param string $type Relation type - * @param string $name Relation name - * @return string|null + * + * If no relation exists by the given name and type, `null` will be returned. */ - public function getRelationTypeDefinition($type, $name) + public function getRelationTypeDefinition(string $type, string $name): array|null { $definitions = $this->getRelationTypeDefinitions($type); @@ -289,12 +286,12 @@ public function makeRelation(string $name): ?Model } /** - * Determines whether the specified relation should be saved - * when push() is called instead of save() on the model. Default: true. - * @param string $name Relation name - * @return boolean + * Save relation on push. + * + * Determines whether the specified relation should be saved when `push()` is called on the model, instead of + * `save()`. By default, this will be true. */ - public function isRelationPushable($name) + public function isRelationPushable(string $name): bool { $definition = $this->getRelationDefinition($name); if (is_null($definition) || !array_key_exists('push', $definition)) { @@ -305,11 +302,9 @@ public function isRelationPushable($name) } /** - * Returns default relation arguments for a given type. - * @param string $type Relation type - * @return array + * Returns default relation arguments for a given relation type. */ - protected function getRelationDefaults($type) + protected function getRelationDefaults(string $type): array { switch ($type) { case 'attachOne': @@ -322,163 +317,180 @@ protected function getRelationDefaults($type) } /** - * Looks for the relation and does the correct magic as Eloquent would require - * inside relation methods. For more information, read the documentation of the mentioned property. - * @param string $relationName the relation key, camel-case version - * @return \Illuminate\Database\Eloquent\Relations\Relation + * Creates a Laravel relation object from a Winter relation definition array. + * + * Winter has traditionally used array properties in the model to configure relationships. This method converts + * these to the applicable Laravel relation object. */ - protected function handleRelation($relationName) + protected function handleRelation(string $relationName): EloquentRelation { $relationType = $this->getRelationType($relationName); - $relation = $this->getRelationDefinition($relationName); + $definition = $this->getRelationDefinition($relationName); + $relatedClass = $definition[0] ?? null; - if (!isset($relation[0]) && $relationType != 'morphTo') { + if (!isset($relatedClass) && $relationType != 'morphTo') { throw new InvalidArgumentException(sprintf( - "Relation '%s' on model '%s' should have at least a classname.", + "Relation '%s' on model '%s' must specify a related class name.", $relationName, get_called_class() )); } - if (isset($relation[0]) && $relationType == 'morphTo') { + if (isset($relatedClass) && $relationType == 'morphTo') { throw new InvalidArgumentException(sprintf( - "Relation '%s' on model '%s' is a morphTo relation and should not contain additional arguments.", + "Relation '%s' on model '%s' is a morphTo relation and should not specify a related class name.", $relationName, get_called_class() )); } + // Create relation object based on relation switch ($relationType) { case 'hasOne': + $relation = $this->hasOne( + $relatedClass, + $definition['key'] ?? null, + $definition['otherKey'] ?? null, + ); + break; case 'hasMany': - $relation = $this->validateRelationArgs($relationName, ['key', 'otherKey']); - /** @var HasOne|HasMany */ - $relationObj = $this->$relationType($relation[0], $relation['key'], $relation['otherKey'], $relationName); - - if ($relation['delete'] ?? false) { - $relationObj->dependent(); - } - + $relation = $this->hasMany( + $relatedClass, + $definition['key'] ?? null, + $definition['otherKey'] ?? null, + ); break; - case 'belongsTo': - $relation = $this->validateRelationArgs($relationName, ['key', 'otherKey']); - $relationObj = $this->$relationType($relation[0], $relation['key'], $relation['otherKey'], $relationName); + $relation = $this->belongsTo( + $relatedClass, + $definition['key'] ?? null, + $definition['otherKey'] ?? null, + $relationName, + ); break; - case 'belongsToMany': - $relation = $this->validateRelationArgs($relationName, ['table', 'key', 'otherKey', 'parentKey', 'relatedKey', 'pivot', 'timestamps']); - $relationObj = $this->$relationType($relation[0], $relation['table'], $relation['key'], $relation['otherKey'], $relation['parentKey'], $relation['relatedKey'], $relationName); - - if (isset($relation['pivotModel'])) { - $relationObj->using($relation['pivotModel']); + $relation = $this->belongsToMany( + $relatedClass, + $definition['table'] ?? null, + $definition['key'] ?? null, + $definition['otherKey'] ?? null, + $definition['parentKey'] ?? null, + $definition['relatedKey'] ?? null, + $relationName, + ); + if (isset($definition['pivotModel'])) { + $relation->using($definition['pivotModel']); } - break; - case 'morphTo': - $relation = $this->validateRelationArgs($relationName, ['name', 'type', 'id']); - $relationObj = $this->$relationType($relation['name'] ?: $relationName, $relation['type'], $relation['id']); + $relation = $this->morphTo( + $definition['name'] ?? $relationName, + $definition['type'] ?? null, + $definition['id'] ?? null, + $definition['otherKey'] ?? null, + ); break; - case 'morphOne': + $relation = $this->morphOne( + $relatedClass, + $definition['name'], + $definition['type'] ?? null, + $definition['id'] ?? null, + $definition['key'] ?? null, + ); + break; case 'morphMany': - $relation = $this->validateRelationArgs($relationName, ['type', 'id', 'key'], ['name']); - /** @var MorphOne|MorphMany */ - $relationObj = $this->$relationType($relation[0], $relation['name'], $relation['type'], $relation['id'], $relation['key'], $relationName); - - if ($relation['delete'] ?? false) { - $relationObj->dependent(); - } - + $relation = $this->morphMany( + $relatedClass, + $definition['name'], + $definition['type'] ?? null, + $definition['id'] ?? null, + $definition['key'] ?? null, + ); break; - case 'morphToMany': - $relation = $this->validateRelationArgs($relationName, ['table', 'key', 'otherKey', 'parentKey', 'relatedKey', 'pivot', 'timestamps'], ['name']); - $relationObj = $this->$relationType($relation[0], $relation['name'], $relation['table'], $relation['key'], $relation['otherKey'], $relation['parentKey'], $relation['relatedKey'], false, $relationName); - - if (isset($relation['pivotModel'])) { - $relationObj->using($relation['pivotModel']); + $relation = $this->morphToMany( + $relatedClass, + $definition['name'], + $definition['table'] ?? null, + $definition['key'] ?? null, + $definition['otherKey'] ?? null, + $definition['parentKey'] ?? null, + $definition['relatedKey'] ?? null, + $definition['inverse'] ?? false, + ); + if (isset($definition['pivotModel'])) { + $relation->using($definition['pivotModel']); } - break; - case 'morphedByMany': - $relation = $this->validateRelationArgs($relationName, ['table', 'key', 'otherKey', 'parentKey', 'relatedKey', 'pivot', 'timestamps'], ['name']); - $relationObj = $this->$relationType($relation[0], $relation['name'], $relation['table'], $relation['key'], $relation['otherKey'], $relation['parentKey'], $relation['relatedKey'], $relationName); + $relation = $this->morphedByMany( + $relatedClass, + $definition['name'], + $definition['table'] ?? null, + $definition['key'] ?? null, + $definition['otherKey'] ?? null, + $definition['parentKey'] ?? null, + $definition['relatedKey'] ?? null, + ); break; - case 'attachOne': + $relation = $this->attachOne( + $relatedClass, + $definition['public'] ?? true, + $definition['key'] ?? null, + ); + break; case 'attachMany': - $relation = $this->validateRelationArgs($relationName, ['public', 'key']); - /** @var AttachOne|AttachMany */ - $relationObj = $this->$relationType($relation[0], $relation['public'], $relation['key']); - - if ($relation['delete'] ?? false) { - $relationObj->dependent(); - } - + $relation = $this->attachMany( + $relatedClass, + $definition['public'] ?? true, + $definition['key'] ?? null, + ); break; - case 'hasOneThrough': + $relation = $this->hasOneThrough( + $relatedClass, + $definition['through'], + $definition['key'] ?? null, + $definition['throughKey'] ?? null, + $definition['otherKey'] ?? null, + $definition['secondOtherKey'] ?? null, + ); + break; case 'hasManyThrough': - $relation = $this->validateRelationArgs($relationName, ['key', 'throughKey', 'otherKey', 'secondOtherKey'], ['through']); - $relationObj = $this->$relationType($relation[0], $relation['through'], $relation['key'], $relation['throughKey'], $relation['otherKey'], $relation['secondOtherKey']); + $relation = $this->hasManyThrough( + $relatedClass, + $definition['through'], + $definition['key'] ?? null, + $definition['throughKey'] ?? null, + $definition['otherKey'] ?? null, + $definition['secondOtherKey'] ?? null, + ); 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, + get_called_class() + ) + ); } // Add relation name - $relationObj->setRelationName($relationName); + $relation->setRelationName($relationName); // Add defined constraints - $relationObj->addDefinedConstraints(); - - return $relationObj; - } - - /** - * Validate relation supplied arguments. - */ - protected function validateRelationArgs($relationName, $optional, $required = []) - { - $relation = $this->getRelationDefinition($relationName); - - // Query filter arguments - $filters = ['scope', 'conditions', 'order', 'pivot', 'timestamps', 'push', 'count', 'default']; - - foreach (array_merge($optional, $filters) as $key) { - if (!array_key_exists($key, $relation)) { - $relation[$key] = null; - } - } - - $missingRequired = []; - foreach ($required as $key) { - if (!array_key_exists($key, $relation)) { - $missingRequired[] = $key; - } - } - - if ($missingRequired) { - throw new InvalidArgumentException(sprintf( - 'Relation "%s" on model "%s" should contain the following key(s): %s', - $relationName, - get_called_class(), - implode(', ', $missingRequired) - )); - } + $relation->addDefinedConstraints(); return $relation; } - /** * Finds the calling function name from the stack trace. */ - protected function getRelationCaller() + protected function getRelationCaller(): ?string { $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); @@ -514,30 +526,15 @@ public function getRelationValue($relationName) /** * Sets a relation value directly from its attribute. */ - protected function setRelationValue($relationName, $value) + protected function setRelationValue(string $relationName, $value): void { $this->$relationName()->setSimpleValue($value); } /** - * Get the polymorphic relationship columns. - * - * @param string $name - * @param string|null $type - * @param string|null $id - * @return array - */ - protected function getMorphs($name, $type = null, $id = null) - { - return [$type ?: $name.'_type', $id ?: $name.'_id']; - } - - /** - * Locates relations with delete flag and cascades the delete event. - * For pivot relations, detach the pivot record unless the detach flag is false. - * @return void + * Perform cascading deletions on related models that are dependent on the primary model. */ - protected function performDeleteOnRelations() + protected function performDeleteOnRelations(): void { $definitions = $this->getRelationDefinitions(); foreach ($definitions as $type => $relations) { @@ -597,7 +594,7 @@ public function getRelationMethods(): array $relationMethods = []; foreach (get_class_methods($this) as $method) { - if ($this->isRelationMethod($method)) { + if (!in_array($method, ['attachOne', 'attachMany']) && $this->isRelationMethod($method)) { $relationMethods[] = $method; } } @@ -815,11 +812,9 @@ protected function newMorphToMany( } /** - * Define an attachment one-to-one relationship. - * This code is a duplicate of Eloquent but uses a Storm relation class. - * @return \Winter\Storm\Database\Relations\AttachOne + * Define an attachment one-to-one (morphOne) relationship. */ - public function attachOne($related, $isPublic = true, $localKey = null) + public function attachOne($related, $isPublic = true, $localKey = null): AttachOne { $instance = $this->newRelatedInstance($related); @@ -839,11 +834,9 @@ public function attachOne($related, $isPublic = true, $localKey = null) } /** - * Define an attachment one-to-many relationship. - * This code is a duplicate of Eloquent but uses a Storm relation class. - * @return \Winter\Storm\Database\Relations\AttachMany + * Define an attachment one-to-many (morphMany) relationship. */ - public function attachMany($related, $isPublic = null, $localKey = null) + public function attachMany($related, $isPublic = null, $localKey = null): AttachMany { $instance = $this->newRelatedInstance($related); From 8594a134b65b7bd3bf86d0b0b5c27c7ad7880e21 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Fri, 23 Feb 2024 11:33:20 +0800 Subject: [PATCH 14/59] Minor --- src/Database/Concerns/HasRelationships.php | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/Database/Concerns/HasRelationships.php b/src/Database/Concerns/HasRelationships.php index 415a07949..1e3cde9ae 100644 --- a/src/Database/Concerns/HasRelationships.php +++ b/src/Database/Concerns/HasRelationships.php @@ -818,13 +818,12 @@ public function attachOne($related, $isPublic = true, $localKey = null): AttachO { $instance = $this->newRelatedInstance($related); - list($type, $id) = $this->getMorphs('attachment', null, null); - $table = $instance->getTable(); $localKey = $localKey ?: $this->getKeyName(); - $relation = new AttachOne($instance->newQuery(), $this, $table . '.' . $type, $table . '.' . $id, $isPublic, $localKey); + $relation = new AttachOne($instance->newQuery(), $this, $table . '.attachment_type', $table . '.attachment_id', $isPublic, $localKey); + $caller = $this->getRelationCaller(); if (!is_null($caller)) { $relation->setRelationName($caller); @@ -840,13 +839,12 @@ public function attachMany($related, $isPublic = null, $localKey = null): Attach { $instance = $this->newRelatedInstance($related); - list($type, $id) = $this->getMorphs('attachment', null, null); - $table = $instance->getTable(); $localKey = $localKey ?: $this->getKeyName(); - $relation = new AttachMany($instance->newQuery(), $this, $table . '.' . $type, $table . '.' . $id, $isPublic, $localKey); + $relation = new AttachMany($instance->newQuery(), $this, $table . '.attachment_type', $table . '.attachment_id', $isPublic, $localKey); + $caller = $this->getRelationCaller(); if (!is_null($caller)) { $relation->setRelationName($caller); From 7fd53cd248b4ccbe88926819986fa5c2d8971306 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Fri, 23 Feb 2024 14:39:34 +0800 Subject: [PATCH 15/59] Add ability to define model as pushable, add all relation definitions --- src/Database/Relations/AttachMany.php | 2 + src/Database/Relations/AttachOne.php | 2 + src/Database/Relations/BelongsTo.php | 8 ++- src/Database/Relations/BelongsToMany.php | 10 ++- .../Relations/Concerns/CanBePushed.php | 69 +++++++++++++++++++ src/Database/Relations/HasMany.php | 2 + src/Database/Relations/HasManyThrough.php | 11 ++- src/Database/Relations/HasOne.php | 2 + src/Database/Relations/HasOneThrough.php | 11 ++- src/Database/Relations/MorphMany.php | 6 +- src/Database/Relations/MorphOne.php | 9 ++- src/Database/Relations/MorphTo.php | 8 ++- src/Database/Relations/MorphToMany.php | 18 ++++- .../Concerns/HasRelationshipsTest.php | 30 +++++++- tests/Database/Fixtures/Author.php | 6 ++ 15 files changed, 185 insertions(+), 9 deletions(-) create mode 100644 src/Database/Relations/Concerns/CanBePushed.php diff --git a/src/Database/Relations/AttachMany.php b/src/Database/Relations/AttachMany.php index 67fa2afd3..5371e67d8 100644 --- a/src/Database/Relations/AttachMany.php +++ b/src/Database/Relations/AttachMany.php @@ -14,6 +14,7 @@ class AttachMany extends MorphManyBase implements Relation { use Concerns\AttachOneOrMany; use Concerns\CanBeDependent; + use Concerns\CanBePushed; use Concerns\DefinedConstraints; use Concerns\HasRelationName; @@ -137,6 +138,7 @@ public function getArrayDefinition(): array 'key' => $this->localKey, 'delete' => $this->isDependent(), 'public' => $this->public, + 'push' => $this->isPushable(), ]; } } diff --git a/src/Database/Relations/AttachOne.php b/src/Database/Relations/AttachOne.php index 0dfd4f726..4d96ee69c 100644 --- a/src/Database/Relations/AttachOne.php +++ b/src/Database/Relations/AttachOne.php @@ -14,6 +14,7 @@ class AttachOne extends MorphOneBase implements Relation { use Concerns\AttachOneOrMany; use Concerns\CanBeDependent; + use Concerns\CanBePushed; use Concerns\DefinedConstraints; use Concerns\HasRelationName; @@ -119,6 +120,7 @@ public function getArrayDefinition(): array 'key' => $this->localKey, 'delete' => $this->isDependent(), 'public' => $this->public, + 'push' => $this->isPushable(), ]; } } diff --git a/src/Database/Relations/BelongsTo.php b/src/Database/Relations/BelongsTo.php index 4be2daafa..9774754f7 100644 --- a/src/Database/Relations/BelongsTo.php +++ b/src/Database/Relations/BelongsTo.php @@ -12,6 +12,7 @@ class BelongsTo extends BelongsToBase implements Relation { use Concerns\BelongsOrMorphsTo; + use Concerns\CanBePushed; use Concerns\DeferOneOrMany; use Concerns\DefinedConstraints; use Concerns\HasRelationName; @@ -91,6 +92,11 @@ public function getOtherKey() */ public function getArrayDefinition(): array { - return []; + return [ + get_class($this->query->getModel()), + 'key' => $this->getForeignKeyName(), + 'otherKey' => $this->getOtherKey(), + 'push' => $this->isPushable(), + ]; } } diff --git a/src/Database/Relations/BelongsToMany.php b/src/Database/Relations/BelongsToMany.php index 359814fe8..c65869902 100644 --- a/src/Database/Relations/BelongsToMany.php +++ b/src/Database/Relations/BelongsToMany.php @@ -9,6 +9,7 @@ class BelongsToMany extends BelongsToManyBase implements Relation { use Concerns\BelongsOrMorphsToMany; + use Concerns\CanBePushed; use Concerns\DeferOneOrMany; use Concerns\DefinedConstraints; use Concerns\HasRelationName; @@ -97,11 +98,18 @@ public function getSimpleValue() */ public function getArrayDefinition(): array { - return [ + $definition = [ get_class($this->getRelated()), 'table' => $this->getTable(), 'key' => $this->getForeignPivotKeyName(), 'otherKey' => $this->getRelatedKeyName(), + 'push' => $this->isPushable(), ]; + + if (count($this->pivotColumns)) { + $definition['pivot'] = $this->pivotColumns; + } + + return $definition; } } diff --git a/src/Database/Relations/Concerns/CanBePushed.php b/src/Database/Relations/Concerns/CanBePushed.php new file mode 100644 index 000000000..8d86d64be --- /dev/null +++ b/src/Database/Relations/Concerns/CanBePushed.php @@ -0,0 +1,69 @@ +push()` or + * `->noPush()` to the relationship definition method. For example: + * + * ```php + * public function messages() + * { + * return $this->hasMany(Message::class)->push(); + * } + * ``` + * + * If you are using the array-style definition, you can use the `push` key to mark the relationship as pushable or not. + * + * ```php + * public $hasMany = [ + * 'messages' => [Message::class, 'push' => true] + * ]; + * ``` + * + * Please note that by default, all relationships are pushable. + * + * @author Ben Thomson + * @copyright Winter CMS Maintainers + */ +trait CanBePushed +{ + /** + * Is this relation pushable? + */ + protected bool $isPushable = true; + + /** + * Allow this relationship to be saved when the `push()` method is used on the primary model. + */ + public function push(): static + { + $this->isPushable = true; + + return $this; + } + + /** + * Disallow this relationship from being saved when the `push()` method is used on the primary model. + */ + public function noPush(): static + { + $this->isPushable = false; + + return $this; + } + + /** + * Determine if the relationship is pushable. + */ + public function isPushable(): bool + { + return $this->isPushable; + } +} diff --git a/src/Database/Relations/HasMany.php b/src/Database/Relations/HasMany.php index ae03bdd19..a06ca19ce 100644 --- a/src/Database/Relations/HasMany.php +++ b/src/Database/Relations/HasMany.php @@ -14,6 +14,7 @@ class HasMany extends HasManyBase implements Relation { use Concerns\HasOneOrMany; use Concerns\CanBeDependent; + use Concerns\CanBePushed; use Concerns\DefinedConstraints; use Concerns\HasRelationName; @@ -88,6 +89,7 @@ public function getArrayDefinition(): array 'key' => $this->getForeignKeyName(), 'otherKey' => $this->getOtherKey(), 'delete' => $this->isDependent(), + 'push' => $this->isPushable(), ]; } } diff --git a/src/Database/Relations/HasManyThrough.php b/src/Database/Relations/HasManyThrough.php index 0b3061f0e..835a2d145 100644 --- a/src/Database/Relations/HasManyThrough.php +++ b/src/Database/Relations/HasManyThrough.php @@ -10,6 +10,7 @@ */ class HasManyThrough extends HasManyThroughBase { + use Concerns\CanBePushed; use Concerns\DefinedConstraints; use Concerns\HasRelationName; @@ -31,6 +32,14 @@ public function parentSoftDeletes() */ public function getArrayDefinition(): array { - return []; + return [ + get_class($this->query->getModel()), + 'through' => get_class($this->throughParent), + 'key' => $this->getForeignKeyName(), + 'throughKey' => $this->getFirstKeyName(), + 'otherKey' => $this->getLocalKeyName(), + 'secondOtherKey' => $this->getSecondLocalKeyName(), + 'push' => $this->isPushable(), + ]; } } diff --git a/src/Database/Relations/HasOne.php b/src/Database/Relations/HasOne.php index 8314e5aa8..30458d648 100644 --- a/src/Database/Relations/HasOne.php +++ b/src/Database/Relations/HasOne.php @@ -10,6 +10,7 @@ class HasOne extends HasOneBase implements Relation { use Concerns\HasOneOrMany; use Concerns\CanBeDependent; + use Concerns\CanBePushed; use Concerns\DefinedConstraints; use Concerns\HasRelationName; @@ -87,6 +88,7 @@ public function getArrayDefinition(): array 'key' => $this->getForeignKeyName(), 'otherKey' => $this->getOtherKey(), 'delete' => $this->isDependent(), + 'push' => $this->isPushable(), ]; } } diff --git a/src/Database/Relations/HasOneThrough.php b/src/Database/Relations/HasOneThrough.php index eb6ac51d4..d88f32735 100644 --- a/src/Database/Relations/HasOneThrough.php +++ b/src/Database/Relations/HasOneThrough.php @@ -10,6 +10,7 @@ */ class HasOneThrough extends HasOneThroughBase { + use Concerns\CanBePushed; use Concerns\DefinedConstraints; use Concerns\HasRelationName; @@ -31,6 +32,14 @@ public function parentSoftDeletes() */ public function getArrayDefinition(): array { - return []; + return [ + get_class($this->query->getModel()), + 'through' => get_class($this->throughParent), + 'key' => $this->getForeignKeyName(), + 'throughKey' => $this->getFirstKeyName(), + 'otherKey' => $this->getLocalKeyName(), + 'secondOtherKey' => $this->getSecondLocalKeyName(), + 'push' => $this->isPushable(), + ]; } } diff --git a/src/Database/Relations/MorphMany.php b/src/Database/Relations/MorphMany.php index 3ed913ff2..66e70f8ff 100644 --- a/src/Database/Relations/MorphMany.php +++ b/src/Database/Relations/MorphMany.php @@ -14,6 +14,7 @@ class MorphMany extends MorphManyBase implements Relation { use Concerns\MorphOneOrMany; use Concerns\CanBeDependent; + use Concerns\CanBePushed; use Concerns\DefinedConstraints; use Concerns\HasRelationName; @@ -91,10 +92,13 @@ public function getSimpleValue() */ public function getArrayDefinition(): array { + return [ get_class($this->query->getModel()), - 'name' => $this->morphType, + 'type' => $this->getMorphType(), + 'id' => $this->getForeignKeyName(), 'delete' => $this->isDependent(), + 'push' => $this->isPushable(), ]; } } diff --git a/src/Database/Relations/MorphOne.php b/src/Database/Relations/MorphOne.php index eb6cb55bb..7b72113d5 100644 --- a/src/Database/Relations/MorphOne.php +++ b/src/Database/Relations/MorphOne.php @@ -12,6 +12,7 @@ class MorphOne extends MorphOneBase implements Relation { use Concerns\MorphOneOrMany; use Concerns\CanBeDependent; + use Concerns\CanBePushed; use Concerns\DefinedConstraints; use Concerns\HasRelationName; @@ -95,6 +96,12 @@ public function getSimpleValue() */ public function getArrayDefinition(): array { - return []; + return [ + get_class($this->query->getModel()), + 'type' => $this->getMorphType(), + 'id' => $this->getForeignKeyName(), + 'delete' => $this->isDependent(), + 'push' => $this->isPushable(), + ]; } } diff --git a/src/Database/Relations/MorphTo.php b/src/Database/Relations/MorphTo.php index a3f96d260..0f91524b8 100644 --- a/src/Database/Relations/MorphTo.php +++ b/src/Database/Relations/MorphTo.php @@ -11,6 +11,7 @@ class MorphTo extends MorphToBase implements Relation { use Concerns\BelongsOrMorphsTo; + use Concerns\CanBePushed; use Concerns\DeferOneOrMany; use Concerns\DefinedConstraints; use Concerns\HasRelationName; @@ -67,6 +68,11 @@ public function getSimpleValue() */ public function getArrayDefinition(): array { - return []; + return [ + get_class($this->query->getModel()), + 'key' => $this->getForeignKeyName(), + 'otherKey' => $this->getOwnerKeyName(), + 'push' => $this->isPushable(), + ]; } } diff --git a/src/Database/Relations/MorphToMany.php b/src/Database/Relations/MorphToMany.php index d6a750b36..aad9c5c90 100644 --- a/src/Database/Relations/MorphToMany.php +++ b/src/Database/Relations/MorphToMany.php @@ -19,6 +19,7 @@ class MorphToMany extends BaseMorphToMany implements Relation { use Concerns\BelongsOrMorphsToMany; + use Concerns\CanBePushed; use Concerns\DeferOneOrMany; use Concerns\DefinedConstraints; use Concerns\HasRelationName; @@ -140,6 +141,21 @@ public function getSimpleValue() */ public function getArrayDefinition(): array { - return []; + $definition = [ + get_class($this->query->getModel()), + 'table' => $this->getTable(), + 'key' => $this->getForeignPivotKeyName(), + 'otherKey' => $this->getRelatedPivotKeyName(), + 'parentKey' => $this->getParentKeyName(), + 'relatedKey' => $this->getRelatedKeyName(), + 'inverse' => $this->getInverse(), + 'push' => $this->isPushable(), + ]; + + if (count($this->pivotColumns)) { + $definition['pivot'] = $this->pivotColumns; + } + + return $definition; } } diff --git a/tests/Database/Concerns/HasRelationshipsTest.php b/tests/Database/Concerns/HasRelationshipsTest.php index 7cd4c28c0..cf998bd08 100644 --- a/tests/Database/Concerns/HasRelationshipsTest.php +++ b/tests/Database/Concerns/HasRelationshipsTest.php @@ -93,19 +93,47 @@ public function testGetRelationDefinition() 'key' => 'author_id', 'otherKey' => 'id', 'delete' => false, + 'push' => true, ], $author->getRelationDefinition('contactNumber')); $this->assertEquals([ Post::class, 'key' => 'author_id', 'otherKey' => 'id', 'delete' => false, + 'push' => true, ], $author->getRelationDefinition('messages')); $this->assertEquals([ Role::class, 'table' => 'database_tester_authors_roles', 'key' => 'author_id', - 'otherKey' => 'id' + 'otherKey' => 'id', + 'push' => true, ], $author->getRelationDefinition('scopes')); + $this->assertEquals([ + Meta::class, + 'type' => 'taggable_type', + 'id' => 'taggable_id', + 'delete' => false, + 'push' => true, + ], $author->getRelationDefinition('info')); + $this->assertEquals([ + EventLog::class, + 'type' => 'related_type', + 'id' => 'related_id', + 'delete' => true, + 'push' => true, + ], $author->getRelationDefinition('auditLogs')); + $this->assertEquals([ + Tag::class, + 'table' => 'database_tester_taggables', + 'key' => 'taggable_id', + 'otherKey' => 'tag_id', + 'parentKey' => 'id', + 'relatedKey' => 'id', + 'inverse' => false, + 'push' => true, + 'pivot' => ['added_by'] + ], $author->getRelationDefinition('labels')); $this->assertNull($author->getRelationDefinition('invalid')); } diff --git a/tests/Database/Fixtures/Author.php b/tests/Database/Fixtures/Author.php index 7e2304372..e57597530 100644 --- a/tests/Database/Fixtures/Author.php +++ b/tests/Database/Fixtures/Author.php @@ -7,6 +7,7 @@ use Winter\Storm\Database\Model; use Winter\Storm\Database\Relations\HasMany; use Winter\Storm\Database\Relations\HasOne; +use Winter\Storm\Database\Relations\MorphToMany; class Author extends Model { @@ -91,6 +92,11 @@ public function info() return $this->morphOne(Meta::class, 'taggable'); } + public function labels(): MorphToMany + { + return $this->morphToMany(Tag::class, 'taggable', 'database_tester_taggables')->withPivot('added_by'); + } + #[Relation] public function auditLogs() { From 426a2d469f22a2f0d8822aca768993b338bfb43a Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Fri, 23 Feb 2024 15:57:41 +0800 Subject: [PATCH 16/59] Add ability to define detachable relations --- src/Database/Concerns/HasRelationships.php | 127 ++++++++++-------- src/Database/Relations/BelongsToMany.php | 2 + .../Relations/Concerns/CanBeDetachable.php | 71 ++++++++++ src/Database/Relations/MorphToMany.php | 2 + .../Concerns/HasRelationshipsTest.php | 56 +++++++- 5 files changed, 204 insertions(+), 54 deletions(-) create mode 100644 src/Database/Relations/Concerns/CanBeDetachable.php diff --git a/src/Database/Concerns/HasRelationships.php b/src/Database/Concerns/HasRelationships.php index 1e3cde9ae..32f9dc3d5 100644 --- a/src/Database/Concerns/HasRelationships.php +++ b/src/Database/Concerns/HasRelationships.php @@ -39,8 +39,6 @@ * @method \Winter\Storm\Database\Relations\BelongsToMany belongsToMany(string $related, string|null $table = null, string|null $foreignPivotKey = null, string|null $relatedPivotKey = null, string|null $parentKey = null, string|null $relatedKey = null, string|null $relation = null) * @method \Winter\Storm\Database\Relations\MorphToMany morphToMany(string $related, string $name, string|null $table = null, string|null $foreignPivotKey = null, string|null $relatedPivotKey = null, string|null $parentKey = null, string|null $relatedKey = null, bool $inverse = false) * @method \Winter\Storm\Database\Relations\MorphToMany morphedByMany(string $related, string $name, string|null $table = null, string|null $foreignPivotKey = null, string|null $relatedPivotKey = null, string|null $parentKey = null, string|null $relatedKey = null) - * @method \Winter\Storm\Database\Relations\AttachOne attachOne(string $related, bool $isPublic = true, string|null $localKey = null) - * @method \Winter\Storm\Database\Relations\AttachMany attachMany(string $related, bool $isPublic = null, string|null $localKey = null) */ trait HasRelationships { @@ -175,15 +173,38 @@ trait HasRelationships */ public function hasRelation(string $name): bool { - if (method_exists($this, $name) && $this->isRelationMethod($name)) { - return true; + return $this->getRelationDefinition($name) !== null; + } + + /** + * Gets the name and relation object of all defined relations on the model. + * + * This differs from the `getRelations()` method provided by Laravel, which only returns the loaded relations. It + * also contains the Relation object as a value, rather than the result of the relation as per Laravel's + * implementation. + */ + public function getDefinedRelations(): array + { + $relations = []; + + foreach (array_keys(static::$relationTypes) as $type) { + foreach (array_keys($this->getRelationTypeDefinitions($type)) as $name) { + $relations[$name] = $this->handleRelation($name, false); + } } - return $this->getRelationDefinition($name) !== null; + foreach ($this->getRelationMethods() as $relation) { + $relations[$relation] = $this->{$relation}(); + } + + return $relations; } /** * Returns relationship details from a supplied name. + * + * If the name resolves to a relation method, the method's returned relation object will be converted back to an + * array definition. */ public function getRelationDefinition(string $name): ?array { @@ -322,7 +343,7 @@ protected function getRelationDefaults(string $type): array * Winter has traditionally used array properties in the model to configure relationships. This method converts * these to the applicable Laravel relation object. */ - protected function handleRelation(string $relationName): EloquentRelation + protected function handleRelation(string $relationName, bool $addConstraints = true): EloquentRelation { $relationType = $this->getRelationType($relationName); $definition = $this->getRelationDefinition($relationName); @@ -481,8 +502,43 @@ protected function handleRelation(string $relationName): EloquentRelation // Add relation name $relation->setRelationName($relationName); - // Add defined constraints - $relation->addDefinedConstraints(); + // Add dependency, if required + if ( + in_array( + \Winter\Storm\Database\Relations\Concerns\CanBeDependent::class, + class_uses_recursive($relation) + ) + && (($definition['delete'] ?? false) === true) + ) { + $relation = $relation->dependent(); + } + + // Remove detachable, if required + if ( + in_array( + \Winter\Storm\Database\Relations\Concerns\CanBeDetachable::class, + class_uses_recursive($relation) + ) + && (($definition['detach'] ?? true) === false) + ) { + $relation = $relation->notDetachable(); + } + + // Remove pushable flag, if required + if ( + in_array( + \Winter\Storm\Database\Relations\Concerns\CanBePushed::class, + class_uses_recursive($relation) + ) + && (($definition['push'] ?? true) === false) + ) { + $relation = $relation->noPush(); + } + + if ($addConstraints) { + // Add defined constraints + $relation->addDefinedConstraints(); + } return $relation; } @@ -536,52 +592,17 @@ protected function setRelationValue(string $relationName, $value): void */ protected function performDeleteOnRelations(): void { - $definitions = $this->getRelationDefinitions(); - foreach ($definitions as $type => $relations) { - /* - * Hard 'delete' definition - */ - foreach ($relations as $name => $options) { - if (in_array($type, ['belongsToMany', 'morphToMany', 'morphedByMany'])) { - // we want to remove the pivot record, not the actual relation record - if (Arr::get($options, 'detach', true)) { - $this->{$name}()->detach(); - } - } elseif (in_array($type, ['belongsTo', 'hasOneThrough', 'hasManyThrough', 'morphTo'])) { - // the model does not own the related record, we should not remove it. - continue; - } elseif (in_array($type, ['attachOne', 'attachMany', 'hasOne', 'hasMany', 'morphOne', 'morphMany'])) { - if (!Arr::get($options, 'delete', false)) { - continue; - } - - // Attempt to load the related record(s) - if (!$relation = $this->{$name}) { - continue; - } - - if ($relation instanceof Model) { - $relation->forceDelete(); - } elseif ($relation instanceof CollectionBase) { - $relation->each(function ($model) { - $model->forceDelete(); - }); - } - } - } - } + $relations = $this->getDefinedRelations(); - // Find relation methods - foreach ($this->getRelationMethods() as $relation) { - /** @var EloquentRelation */ - $relationObj = $this->{$relation}(); - - if (method_exists($relationObj, 'isDependent')) { - if ($relationObj->isDependent()) { - $relationObj->get()->each(function ($model) { - $model->forceDelete(); - }); - } + /** @var EloquentRelation */ + foreach (array_values($relations) as $relationObj) { + if (method_exists($relationObj, 'isDetachable') && $relationObj->isDetachable()) { + $relationObj->detach(); + } + if (method_exists($relationObj, 'isDependent') && $relationObj->isDependent()) { + $relationObj->get()->each(function ($model) { + $model->forceDelete(); + }); } } } diff --git a/src/Database/Relations/BelongsToMany.php b/src/Database/Relations/BelongsToMany.php index c65869902..7eb167cff 100644 --- a/src/Database/Relations/BelongsToMany.php +++ b/src/Database/Relations/BelongsToMany.php @@ -9,6 +9,7 @@ class BelongsToMany extends BelongsToManyBase implements Relation { use Concerns\BelongsOrMorphsToMany; + use Concerns\CanBeDetachable; use Concerns\CanBePushed; use Concerns\DeferOneOrMany; use Concerns\DefinedConstraints; @@ -104,6 +105,7 @@ public function getArrayDefinition(): array 'key' => $this->getForeignPivotKeyName(), 'otherKey' => $this->getRelatedKeyName(), 'push' => $this->isPushable(), + 'detach' => $this->isDetachable(), ]; if (count($this->pivotColumns)) { diff --git a/src/Database/Relations/Concerns/CanBeDetachable.php b/src/Database/Relations/Concerns/CanBeDetachable.php new file mode 100644 index 000000000..2efb71039 --- /dev/null +++ b/src/Database/Relations/Concerns/CanBeDetachable.php @@ -0,0 +1,71 @@ +detachable()` to the + * relationship definition method. For example: + * + * ```php + * public function users() + * { + * return $this->belongsToMany(User::class)->detachable(); + * } + * ``` + * + * If you are using the array-style definition, you can use the `detach` key to mark the relationship as detachable. + * + * ```php + * public $belongsToMany = [ + * 'users' => [User::class, 'detach' => true] + * ]; + * ``` + * + * @author Ben Thomson + * @copyright Winter CMS Maintainers + */ +trait CanBeDetachable +{ + /** + * Should this relation be detached when the primary model is deleted? + */ + protected bool $detachable = true; + + /** + * Allow this relationship to be detached when the primary model is deleted. + */ + public function detachable(): static + { + $this->detachable = true; + + return $this; + } + + /** + * Disallow this relationship to be detached when the primary model is deleted. + */ + public function notDetachable(): static + { + $this->detachable = false; + + return $this; + } + + /** + * Determine if the relation should be detached when the primary model is deleted. + */ + public function isDetachable(): bool + { + return $this->detachable; + } +} diff --git a/src/Database/Relations/MorphToMany.php b/src/Database/Relations/MorphToMany.php index aad9c5c90..370e53351 100644 --- a/src/Database/Relations/MorphToMany.php +++ b/src/Database/Relations/MorphToMany.php @@ -19,6 +19,7 @@ class MorphToMany extends BaseMorphToMany implements Relation { use Concerns\BelongsOrMorphsToMany; + use Concerns\CanBeDetachable; use Concerns\CanBePushed; use Concerns\DeferOneOrMany; use Concerns\DefinedConstraints; @@ -150,6 +151,7 @@ public function getArrayDefinition(): array 'relatedKey' => $this->getRelatedKeyName(), 'inverse' => $this->getInverse(), 'push' => $this->isPushable(), + 'detach' => $this->isDetachable(), ]; if (count($this->pivotColumns)) { diff --git a/tests/Database/Concerns/HasRelationshipsTest.php b/tests/Database/Concerns/HasRelationshipsTest.php index cf998bd08..a7d91d994 100644 --- a/tests/Database/Concerns/HasRelationshipsTest.php +++ b/tests/Database/Concerns/HasRelationshipsTest.php @@ -2,6 +2,13 @@ namespace Winter\Storm\Tests\Database\Concerns; +use Winter\Storm\Database\Relations\BelongsTo; +use Winter\Storm\Database\Relations\BelongsToMany; +use Winter\Storm\Database\Relations\HasMany; +use Winter\Storm\Database\Relations\HasOne; +use Winter\Storm\Database\Relations\MorphMany; +use Winter\Storm\Database\Relations\MorphOne; +use Winter\Storm\Database\Relations\MorphToMany; use Winter\Storm\Tests\Database\Fixtures\Author; use Winter\Storm\Tests\Database\Fixtures\Country; use Winter\Storm\Tests\Database\Fixtures\EventLog; @@ -65,6 +72,51 @@ public function testGetRelationType() $this->assertNull($author->getRelationType('invalid')); } + public function testGetDefinedRelations() + { + $author = new Author(); + $defined = $author->getDefinedRelations(); + + $this->assertCount(16, $defined); + foreach ([ + 'user', + 'country', + 'user_soft', + 'posts', + 'phone', + 'roles', + 'event_log', + 'meta', + 'tags', + 'contactNumber', + 'messages', + 'scopes', + 'executiveAuthors', + 'info', + 'labels', + 'auditLogs', + ] as $expected) { + $this->assertArrayHasKey($expected, $defined); + } + + $this->assertInstanceOf(BelongsTo::class, $defined['user']); + $this->assertInstanceOf(BelongsTo::class, $defined['country']); + $this->assertInstanceOf(BelongsTo::class, $defined['user_soft']); + $this->assertInstanceOf(HasMany::class, $defined['posts']); + $this->assertInstanceOf(HasOne::class, $defined['phone']); + $this->assertInstanceOf(BelongsToMany::class, $defined['roles']); + $this->assertInstanceOf(MorphMany::class, $defined['event_log']); + $this->assertInstanceOf(MorphOne::class, $defined['meta']); + $this->assertInstanceOf(MorphToMany::class, $defined['tags']); + $this->assertInstanceOf(HasOne::class, $defined['contactNumber']); + $this->assertInstanceOf(HasMany::class, $defined['messages']); + $this->assertInstanceOf(BelongsToMany::class, $defined['scopes']); + $this->assertInstanceOf(BelongsToMany::class, $defined['executiveAuthors']); + $this->assertInstanceOf(MorphOne::class, $defined['info']); + $this->assertInstanceOf(MorphToMany::class, $defined['labels']); + $this->assertInstanceOf(MorphMany::class, $defined['auditLogs']); + } + public function testGetRelationDefinition() { $author = new Author(); @@ -108,6 +160,7 @@ public function testGetRelationDefinition() 'key' => 'author_id', 'otherKey' => 'id', 'push' => true, + 'detach' => true, ], $author->getRelationDefinition('scopes')); $this->assertEquals([ Meta::class, @@ -132,7 +185,8 @@ public function testGetRelationDefinition() 'relatedKey' => 'id', 'inverse' => false, 'push' => true, - 'pivot' => ['added_by'] + 'pivot' => ['added_by'], + 'detach' => true, ], $author->getRelationDefinition('labels')); $this->assertNull($author->getRelationDefinition('invalid')); From cd3e791e5bc2913d7c606e4dfab93299869a2f48 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Fri, 23 Feb 2024 15:57:49 +0800 Subject: [PATCH 17/59] Add attachment tests --- tests/Database/Fixtures/User.php | 5 +- tests/Database/Relations/AttachManyTest.php | 48 +++++++++ tests/Database/Relations/AttachOneTest.php | 103 ++++++++++++++++++++ tests/fixtures/attach/avatar.png | Bin 0 -> 1505 bytes 4 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 tests/Database/Relations/AttachManyTest.php create mode 100644 tests/Database/Relations/AttachOneTest.php create mode 100644 tests/fixtures/attach/avatar.png diff --git a/tests/Database/Fixtures/User.php b/tests/Database/Fixtures/User.php index f166c871f..a369b7478 100644 --- a/tests/Database/Fixtures/User.php +++ b/tests/Database/Fixtures/User.php @@ -3,6 +3,7 @@ namespace Winter\Storm\Tests\Database\Fixtures; use Illuminate\Database\Schema\Builder; +use Winter\Storm\Database\Attach\File; use Winter\Storm\Database\Model; use Winter\Storm\Database\Relations\HasOneThrough; @@ -37,11 +38,11 @@ class User extends Model ]; public $attachOne = [ - 'avatar' => 'System\Models\File' + 'avatar' => File::class, ]; public $attachMany = [ - 'photos' => 'System\Models\File' + 'photos' => File::class, ]; public function contactNumber(): HasOneThrough diff --git a/tests/Database/Relations/AttachManyTest.php b/tests/Database/Relations/AttachManyTest.php new file mode 100644 index 000000000..a75655972 --- /dev/null +++ b/tests/Database/Relations/AttachManyTest.php @@ -0,0 +1,48 @@ + 'Stevie', 'email' => 'stevie@example.com']); + Model::reguard(); + + $this->assertEmpty($user->photos); + $user->photos()->create(['data' => dirname(dirname(__DIR__)) . '/fixtures/attach/avatar.png']); + $user->reloadRelations(); + $this->assertNotEmpty($user->photos); + + $photo = $user->photos->first(); + $photoId = $photo->id; + + $user->photos()->remove($photo); + $this->assertNull(File::find($photoId)); + } + + public function testDeleteFlagDeleteModel() + { + Model::unguard(); + $user = User::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + Model::reguard(); + + $this->assertEmpty($user->photos); + $user->photos()->create(['data' => dirname(dirname(__DIR__)) . '/fixtures/attach/avatar.png']); + $user->reloadRelations(); + $this->assertNotEmpty($user->photos); + + $photo = $user->photos->first(); + $this->assertNotNull($photo); + $photoId = $photo->id; + + $user->delete(); + $this->assertNull(File::find($photoId)); + } +} diff --git a/tests/Database/Relations/AttachOneTest.php b/tests/Database/Relations/AttachOneTest.php new file mode 100644 index 000000000..f8d5991d2 --- /dev/null +++ b/tests/Database/Relations/AttachOneTest.php @@ -0,0 +1,103 @@ + 'Stevie', 'email' => 'stevie@example.com']); + $user2 = User::create(['name' => 'Joe', 'email' => 'joe@example.com']); + Model::reguard(); + + // Set by string + $user->avatar = dirname(dirname(__DIR__)) . '/fixtures/attach/avatar.png'; + + // @todo $user->avatar currently sits as a string, not good for validation + // this should really assert as an UploadedFile instead. + + // Commit the file and it should snap to a File model + $user->save(); + + $this->assertNotNull($user->avatar); + $this->assertEquals('avatar.png', $user->avatar->file_name); + + // Set by Uploaded file + $sample = $user->avatar; + $upload = new UploadedFile( + dirname(dirname(__DIR__)) . '/fixtures/attach/avatar.png', + $sample->file_name, + $sample->content_type, + null, + true + ); + + $user2->avatar = $upload; + + // The file is prepped but not yet commited, this is for validation + $this->assertNotNull($user2->avatar); + $this->assertEquals($upload, $user2->avatar); + + // Commit the file and it should snap to a File model + $user2->save(); + + $this->assertNotNull($user2->avatar); + $this->assertEquals('avatar.png', $user2->avatar->file_name); + } + + public function testDeleteFlagDestroyRelationship() + { + Model::unguard(); + $user = User::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + Model::reguard(); + + $this->assertNull($user->avatar); + $user->avatar()->create(['data' => dirname(dirname(__DIR__)) . '/fixtures/attach/avatar.png']); + $user->reloadRelations(); + $this->assertNotNull($user->avatar); + + $avatar = $user->avatar; + $avatarId = $avatar->id; + + $user->avatar()->remove($avatar); + $this->assertNull(File::find($avatarId)); + } + + public function testDeleteFlagDeleteModel() + { + Model::unguard(); + $user = User::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + Model::reguard(); + + $this->assertNull($user->avatar); + $user->avatar()->create(['data' => dirname(dirname(__DIR__)) . '/fixtures/attach/avatar.png']); + $user->reloadRelations(); + $this->assertNotNull($user->avatar); + + $avatarId = $user->avatar->id; + $user->delete(); + $this->assertNull(File::find($avatarId)); + } + + public function testDeleteFlagSoftDeleteModel() + { + Model::unguard(); + $user = SoftDeleteUser::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + Model::reguard(); + + $user->avatar()->create(['data' => dirname(dirname(__DIR__)) . '/fixtures/attach/avatar.png']); + $this->assertNotNull($user->avatar); + + $avatarId = $user->avatar->id; + $user->delete(); + $this->assertNotNull(File::find($avatarId)); + } +} diff --git a/tests/fixtures/attach/avatar.png b/tests/fixtures/attach/avatar.png new file mode 100644 index 0000000000000000000000000000000000000000..99109125a21cdefe7b04ae2f5d4d1f429a43956b GIT binary patch literal 1505 zcmeAS@N?(olHy`uVBq!ia0vp^79h;Q1SHj;tnma=k|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*9U+n3Xd_B1$5BeXNr6bM+EIYV;~{3xK*A7;Nk-3KEmEQ%e+* zQqwc@Y?a>c-mj#PnPRIHZt82`Ti~3Uk?B!Ylp0*+7m{3+ootz+WN)WnQ(*-(AUCxn zQK2F?C$HG5!d3}vt`(3C64qBz04piUwpD^SD#ABF!8yMuRl!uxR5#hc$WX!DQqR!T z#M01EN5ROz&{*HlK;Otx*U-?)#N5izOaTg%fVLH-q*(>IxIyg#@@$ndN=gc>^!3Zj z%k|2Q_413-^$jg8E%gnI^o@*kfhu&1EAvVcD|GXUm0>2hq!uR^WfqiV=I1GZOiWD5 zFD$Tv3bSNU;+l1ennz|zM-B0$V)JVzP|XC=H|jx7ncO3BHWAB;NpiyW)Z+ZoqGVvir744~DzI`cN=+=uFAB-e&w+(vKt_H^esM;Afr7KMf`)Hma%LWg zuL;)R>ucqiS6q^qmz?V9Vygr+LN7Bj#md#y(Z$fk&C=1p(9O`$)!5O%%*n#V&Dqt( z)y>k?(GjNCB|o_|H#M&WrZ)wl*Ab^)P+G_>0NU)5T9jFqn&MWJpQ`}&vsET;x0vHJ z52`l>w_7Z5>eUB2MjsTjNHGl)0wy026P|8?9C*r4%>yR)B4E0He7o})0|Qftr;B4q z#jT`2|Nq+`4q))%ac1eUD!H<7F`sPd3k6o4D@HG_zrP=ze|KNS=V#mH?H@Fq+GLbg zRn_$8&y?BcJ*LijdG2ug?moH0s@x^(cCUSV!1~LBlfmKd@2RfPQ8j7lomzU}(u=LP z%R35H1#DY!EAFQ9G;RtNOWk35j+&`|^1zU!SjwXaAMUAXD}KWpL2eJvD#Z z?(wm<@fLZysQmc(`}zfq5A*j`SVpx5iE5lYb?NrP1D9rZH#eum=B!~BSd_MF*ODwj zaY0Fj_Nyk{b1sOOBowxYfsc#dh|%jQk9o?H5kPf#qY0Wo^)VE&Hs;&Kdrs} zVRgI7f(EU|sEQpscI@1d@h)J2?}1suI~5+x_R;)_weQ{CvF3o18y3d%89m z9SAet;8^?Tk*iOv+pJk)ZEPlmFPchUe+-#@pnz!_^Q5Fj2WI^J`}_NDe);ux_cm+K zc-ZKha!B>y!sqMb4oew`G_6_Tk@c)9;L?&tZAIVwEUPJRo=G|u{Jf@{W%cFW1+NvTsqyl1wg3C|wK~IM z%9}LHy|P|YA6o2c(TKdO{HU>QlhD;7+YLWGl*5^#{~a;$=`7W_pp(EnQ*7Or^1@|x z)glqDb9WbgeYiROe*M2C7dj^`QevAm>xlU$N%!YY?y^>|E(EssLDS4NK2{C`hK;|z VI$V;}-UBLhJzf1=);T3K0RYZHOMd_W literal 0 HcmV?d00001 From ee1733345a332c82d7bd98e4303384743f451c97 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Fri, 23 Feb 2024 22:33:05 +0800 Subject: [PATCH 18/59] Fix attachment relations Added the ability to define the field name and use this as a constraint for attachment relations, separating it from the relation name. --- src/Database/Concerns/HasRelationships.php | 26 ++++++- src/Database/Relations/AttachMany.php | 4 +- src/Database/Relations/AttachOne.php | 4 +- .../Relations/Concerns/AttachOneOrMany.php | 25 ++++-- tests/Database/Fixtures/User.php | 12 +++ tests/Database/Relations/AttachManyTest.php | 37 +++++++++ tests/Database/Relations/AttachOneTest.php | 76 +++++++++++++++++++ 7 files changed, 172 insertions(+), 12 deletions(-) diff --git a/src/Database/Concerns/HasRelationships.php b/src/Database/Concerns/HasRelationships.php index 32f9dc3d5..6cefbab13 100644 --- a/src/Database/Concerns/HasRelationships.php +++ b/src/Database/Concerns/HasRelationships.php @@ -460,14 +460,22 @@ protected function handleRelation(string $relationName, bool $addConstraints = t $relatedClass, $definition['public'] ?? true, $definition['key'] ?? null, + $definition['field'] ?? $relationName, ); + if (isset($definition['delete']) && $definition['delete'] === false) { + $relation = $relation->notDeletable(); + } break; case 'attachMany': $relation = $this->attachMany( $relatedClass, $definition['public'] ?? true, $definition['key'] ?? null, + $definition['field'] ?? $relationName, ); + if (isset($definition['delete']) && $definition['delete'] === false) { + $relation = $relation->notDeletable(); + } break; case 'hasOneThrough': $relation = $this->hasOneThrough( @@ -835,7 +843,7 @@ protected function newMorphToMany( /** * Define an attachment one-to-one (morphOne) relationship. */ - public function attachOne($related, $isPublic = true, $localKey = null): AttachOne + public function attachOne($related, $isPublic = true, $localKey = null, $fieldName = null): AttachOne { $instance = $this->newRelatedInstance($related); @@ -843,7 +851,12 @@ public function attachOne($related, $isPublic = true, $localKey = null): AttachO $localKey = $localKey ?: $this->getKeyName(); - $relation = new AttachOne($instance->newQuery(), $this, $table . '.attachment_type', $table . '.attachment_id', $isPublic, $localKey); + $fieldName = $fieldName ?? $this->getRelationCaller(); + + $relation = new AttachOne($instance->newQuery(), $this, $table . '.attachment_type', $table . '.attachment_id', $isPublic, $localKey, $fieldName); + + // By default, attachments are dependent on primary models. + $relation->dependent(); $caller = $this->getRelationCaller(); if (!is_null($caller)) { @@ -856,7 +869,7 @@ public function attachOne($related, $isPublic = true, $localKey = null): AttachO /** * Define an attachment one-to-many (morphMany) relationship. */ - public function attachMany($related, $isPublic = null, $localKey = null): AttachMany + public function attachMany($related, $isPublic = null, $localKey = null, $fieldName = null): AttachMany { $instance = $this->newRelatedInstance($related); @@ -864,7 +877,12 @@ public function attachMany($related, $isPublic = null, $localKey = null): Attach $localKey = $localKey ?: $this->getKeyName(); - $relation = new AttachMany($instance->newQuery(), $this, $table . '.attachment_type', $table . '.attachment_id', $isPublic, $localKey); + $fieldName = $fieldName ?? $this->getRelationCaller(); + + $relation = new AttachMany($instance->newQuery(), $this, $table . '.attachment_type', $table . '.attachment_id', $isPublic, $localKey, $fieldName); + + // By default, attachments are dependent on primary models. + $relation->dependent(); $caller = $this->getRelationCaller(); if (!is_null($caller)) { diff --git a/src/Database/Relations/AttachMany.php b/src/Database/Relations/AttachMany.php index 5371e67d8..5810dd831 100644 --- a/src/Database/Relations/AttachMany.php +++ b/src/Database/Relations/AttachMany.php @@ -27,10 +27,12 @@ class AttachMany extends MorphManyBase implements Relation * @param string $id * @param bool $isPublic * @param string $localKey + * @param string $fieldName * @return void */ - public function __construct(Builder $query, Model $parent, $type, $id, $isPublic, $localKey) + public function __construct(Builder $query, Model $parent, $type, $id, $isPublic, $localKey, $fieldName) { + $this->fieldName = $fieldName; parent::__construct($query, $parent, $type, $id, $localKey); $this->public = $isPublic; } diff --git a/src/Database/Relations/AttachOne.php b/src/Database/Relations/AttachOne.php index 4d96ee69c..693566bd2 100644 --- a/src/Database/Relations/AttachOne.php +++ b/src/Database/Relations/AttachOne.php @@ -27,10 +27,12 @@ class AttachOne extends MorphOneBase implements Relation * @param string $id * @param bool $isPublic * @param string $localKey + * @param string $fieldName * @return void */ - public function __construct(Builder $query, Model $parent, $type, $id, $isPublic, $localKey) + public function __construct(Builder $query, Model $parent, $type, $id, $isPublic, $localKey, $fieldName) { + $this->fieldName = $fieldName; parent::__construct($query, $parent, $type, $id, $localKey); $this->public = $isPublic; } diff --git a/src/Database/Relations/Concerns/AttachOneOrMany.php b/src/Database/Relations/Concerns/AttachOneOrMany.php index 935d1ac52..758465d92 100644 --- a/src/Database/Relations/Concerns/AttachOneOrMany.php +++ b/src/Database/Relations/Concerns/AttachOneOrMany.php @@ -16,6 +16,11 @@ trait AttachOneOrMany */ protected $public; + /** + * The field name (relation) to associate this attachment with. + */ + protected string $fieldName; + /** * Determines if the file should be flagged "public" or not. */ @@ -39,12 +44,20 @@ public function addConstraints() $this->query->where($this->foreignKey, '=', $this->getParentKey()); - $this->query->where('field', $this->relationName); + $this->query->where('field', $this->getFieldName()); $this->query->whereNotNull($this->foreignKey); } } + /** + * Get the field name (relation) to associate this attachment with. + */ + public function getFieldName() + { + return $this->fieldName; + } + /** * Add the constraints for a relationship count query. * @@ -66,7 +79,7 @@ public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $query = $query->where($this->morphType, $this->morphClass); - return $query->where('field', $this->relationName); + return $query->where('field', $this->fieldName); } /** @@ -100,7 +113,7 @@ public function addEagerConstraints(array $models) { parent::addEagerConstraints($models); - $this->query->where('field', $this->relationName); + $this->query->where('field', $this->fieldName); } /** @@ -117,7 +130,7 @@ public function save(Model $model, $sessionKey = null) $model->setAttribute('is_public', $this->isPublic()); } - $model->setAttribute('field', $this->relationName); + $model->setAttribute('field', $this->fieldName); if ($sessionKey === null) { return parent::save($model); @@ -141,7 +154,7 @@ public function create(array $attributes = [], $sessionKey = null) $attributes = array_merge(['is_public' => $this->isPublic()], $attributes); } - $attributes['field'] = $this->relationName; + $attributes['field'] = $this->fieldName; $model = parent::create($attributes); @@ -184,7 +197,7 @@ public function add(Model $model, $sessionKey = null) $model->setAttribute($this->getForeignKeyName(), $this->parent->getKey()); $model->setAttribute($this->getMorphType(), $this->morphClass); - $model->setAttribute('field', $this->relationName); + $model->setAttribute('field', $this->fieldName); $model->save(); /* diff --git a/tests/Database/Fixtures/User.php b/tests/Database/Fixtures/User.php index a369b7478..c1c5ffe31 100644 --- a/tests/Database/Fixtures/User.php +++ b/tests/Database/Fixtures/User.php @@ -5,6 +5,8 @@ use Illuminate\Database\Schema\Builder; use Winter\Storm\Database\Attach\File; use Winter\Storm\Database\Model; +use Winter\Storm\Database\Relations\AttachMany; +use Winter\Storm\Database\Relations\AttachOne; use Winter\Storm\Database\Relations\HasOneThrough; class User extends Model @@ -50,6 +52,16 @@ public function contactNumber(): HasOneThrough return $this->hasOneThrough(Phone::class, Author::class); } + public function displayPicture(): AttachOne + { + return $this->attachOne(File::class, true, null, 'avatar'); + } + + public function images(): AttachMany + { + return $this->attachMany(File::class, true, null, 'photos'); + } + public static function migrateUp(Builder $builder): void { if ($builder->hasTable('database_tester_users')) { diff --git a/tests/Database/Relations/AttachManyTest.php b/tests/Database/Relations/AttachManyTest.php index a75655972..ae3e75923 100644 --- a/tests/Database/Relations/AttachManyTest.php +++ b/tests/Database/Relations/AttachManyTest.php @@ -27,6 +27,24 @@ public function testDeleteFlagDestroyRelationship() $this->assertNull(File::find($photoId)); } + public function testDeleteFlagDestroyRelationshipLaravelRelation() + { + Model::unguard(); + $user = User::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + Model::reguard(); + + $this->assertEmpty($user->images); + $user->images()->create(['data' => dirname(dirname(__DIR__)) . '/fixtures/attach/avatar.png']); + $user->reloadRelations(); + $this->assertNotEmpty($user->images); + + $photo = $user->images->first(); + $photoId = $photo->id; + + $user->images()->remove($photo); + $this->assertNull(File::find($photoId)); + } + public function testDeleteFlagDeleteModel() { Model::unguard(); @@ -45,4 +63,23 @@ public function testDeleteFlagDeleteModel() $user->delete(); $this->assertNull(File::find($photoId)); } + + public function testDeleteFlagDeleteModelLaravelRelation() + { + Model::unguard(); + $user = User::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + Model::reguard(); + + $this->assertEmpty($user->images); + $user->images()->create(['data' => dirname(dirname(__DIR__)) . '/fixtures/attach/avatar.png']); + $user->reloadRelations(); + $this->assertNotEmpty($user->images); + + $photo = $user->images->first(); + $this->assertNotNull($photo); + $photoId = $photo->id; + + $user->delete(); + $this->assertNull(File::find($photoId)); + } } diff --git a/tests/Database/Relations/AttachOneTest.php b/tests/Database/Relations/AttachOneTest.php index f8d5991d2..2ed5e47d0 100644 --- a/tests/Database/Relations/AttachOneTest.php +++ b/tests/Database/Relations/AttachOneTest.php @@ -53,6 +53,48 @@ public function testSetRelationValue() $this->assertEquals('avatar.png', $user2->avatar->file_name); } + public function testSetRelationValueLaravelRelation() + { + Model::unguard(); + $user = User::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $user2 = User::create(['name' => 'Joe', 'email' => 'joe@example.com']); + Model::reguard(); + + // Set by string + $user->displayPicture = dirname(dirname(__DIR__)) . '/fixtures/attach/avatar.png'; + + // @todo $user->displayPicture currently sits as a string, not good for validation + // this should really assert as an UploadedFile instead. + + // Commit the file and it should snap to a File model + $user->save(); + + $this->assertNotNull($user->displayPicture); + $this->assertEquals('avatar.png', $user->displayPicture->file_name); + + // Set by Uploaded file + $sample = $user->displayPicture; + $upload = new UploadedFile( + dirname(dirname(__DIR__)) . '/fixtures/attach/avatar.png', + $sample->file_name, + $sample->content_type, + null, + true + ); + + $user2->displayPicture = $upload; + + // The file is prepped but not yet commited, this is for validation + $this->assertNotNull($user2->displayPicture); + $this->assertEquals($upload, $user2->displayPicture); + + // Commit the file and it should snap to a File model + $user2->save(); + + $this->assertNotNull($user2->displayPicture); + $this->assertEquals('avatar.png', $user2->displayPicture->file_name); + } + public function testDeleteFlagDestroyRelationship() { Model::unguard(); @@ -71,6 +113,24 @@ public function testDeleteFlagDestroyRelationship() $this->assertNull(File::find($avatarId)); } + public function testDeleteFlagDestroyRelationshipLaravelRelation() + { + Model::unguard(); + $user = User::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + Model::reguard(); + + $this->assertNull($user->displayPicture); + $user->displayPicture()->create(['data' => dirname(dirname(__DIR__)) . '/fixtures/attach/avatar.png']); + $user->reloadRelations(); + $this->assertNotNull($user->displayPicture); + + $avatar = $user->displayPicture; + $avatarId = $avatar->id; + + $user->displayPicture()->remove($avatar); + $this->assertNull(File::find($avatarId)); + } + public function testDeleteFlagDeleteModel() { Model::unguard(); @@ -87,6 +147,22 @@ public function testDeleteFlagDeleteModel() $this->assertNull(File::find($avatarId)); } + public function testDeleteFlagDeleteModelLaravelRelation() + { + Model::unguard(); + $user = User::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + Model::reguard(); + + $this->assertNull($user->displayPicture); + $user->displayPicture()->create(['data' => dirname(dirname(__DIR__)) . '/fixtures/attach/avatar.png']); + $user->reloadRelations(); + $this->assertNotNull($user->displayPicture); + + $avatarId = $user->displayPicture->id; + $user->delete(); + $this->assertNull(File::find($avatarId)); + } + public function testDeleteFlagSoftDeleteModel() { Model::unguard(); From 8a2d88e076e5079d4c1659915132ecc737873fa5 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Fri, 23 Feb 2024 22:35:00 +0800 Subject: [PATCH 19/59] Copilot went a bit hog-wild on that one --- src/Database/Concerns/HasRelationships.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Database/Concerns/HasRelationships.php b/src/Database/Concerns/HasRelationships.php index 6cefbab13..0f09d38e2 100644 --- a/src/Database/Concerns/HasRelationships.php +++ b/src/Database/Concerns/HasRelationships.php @@ -463,7 +463,7 @@ protected function handleRelation(string $relationName, bool $addConstraints = t $definition['field'] ?? $relationName, ); if (isset($definition['delete']) && $definition['delete'] === false) { - $relation = $relation->notDeletable(); + $relation = $relation->notDependent(); } break; case 'attachMany': @@ -474,7 +474,7 @@ protected function handleRelation(string $relationName, bool $addConstraints = t $definition['field'] ?? $relationName, ); if (isset($definition['delete']) && $definition['delete'] === false) { - $relation = $relation->notDeletable(); + $relation = $relation->notDependent(); } break; case 'hasOneThrough': From c9414e1091a604ea3fbb6dfc6ed73a8471239134 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Fri, 23 Feb 2024 22:38:10 +0800 Subject: [PATCH 20/59] Fix Stan issue --- src/Database/Concerns/HasRelationships.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Database/Concerns/HasRelationships.php b/src/Database/Concerns/HasRelationships.php index 0f09d38e2..5a031db0c 100644 --- a/src/Database/Concerns/HasRelationships.php +++ b/src/Database/Concerns/HasRelationships.php @@ -605,6 +605,7 @@ protected function performDeleteOnRelations(): void /** @var EloquentRelation */ foreach (array_values($relations) as $relationObj) { if (method_exists($relationObj, 'isDetachable') && $relationObj->isDetachable()) { + /** @var BelongsToMany|MorphToMany $relationObj */ $relationObj->detach(); } if (method_exists($relationObj, 'isDependent') && $relationObj->isDependent()) { From b93e935cb859a4c2b6573f939846c386bd0ba0d4 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Mon, 26 Feb 2024 08:45:08 +0800 Subject: [PATCH 21/59] Rename MigratesForTest class The "Test" suffix might inadvertently be picked up by PHPUnit as a test case. --- tests/Database/Fixtures/Author.php | 2 +- tests/Database/Fixtures/Category.php | 2 +- tests/Database/Fixtures/Country.php | 2 +- tests/Database/Fixtures/EventLog.php | 2 +- tests/Database/Fixtures/Meta.php | 2 +- .../Fixtures/{MigratesForTest.php => MigratesForTesting.php} | 2 +- tests/Database/Fixtures/Phone.php | 2 +- tests/Database/Fixtures/Post.php | 2 +- tests/Database/Fixtures/Role.php | 2 +- tests/Database/Fixtures/Tag.php | 2 +- tests/Database/Fixtures/User.php | 2 +- tests/DbTestCase.php | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) rename tests/Database/Fixtures/{MigratesForTest.php => MigratesForTesting.php} (96%) diff --git a/tests/Database/Fixtures/Author.php b/tests/Database/Fixtures/Author.php index e57597530..039586a91 100644 --- a/tests/Database/Fixtures/Author.php +++ b/tests/Database/Fixtures/Author.php @@ -11,7 +11,7 @@ class Author extends Model { - use MigratesForTest; + use MigratesForTesting; /** * @var string The database table used by the model. diff --git a/tests/Database/Fixtures/Category.php b/tests/Database/Fixtures/Category.php index 223ed5dd9..aef5a00d3 100644 --- a/tests/Database/Fixtures/Category.php +++ b/tests/Database/Fixtures/Category.php @@ -7,7 +7,7 @@ class Category extends Model { - use MigratesForTest; + use MigratesForTesting; /** * @var string The database table used by the model. diff --git a/tests/Database/Fixtures/Country.php b/tests/Database/Fixtures/Country.php index 9dd7ac6da..56ee69405 100644 --- a/tests/Database/Fixtures/Country.php +++ b/tests/Database/Fixtures/Country.php @@ -8,7 +8,7 @@ class Country extends Model { - use MigratesForTest; + use MigratesForTesting; /** * @var string The database table used by the model. diff --git a/tests/Database/Fixtures/EventLog.php b/tests/Database/Fixtures/EventLog.php index 24e0450a2..bbe9173d6 100644 --- a/tests/Database/Fixtures/EventLog.php +++ b/tests/Database/Fixtures/EventLog.php @@ -7,7 +7,7 @@ class EventLog extends Model { - use MigratesForTest; + use MigratesForTesting; use \Winter\Storm\Database\Traits\SoftDelete; /** diff --git a/tests/Database/Fixtures/Meta.php b/tests/Database/Fixtures/Meta.php index 789d355b6..d48d4bd79 100644 --- a/tests/Database/Fixtures/Meta.php +++ b/tests/Database/Fixtures/Meta.php @@ -7,7 +7,7 @@ class Meta extends Model { - use MigratesForTest; + use MigratesForTesting; public $table = 'database_tester_meta'; diff --git a/tests/Database/Fixtures/MigratesForTest.php b/tests/Database/Fixtures/MigratesForTesting.php similarity index 96% rename from tests/Database/Fixtures/MigratesForTest.php rename to tests/Database/Fixtures/MigratesForTesting.php index 6053f09c4..8bdcb8f4c 100644 --- a/tests/Database/Fixtures/MigratesForTest.php +++ b/tests/Database/Fixtures/MigratesForTesting.php @@ -10,7 +10,7 @@ * @author Ben Thomson * @copyright Winter CMS */ -trait MigratesForTest +trait MigratesForTesting { /** * Store the models that have been migrated. diff --git a/tests/Database/Fixtures/Phone.php b/tests/Database/Fixtures/Phone.php index 9b0dd0f81..ae4f957c8 100644 --- a/tests/Database/Fixtures/Phone.php +++ b/tests/Database/Fixtures/Phone.php @@ -7,7 +7,7 @@ class Phone extends Model { - use MigratesForTest; + use MigratesForTesting; /** * @var string The database table used by the model. diff --git a/tests/Database/Fixtures/Post.php b/tests/Database/Fixtures/Post.php index d26398f1b..9380b6f11 100644 --- a/tests/Database/Fixtures/Post.php +++ b/tests/Database/Fixtures/Post.php @@ -9,7 +9,7 @@ class Post extends Model { - use MigratesForTest; + use MigratesForTesting; /** * @var string The database table used by the model. diff --git a/tests/Database/Fixtures/Role.php b/tests/Database/Fixtures/Role.php index 6487ad5a5..dac39629e 100644 --- a/tests/Database/Fixtures/Role.php +++ b/tests/Database/Fixtures/Role.php @@ -11,7 +11,7 @@ */ class Role extends Model { - use MigratesForTest; + use MigratesForTesting; /** * @var string The database table used by the model. diff --git a/tests/Database/Fixtures/Tag.php b/tests/Database/Fixtures/Tag.php index 1ad970921..711e31578 100644 --- a/tests/Database/Fixtures/Tag.php +++ b/tests/Database/Fixtures/Tag.php @@ -7,7 +7,7 @@ class Tag extends Model { - use MigratesForTest; + use MigratesForTesting; /** * @var string The database table used by the model. diff --git a/tests/Database/Fixtures/User.php b/tests/Database/Fixtures/User.php index c1c5ffe31..288042bc5 100644 --- a/tests/Database/Fixtures/User.php +++ b/tests/Database/Fixtures/User.php @@ -11,7 +11,7 @@ class User extends Model { - use MigratesForTest; + use MigratesForTesting; /** * @var string The database table used by the model. diff --git a/tests/DbTestCase.php b/tests/DbTestCase.php index 9d05a48ba..64c148b88 100644 --- a/tests/DbTestCase.php +++ b/tests/DbTestCase.php @@ -67,7 +67,7 @@ protected function modelDispatcher(): Dispatcher $model = $params[0]; - if (!in_array('Winter\Storm\Tests\Database\Fixtures\MigratesForTest', class_uses_recursive($model))) { + if (!in_array('Winter\Storm\Tests\Database\Fixtures\MigratesForTesting', class_uses_recursive($model))) { return; } From d4c1191bee8bc871ccead750b8a7f186eb482259 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Mon, 26 Feb 2024 08:45:15 +0800 Subject: [PATCH 22/59] Move Sortable trait tests --- tests/Database/{ => Traits}/SortableTest.php | 4 ++++ 1 file changed, 4 insertions(+) rename tests/Database/{ => Traits}/SortableTest.php (95%) diff --git a/tests/Database/SortableTest.php b/tests/Database/Traits/SortableTest.php similarity index 95% rename from tests/Database/SortableTest.php rename to tests/Database/Traits/SortableTest.php index 8b16990fc..d7dc6bc30 100644 --- a/tests/Database/SortableTest.php +++ b/tests/Database/Traits/SortableTest.php @@ -1,5 +1,9 @@ Date: Mon, 26 Feb 2024 11:33:51 +0800 Subject: [PATCH 23/59] Add extension support for relations, add support for SoftDelete trait --- src/Database/Relations/AttachMany.php | 2 + src/Database/Relations/AttachOne.php | 2 + src/Database/Relations/BelongsTo.php | 11 ++ src/Database/Relations/BelongsToMany.php | 19 ++ .../Relations/Concerns/CanBeExtended.php | 126 +++++++++++++ src/Database/Relations/HasMany.php | 11 ++ src/Database/Relations/HasManyThrough.php | 12 ++ src/Database/Relations/HasOne.php | 15 +- src/Database/Relations/HasOneThrough.php | 12 ++ src/Database/Relations/MorphMany.php | 18 ++ src/Database/Relations/MorphOne.php | 18 ++ src/Database/Relations/MorphTo.php | 11 ++ src/Database/Relations/MorphToMany.php | 21 +++ src/Database/Traits/SoftDelete.php | 172 ++++++++++++------ tests/Database/Fixtures/SoftDeleteUser.php | 2 +- tests/Database/Traits/SoftDeleteTest.php | 28 ++- 16 files changed, 418 insertions(+), 62 deletions(-) create mode 100644 src/Database/Relations/Concerns/CanBeExtended.php diff --git a/src/Database/Relations/AttachMany.php b/src/Database/Relations/AttachMany.php index 5810dd831..aa4f256f5 100644 --- a/src/Database/Relations/AttachMany.php +++ b/src/Database/Relations/AttachMany.php @@ -14,6 +14,7 @@ class AttachMany extends MorphManyBase implements Relation { use Concerns\AttachOneOrMany; use Concerns\CanBeDependent; + use Concerns\CanBeExtended; use Concerns\CanBePushed; use Concerns\DefinedConstraints; use Concerns\HasRelationName; @@ -35,6 +36,7 @@ public function __construct(Builder $query, Model $parent, $type, $id, $isPublic $this->fieldName = $fieldName; parent::__construct($query, $parent, $type, $id, $localKey); $this->public = $isPublic; + $this->extendableRelationConstruct(); } /** diff --git a/src/Database/Relations/AttachOne.php b/src/Database/Relations/AttachOne.php index 693566bd2..66de43fb6 100644 --- a/src/Database/Relations/AttachOne.php +++ b/src/Database/Relations/AttachOne.php @@ -14,6 +14,7 @@ class AttachOne extends MorphOneBase implements Relation { use Concerns\AttachOneOrMany; use Concerns\CanBeDependent; + use Concerns\CanBeExtended; use Concerns\CanBePushed; use Concerns\DefinedConstraints; use Concerns\HasRelationName; @@ -35,6 +36,7 @@ public function __construct(Builder $query, Model $parent, $type, $id, $isPublic $this->fieldName = $fieldName; parent::__construct($query, $parent, $type, $id, $localKey); $this->public = $isPublic; + $this->extendableRelationConstruct(); } /** diff --git a/src/Database/Relations/BelongsTo.php b/src/Database/Relations/BelongsTo.php index 9774754f7..7d0bdf14e 100644 --- a/src/Database/Relations/BelongsTo.php +++ b/src/Database/Relations/BelongsTo.php @@ -2,6 +2,7 @@ namespace Winter\Storm\Database\Relations; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo as BelongsToBase; @@ -12,11 +13,21 @@ class BelongsTo extends BelongsToBase implements Relation { use Concerns\BelongsOrMorphsTo; + use Concerns\CanBeExtended; use Concerns\CanBePushed; use Concerns\DeferOneOrMany; use Concerns\DefinedConstraints; use Concerns\HasRelationName; + /** + * {@inheritDoc} + */ + public function __construct(Builder $query, Model $child, $foreignKey, $ownerKey, $relationName) + { + parent::__construct($query, $child, $foreignKey, $ownerKey, $relationName); + $this->extendableRelationConstruct(); + } + /** * Adds a model to this relationship type. */ diff --git a/src/Database/Relations/BelongsToMany.php b/src/Database/Relations/BelongsToMany.php index 7eb167cff..c970296d6 100644 --- a/src/Database/Relations/BelongsToMany.php +++ b/src/Database/Relations/BelongsToMany.php @@ -2,6 +2,7 @@ namespace Winter\Storm\Database\Relations; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsToMany as BelongsToManyBase; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; @@ -10,11 +11,29 @@ class BelongsToMany extends BelongsToManyBase implements Relation { use Concerns\BelongsOrMorphsToMany; use Concerns\CanBeDetachable; + use Concerns\CanBeExtended; use Concerns\CanBePushed; use Concerns\DeferOneOrMany; use Concerns\DefinedConstraints; use Concerns\HasRelationName; + /** + * {@inheritDoc} + */ + public function __construct( + Builder $query, + Model $parent, + $table, + $foreignPivotKey, + $relatedPivotKey, + $parentKey, + $relatedKey, + $relationName = null + ) { + parent::__construct($query, $parent, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, $relationName); + $this->extendableRelationConstruct(); + } + /** * {@inheritDoc} */ diff --git a/src/Database/Relations/Concerns/CanBeExtended.php b/src/Database/Relations/Concerns/CanBeExtended.php new file mode 100644 index 000000000..e90582ca5 --- /dev/null +++ b/src/Database/Relations/Concerns/CanBeExtended.php @@ -0,0 +1,126 @@ + + * @copyright Winter CMS Maintainers + */ +trait CanBeExtended +{ + use ExtendableTrait; + + /** + * @var string|array|null Extensions implemented by this class. + */ + public $implement = null; + + /** + * Indicates if the extendable constructor has completed. + */ + protected bool $extendableConstructed = false; + + /** + * This stores any locally-scoped callbacks fired before the extendable constructor had completed. + */ + protected array $localCallbacks = []; + + /** + * Constructor + */ + public function extendableRelationConstruct() + { + $this->extendableConstruct(); + $this->extendableConstructed = true; + + if (count($this->localCallbacks)) { + foreach ($this->localCallbacks as $callback) { + $this->extendableAddLocalExtension($callback[0], $callback[1]); + } + } + } + + public function __get($name) + { + return $this->extendableGet($name); + } + + public function __set($name, $value) + { + $this->extendableSet($name, $value); + } + + public function __call($name, $params) + { + if ($name === 'extend') { + if (empty($params[0]) || !is_callable($params[0])) { + throw new \InvalidArgumentException('The extend() method requires a callback parameter or closure.'); + } + if ($params[0] instanceof \Closure) { + return $this->extendableAddLocalExtension($params[0], $params[1] ?? null); + } + return $this->extendableAddLocalExtension(\Closure::fromCallable($params[0]), $params[1] ?? false); + } + + return $this->extendableCall($name, $params); + } + + public static function __callStatic($name, $params) + { + if ($name === 'extend') { + if (empty($params[0])) { + throw new \InvalidArgumentException('The extend() method requires a callback parameter or closure.'); + } + static::extendableAddExtension($params[0], $params[1] ?? false, $params[2] ?? null); + return; + } + + return static::extendableCallStatic($name, $params); + } + + /** + * Extends the class using a closure. + * + * The closure will be provided a single parameter which is the instance of the extended class, by default. + * + * You may optionally specify the callback as a scoped callback, which inherits the scope of the extended class and + * provides access to protected and private methods and properties. This makes any call using `$this` act on the + * extended class, not the class providing the extension. + * + * If you use a scoped callback, you can provide the "outer" scope - or the scope of the class providing the extension, + * with the third parameter. The outer scope object will then be passed as the single parameter to the closure. + */ + public static function extendableAddExtension(callable $callback, bool $scoped = false, ?object $outerScope = null): void + { + static::extendableExtendCallback($callback, $scoped, $outerScope); + } + + /** + * Adds local extensibility to the current instance. + * + * This rebinds a given closure to the current instance, making it able to access protected and private methods. This + * makes any call using `$this` within the closure act on the extended class, not the class providing the extension. + * + * An outer scope may be provided by providing a second parameter, which will then be passed through to the closure + * as its first parameter. If this is not given, the current instance will be provided as the first parameter. + */ + protected function extendableAddLocalExtension(\Closure $callback, ?object $outerScope = null) + { + if (!$this->extendableConstructed) { + $this->localCallbacks[] = [$callback, $outerScope]; + return; + } + + return $callback->call($this, $outerScope ?? $this); + } +} diff --git a/src/Database/Relations/HasMany.php b/src/Database/Relations/HasMany.php index a06ca19ce..313f96240 100644 --- a/src/Database/Relations/HasMany.php +++ b/src/Database/Relations/HasMany.php @@ -2,6 +2,7 @@ namespace Winter\Storm\Database\Relations; +use Illuminate\Database\Eloquent\Builder; use Winter\Storm\Database\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Collection as CollectionBase; @@ -14,10 +15,20 @@ class HasMany extends HasManyBase implements Relation { use Concerns\HasOneOrMany; use Concerns\CanBeDependent; + use Concerns\CanBeExtended; use Concerns\CanBePushed; use Concerns\DefinedConstraints; use Concerns\HasRelationName; + /** + * {@inheritDoc} + */ + public function __construct(Builder $query, Model $parent, $foreignKey, $localKey) + { + parent::__construct($query, $parent, $foreignKey, $localKey); + $this->extendableRelationConstruct(); + } + /** * {@inheritDoc} */ diff --git a/src/Database/Relations/HasManyThrough.php b/src/Database/Relations/HasManyThrough.php index 835a2d145..4574856f7 100644 --- a/src/Database/Relations/HasManyThrough.php +++ b/src/Database/Relations/HasManyThrough.php @@ -2,6 +2,8 @@ namespace Winter\Storm\Database\Relations; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasManyThrough as HasManyThroughBase; /** @@ -10,10 +12,20 @@ */ class HasManyThrough extends HasManyThroughBase { + use Concerns\CanBeExtended; use Concerns\CanBePushed; use Concerns\DefinedConstraints; use Concerns\HasRelationName; + /** + * {@inheritDoc} + */ + public function __construct(Builder $query, Model $farParent, Model $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey) + { + parent::__construct($query, $farParent, $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey); + $this->extendableRelationConstruct(); + } + /** * Determine whether close parent of the relation uses Soft Deletes. * diff --git a/src/Database/Relations/HasOne.php b/src/Database/Relations/HasOne.php index 30458d648..f009381f2 100644 --- a/src/Database/Relations/HasOne.php +++ b/src/Database/Relations/HasOne.php @@ -1,5 +1,8 @@ -extendableRelationConstruct(); + } + /** * {@inheritDoc} */ diff --git a/src/Database/Relations/HasOneThrough.php b/src/Database/Relations/HasOneThrough.php index d88f32735..8d8390aa5 100644 --- a/src/Database/Relations/HasOneThrough.php +++ b/src/Database/Relations/HasOneThrough.php @@ -2,6 +2,8 @@ namespace Winter\Storm\Database\Relations; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasOneThrough as HasOneThroughBase; /** @@ -10,10 +12,20 @@ */ class HasOneThrough extends HasOneThroughBase { + use Concerns\CanBeExtended; use Concerns\CanBePushed; use Concerns\DefinedConstraints; use Concerns\HasRelationName; + /** + * {@inheritDoc} + */ + public function __construct(Builder $query, Model $farParent, Model $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey) + { + parent::__construct($query, $farParent, $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey); + $this->extendableRelationConstruct(); + } + /** * Determine whether close parent of the relation uses Soft Deletes. * diff --git a/src/Database/Relations/MorphMany.php b/src/Database/Relations/MorphMany.php index 66e70f8ff..82be6606f 100644 --- a/src/Database/Relations/MorphMany.php +++ b/src/Database/Relations/MorphMany.php @@ -2,6 +2,7 @@ namespace Winter\Storm\Database\Relations; +use Illuminate\Database\Eloquent\Builder; use Winter\Storm\Database\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Collection as CollectionBase; @@ -14,10 +15,27 @@ class MorphMany extends MorphManyBase implements Relation { use Concerns\MorphOneOrMany; use Concerns\CanBeDependent; + use Concerns\CanBeExtended; use Concerns\CanBePushed; use Concerns\DefinedConstraints; use Concerns\HasRelationName; + /** + * Create a new morph one or many relationship instance. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param \Illuminate\Database\Eloquent\Model $parent + * @param string $type + * @param string $id + * @param string $localKey + * @return void + */ + public function __construct(Builder $query, Model $parent, $type, $id, $localKey) + { + parent::__construct($query, $parent, $type, $id, $localKey); + $this->extendableRelationConstruct(); + } + /** * {@inheritDoc} */ diff --git a/src/Database/Relations/MorphOne.php b/src/Database/Relations/MorphOne.php index 7b72113d5..c913e37de 100644 --- a/src/Database/Relations/MorphOne.php +++ b/src/Database/Relations/MorphOne.php @@ -2,6 +2,7 @@ namespace Winter\Storm\Database\Relations; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphOne as MorphOneBase; @@ -12,10 +13,27 @@ class MorphOne extends MorphOneBase implements Relation { use Concerns\MorphOneOrMany; use Concerns\CanBeDependent; + use Concerns\CanBeExtended; use Concerns\CanBePushed; use Concerns\DefinedConstraints; use Concerns\HasRelationName; + /** + * Create a new morph one or many relationship instance. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param \Illuminate\Database\Eloquent\Model $parent + * @param string $type + * @param string $id + * @param string $localKey + * @return void + */ + public function __construct(Builder $query, Model $parent, $type, $id, $localKey) + { + parent::__construct($query, $parent, $type, $id, $localKey); + $this->extendableRelationConstruct(); + } + /** * {@inheritDoc} */ diff --git a/src/Database/Relations/MorphTo.php b/src/Database/Relations/MorphTo.php index 0f91524b8..456c10aeb 100644 --- a/src/Database/Relations/MorphTo.php +++ b/src/Database/Relations/MorphTo.php @@ -2,6 +2,7 @@ namespace Winter\Storm\Database\Relations; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphTo as MorphToBase; @@ -11,11 +12,21 @@ class MorphTo extends MorphToBase implements Relation { use Concerns\BelongsOrMorphsTo; + use Concerns\CanBeExtended; use Concerns\CanBePushed; use Concerns\DeferOneOrMany; use Concerns\DefinedConstraints; use Concerns\HasRelationName; + /** + * {@inheritDoc} + */ + public function __construct(Builder $query, Model $parent, $foreignKey, $ownerKey, $type, $relation) + { + parent::__construct($query, $parent, $foreignKey, $ownerKey, $type, $relation); + $this->extendableRelationConstruct(); + } + /** * {@inheritDoc} */ diff --git a/src/Database/Relations/MorphToMany.php b/src/Database/Relations/MorphToMany.php index 370e53351..ebe8bfd14 100644 --- a/src/Database/Relations/MorphToMany.php +++ b/src/Database/Relations/MorphToMany.php @@ -2,6 +2,7 @@ namespace Winter\Storm\Database\Relations; +use Illuminate\Database\Eloquent\Builder; use Winter\Storm\Database\MorphPivot; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; @@ -20,11 +21,31 @@ class MorphToMany extends BaseMorphToMany implements Relation { use Concerns\BelongsOrMorphsToMany; use Concerns\CanBeDetachable; + use Concerns\CanBeExtended; use Concerns\CanBePushed; use Concerns\DeferOneOrMany; use Concerns\DefinedConstraints; use Concerns\HasRelationName; + /** + * {@inheritDoc} + */ + public function __construct( + Builder $query, + Model $parent, + $name, + $table, + $foreignPivotKey, + $relatedPivotKey, + $parentKey, + $relatedKey, + $relationName = null, + $inverse = false + ) { + parent::__construct($query, $parent, $name, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, $relationName, $inverse); + $this->extendableRelationConstruct(); + } + /** * Create a new query builder for the pivot table. * diff --git a/src/Database/Traits/SoftDelete.php b/src/Database/Traits/SoftDelete.php index 82953eb2b..5cd264468 100644 --- a/src/Database/Traits/SoftDelete.php +++ b/src/Database/Traits/SoftDelete.php @@ -1,12 +1,25 @@ -fireEvent('model.afterRestore', halt: true); }); + + foreach ([ + AttachMany::class, + AttachOne::class, + BelongsToMany::class, + HasMany::class, + HasOne::class, + MorphMany::class, + MorphOne::class, + MorphToMany::class, + ] as $relationClass) { + $relationClass::extend(function () { + $this->addDynamicProperty('isSoftDeletable', false); + $this->addDynamicProperty('deletedAtColumn', 'deleted_at'); + + $this->addDynamicMethod('softDeletable', function (string $deletedAtColumn = 'deleted_at') { + $this->isSoftDeletable = true; + $this->deletedAtColumn = $deletedAtColumn; + return $this; + }); + + $this->addDynamicMethod('notSoftDeletable', function () { + $this->isSoftDeletable = false; + return $this; + }); + + $this->addDynamicMethod('isSoftDeletable', function () { + return $this->isSoftDeletable; + }); + + $this->addDynamicMethod('getDeletedAtColumn', function () { + return $this->deletedAtColumn; + }); + }, true); + } } /** @@ -112,32 +160,34 @@ protected function performDeleteOnModel() */ protected function performSoftDeleteOnRelations() { - $definitions = $this->getRelationDefinitions(); - foreach ($definitions as $type => $relations) { - foreach ($relations as $name => $options) { - if (!array_get($options, 'softDelete', false)) { - continue; - } - // Attempt to load the related record(s) - if (!$relation = $this->{$name}) { - continue; - } - if (in_array($type, ['belongsToMany', 'morphToMany', 'morphedByMany'])) { - // relations using pivot table - $value = $this->fromDateTime($this->freshTimestamp()); - $this->updatePivotDeletedAtColumn($name, $options, $value); - } elseif (in_array($type, ['belongsTo', 'hasOneThrough', 'hasManyThrough', 'morphTo'])) { - // the model does not own the related record, we should not remove it. - continue; - } elseif (in_array($type, ['attachOne', 'attachMany', 'hasOne', 'hasMany', 'morphOne', 'morphMany'])) { - if ($relation instanceof EloquentModel) { - $relation->delete(); - } elseif ($relation instanceof CollectionBase) { - $relation->each(function ($model) { - $model->delete(); - }); - } - } + foreach ($this->getDefinedRelations() as $name => $relation) { + if (!$relation->methodExists('isSoftDeletable')) { + continue; + } + + // Apply soft delete to the relation if it's defined in the array config + $definition = $this->getRelationDefinition($name); + if (array_get($definition, 'softDelete', false)) { + $relation->softDeletable($definition['deletedAtColumn'] ?? 'deleted_at'); + } + + if (!$relation->isSoftDeletable()) { + continue; + } + + if (in_array(get_class($relation), [BelongsToMany::class, MorphToMany::class])) { + // relations using pivot table + $value = $this->fromDateTime($this->freshTimestamp()); + $this->updatePivotDeletedAtColumn($relation, $value); + return; + } + + if ($relation instanceof EloquentModel) { + $relation->delete(); + } elseif ($relation instanceof CollectionBase) { + $relation->each(function ($model) { + $model->delete(); + }); } } } @@ -187,13 +237,10 @@ public function restore() /** * Update relation pivot table deleted_at column */ - protected function updatePivotDeletedAtColumn(string $relationName, array $options, string|null $value) + protected function updatePivotDeletedAtColumn(Relation $relation, $value) { - // get deletedAtColumn from the relation options, otherwise use default - $deletedAtColumn = array_get($options, 'deletedAtColumn', 'deleted_at'); - - $this->{$relationName}()->newPivotQuery()->update([ - $deletedAtColumn => $value, + $relation->newPivotQuery()->update([ + $relation->getDeletedAtColumn() => $value, ]); } @@ -204,30 +251,37 @@ protected function updatePivotDeletedAtColumn(string $relationName, array $optio */ protected function performRestoreOnRelations() { - $definitions = $this->getRelationDefinitions(); - foreach ($definitions as $type => $relations) { - foreach ($relations as $name => $options) { - if (!array_get($options, 'softDelete', false)) { - continue; - } - - if (in_array($type, ['belongsToMany', 'morphToMany', 'morphedByMany'])) { - // relations using pivot table - $this->updatePivotDeletedAtColumn($name, $options, null); - } else { - $relation = $this->{$name}()->onlyTrashed()->getResults(); - if (!$relation) { - continue; - } - - if ($relation instanceof EloquentModel) { - $relation->restore(); - } elseif ($relation instanceof CollectionBase) { - $relation->each(function ($model) { - $model->restore(); - }); - } - } + foreach ($this->getDefinedRelations() as $name => $relation) { + if (!$relation->methodExists('isSoftDeletable')) { + continue; + } + + // Apply soft delete to the relation if it's defined in the array config + $definition = $this->getRelationDefinition($name); + if (array_get($definition, 'softDelete', false)) { + $relation->softDeletable($definition['deletedAtColumn'] ?? 'deleted_at'); + } + + if (!$relation->isSoftDeletable()) { + continue; + } + + if (in_array(get_class($relation), [BelongsToMany::class, MorphToMany::class])) { + $this->updatePivotDeletedAtColumn($relation, null); + return; + } + + $results = $relation->onlyTrashed()->getResults(); + if (!$results) { + continue; + } + + if ($results instanceof EloquentModel) { + $results->restore(); + } elseif ($results instanceof CollectionBase) { + $results->each(function ($model) { + $model->restore(); + }); } } } diff --git a/tests/Database/Fixtures/SoftDeleteUser.php b/tests/Database/Fixtures/SoftDeleteUser.php index 4893ea49c..7bc016c84 100644 --- a/tests/Database/Fixtures/SoftDeleteUser.php +++ b/tests/Database/Fixtures/SoftDeleteUser.php @@ -4,5 +4,5 @@ class SoftDeleteUser extends User { - use \Winter\Storm\Database\Traits\SimpleTree; + use \Winter\Storm\Database\Traits\SoftDelete; } diff --git a/tests/Database/Traits/SoftDeleteTest.php b/tests/Database/Traits/SoftDeleteTest.php index 258b8208b..997f8a020 100644 --- a/tests/Database/Traits/SoftDeleteTest.php +++ b/tests/Database/Traits/SoftDeleteTest.php @@ -2,7 +2,10 @@ namespace Winter\Storm\Tests\Database\Traits; -class SoftDeleteTest extends \DbTestCase +use Winter\Storm\Database\Relations\BelongsToMany; +use Winter\Storm\Tests\DbTestCase; + +class SoftDeleteTest extends DbTestCase { protected $seeded = []; @@ -82,6 +85,24 @@ public function testDeleteAndRestore() $this->assertTrue($post->deleted_at === null); $this->assertTrue($post->categories()->where('deleted_at', null)->count() === 2); } + + public function testDeleteAndRestoreLaravelRelations() + { + $post = Post::first(); + $this->assertTrue($post->deleted_at === null); + $this->assertTrue($post->labels()->where('deleted_at', null)->count() === 2); + + $post->delete(); + + $post = Post::withTrashed()->first(); + $this->assertTrue($post->deleted_at != null); + $this->assertTrue($post->labels()->where('deleted_at', '!=', null)->count() === 2); + $post->restore(); + + $post = Post::first(); + $this->assertTrue($post->deleted_at === null); + $this->assertTrue($post->labels()->where('deleted_at', null)->count() === 2); + } } class Post extends \Winter\Storm\Database\Model @@ -107,6 +128,11 @@ class Post extends \Winter\Storm\Database\Model 'softDelete' => true, ], ]; + + public function labels(): BelongsToMany + { + return $this->belongsToMany(Category::class, 'categories_posts', 'post_id', 'category_id')->softDeletable(); + } } class Category extends \Winter\Storm\Database\Model From 19b3f1aec6a1688499eac289084573d82621d940 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Mon, 26 Feb 2024 15:59:37 +0800 Subject: [PATCH 24/59] Bring in all remaining tests from core --- tests/Database/Fixtures/SoftDeleteAuthor.php | 2 +- tests/Database/Fixtures/UserWithAuthor.php | 10 + .../Fixtures/UserWithAuthorAndSoftDelete.php | 8 + .../Database/Fixtures/UserWithSoftAuthor.php | 10 + .../UserWithSoftAuthorAndSoftDelete.php | 8 + tests/Database/Traits/DeferredBindingTest.php | 102 ++++++++ tests/Database/Traits/NestedTreeTest.php | 154 +++++++++++ tests/Database/Traits/NullableTest.php | 52 ++++ tests/Database/Traits/RevisionableTest.php | 135 ++++++++++ tests/Database/Traits/SimpleTreeTest.php | 240 ++++++++++++++++++ tests/Database/Traits/SluggableTest.php | 104 +++++--- tests/Database/Traits/SoftDeleteTest.php | 189 +++++--------- 12 files changed, 854 insertions(+), 160 deletions(-) create mode 100644 tests/Database/Fixtures/UserWithAuthor.php create mode 100644 tests/Database/Fixtures/UserWithAuthorAndSoftDelete.php create mode 100644 tests/Database/Fixtures/UserWithSoftAuthor.php create mode 100644 tests/Database/Fixtures/UserWithSoftAuthorAndSoftDelete.php create mode 100644 tests/Database/Traits/DeferredBindingTest.php create mode 100644 tests/Database/Traits/NestedTreeTest.php create mode 100644 tests/Database/Traits/NullableTest.php create mode 100644 tests/Database/Traits/RevisionableTest.php create mode 100644 tests/Database/Traits/SimpleTreeTest.php diff --git a/tests/Database/Fixtures/SoftDeleteAuthor.php b/tests/Database/Fixtures/SoftDeleteAuthor.php index 88c51dce0..23dbddc1b 100644 --- a/tests/Database/Fixtures/SoftDeleteAuthor.php +++ b/tests/Database/Fixtures/SoftDeleteAuthor.php @@ -4,5 +4,5 @@ class SoftDeleteAuthor extends Author { - use \Winter\Storm\Database\Traits\SimpleTree; + use \Winter\Storm\Database\Traits\SoftDelete; } diff --git a/tests/Database/Fixtures/UserWithAuthor.php b/tests/Database/Fixtures/UserWithAuthor.php new file mode 100644 index 000000000..79658148d --- /dev/null +++ b/tests/Database/Fixtures/UserWithAuthor.php @@ -0,0 +1,10 @@ + [Author::class, 'key' => 'user_id', 'delete' => true], + ]; +} diff --git a/tests/Database/Fixtures/UserWithAuthorAndSoftDelete.php b/tests/Database/Fixtures/UserWithAuthorAndSoftDelete.php new file mode 100644 index 000000000..c333b61be --- /dev/null +++ b/tests/Database/Fixtures/UserWithAuthorAndSoftDelete.php @@ -0,0 +1,8 @@ + [SoftDeleteAuthor::class, 'key' => 'user_id', 'softDelete' => true], + ]; +} diff --git a/tests/Database/Fixtures/UserWithSoftAuthorAndSoftDelete.php b/tests/Database/Fixtures/UserWithSoftAuthorAndSoftDelete.php new file mode 100644 index 000000000..fcb2b413f --- /dev/null +++ b/tests/Database/Fixtures/UserWithSoftAuthorAndSoftDelete.php @@ -0,0 +1,8 @@ + 'Stevie']); + $post = Post::create(['title' => "First post"]); + $post2 = Post::create(['title' => "Second post"]); + Model::reguard(); + + $author->posts()->add($post, $sessionKey); + $this->assertEquals(1, DeferredBinding::count()); + + // Skip repeat bindings + $author->posts()->add($post, $sessionKey); + $this->assertEquals(1, DeferredBinding::count()); + + // Remove add-delete pairs + $author->posts()->remove($post, $sessionKey); + $this->assertEquals(0, DeferredBinding::count()); + + // Multi ball + $sessionKey = uniqid('session_key', true); + $author->posts()->add($post, $sessionKey); + $author->posts()->add($post, $sessionKey); + $author->posts()->add($post, $sessionKey); + $author->posts()->add($post, $sessionKey); + $author->posts()->add($post2, $sessionKey); + $author->posts()->add($post2, $sessionKey); + $author->posts()->add($post2, $sessionKey); + $author->posts()->add($post2, $sessionKey); + $author->posts()->add($post2, $sessionKey); + $this->assertEquals(2, DeferredBinding::count()); + + // Clean up add-delete pairs + $author->posts()->remove($post, $sessionKey); + $author->posts()->remove($post2, $sessionKey); + $this->assertEquals(0, DeferredBinding::count()); + + // Double negative + $author->posts()->remove($post, $sessionKey); + $author->posts()->remove($post2, $sessionKey); + $this->assertEquals(2, DeferredBinding::count()); + + // Skip repeat bindings + $author->posts()->remove($post, $sessionKey); + $author->posts()->remove($post2, $sessionKey); + $this->assertEquals(2, DeferredBinding::count()); + + // Clean up add-delete pairs again + $author->posts()->add($post, $sessionKey); + $author->posts()->add($post2, $sessionKey); + $this->assertEquals(0, DeferredBinding::count()); + } + + public function testCancelBinding() + { + $sessionKey = uniqid('session_key', true); + DeferredBinding::truncate(); + + Model::unguard(); + $author = Author::make(['name' => 'Stevie']); + $post = Post::create(['title' => "First post"]); + Model::reguard(); + + $author->posts()->add($post, $sessionKey); + $this->assertEquals(1, DeferredBinding::count()); + + $author->cancelDeferred($sessionKey); + $this->assertEquals(0, DeferredBinding::count()); + } + + public function testCommitBinding() + { + $sessionKey = uniqid('session_key', true); + DeferredBinding::truncate(); + + Model::unguard(); + $author = Author::make(['name' => 'Stevie']); + $post = Post::create(['title' => "First post"]); + Model::reguard(); + + $author->posts()->add($post, $sessionKey); + $this->assertEquals(1, DeferredBinding::count()); + + $author->commitDeferred($sessionKey); + $this->assertEquals(0, DeferredBinding::count()); + } +} diff --git a/tests/Database/Traits/NestedTreeTest.php b/tests/Database/Traits/NestedTreeTest.php new file mode 100644 index 000000000..984d18e6d --- /dev/null +++ b/tests/Database/Traits/NestedTreeTest.php @@ -0,0 +1,154 @@ +seedSampleTree(); + } + + public function testGetNested() + { + $items = CategoryNested::getNested(); + + // Eager loaded + $items->each(function ($item) { + $this->assertTrue($item->relationLoaded('children')); + }); + + $this->assertEquals(2, $items->count()); + } + + public function testGetAllRoot() + { + $items = CategoryNested::getAllRoot(); + + // Not eager loaded + $items->each(function ($item) { + $this->assertFalse($item->relationLoaded('children')); + }); + + $this->assertEquals(2, $items->count()); + } + + public function testListsNested() + { + $array = CategoryNested::listsNested('name', 'id'); + $this->assertEquals([ + 1 => 'Category Orange', + 2 => '   Autumn Leaves', + 3 => '      September', + 4 => '      October', + 5 => '      November', + 6 => '   Summer Breeze', + 7 => 'Category Green', + 8 => '   Winter Snow', + 9 => '   Spring Trees' + ], $array); + + CategoryNested::flushDuplicateCache(); + + $array = CategoryNested::listsNested('name', 'id', '--'); + $this->assertEquals([ + 1 => 'Category Orange', + 2 => '--Autumn Leaves', + 3 => '----September', + 4 => '----October', + 5 => '----November', + 6 => '--Summer Breeze', + 7 => 'Category Green', + 8 => '--Winter Snow', + 9 => '--Spring Trees' + ], $array); + + CategoryNested::flushDuplicateCache(); + + $array = CategoryNested::listsNested('description', 'name', '**'); + $this->assertEquals([ + 'Category Orange' => 'A root level test category', + 'Autumn Leaves' => '**Disccusion about the season of falling leaves.', + 'September' => '****The start of the fall season.', + 'October' => '****The middle of the fall season.', + 'November' => '****The end of the fall season.', + 'Summer Breeze' => '**Disccusion about the wind at the ocean.', + 'Category Green' => 'A root level test category', + 'Winter Snow' => '**Disccusion about the frosty snow flakes.', + 'Spring Trees' => '**Disccusion about the blooming gardens.' + ], $array); + } + + public function testListsNestedFromCollection() + { + $array = CategoryNested::get()->listsNested('custom_name', 'id', '...'); + $this->assertEquals([ + 1 => 'Category Orange (#1)', + 2 => '...Autumn Leaves (#2)', + 3 => '......September (#3)', + 4 => '......October (#4)', + 5 => '......November (#5)', + 6 => '...Summer Breeze (#6)', + 7 => 'Category Green (#7)', + 8 => '...Winter Snow (#8)', + 9 => '...Spring Trees (#9)' + ], $array); + } + + public function seedSampleTree() + { + Model::unguard(); + + $orange = CategoryNested::create([ + 'name' => 'Category Orange', + 'description' => 'A root level test category', + ]); + + $autumn = $orange->children()->create([ + 'name' => 'Autumn Leaves', + 'description' => 'Disccusion about the season of falling leaves.' + ]); + + $autumn->children()->create([ + 'name' => 'September', + 'description' => 'The start of the fall season.' + ]); + + $autumn->children()->create([ + 'name' => 'October', + 'description' => 'The middle of the fall season.' + ]); + + $autumn->children()->create([ + 'name' => 'November', + 'description' => 'The end of the fall season.' + ]); + + $orange->children()->create([ + 'name' => 'Summer Breeze', + 'description' => 'Disccusion about the wind at the ocean.' + ]); + + $green = CategoryNested::create([ + 'name' => 'Category Green', + 'description' => 'A root level test category', + ]); + + $green->children()->create([ + 'name' => 'Winter Snow', + 'description' => 'Disccusion about the frosty snow flakes.' + ]); + + $green->children()->create([ + 'name' => 'Spring Trees', + 'description' => 'Disccusion about the blooming gardens.' + ]); + + Model::reguard(); + } +} diff --git a/tests/Database/Traits/NullableTest.php b/tests/Database/Traits/NullableTest.php new file mode 100644 index 000000000..938ca028d --- /dev/null +++ b/tests/Database/Traits/NullableTest.php @@ -0,0 +1,52 @@ + ''])->reload(); + $this->assertEquals('Winter', $post->author_nickname); + + // Save as empty string + $post->author_nickname = ''; + $post->save(); + $this->assertNull($post->author_nickname); + } + + public function testNonEmptyValuesAreIgnored() + { + // Save as value + $post = NullablePost::create(['author_nickname' => 'Joe']); + $this->assertEquals('Joe', $post->author_nickname); + + // Save as zero integer + $post->author_nickname = 0; + $post->save(); + $this->assertNotNull($post->author_nickname); + $this->assertEquals(0, $post->author_nickname); + + // Save as zero float + $post->author_nickname = 0.0; + $post->save(); + $this->assertNotNull($post->author_nickname); + $this->assertEquals(0.0, $post->author_nickname); + + // Save as zero string + $post->author_nickname = '0'; + $post->save(); + $this->assertNotNull($post->author_nickname); + $this->assertEquals('0', $post->author_nickname); + + // Save as false + $post->author_nickname = false; + $post->save(); + $this->assertNotNull($post->author_nickname); + $this->assertEquals(false, $post->author_nickname); + } +} diff --git a/tests/Database/Traits/RevisionableTest.php b/tests/Database/Traits/RevisionableTest.php new file mode 100644 index 000000000..f90fbe5e8 --- /dev/null +++ b/tests/Database/Traits/RevisionableTest.php @@ -0,0 +1,135 @@ + 'Hello World!']); + $this->assertEquals('Hello World!', $post->title); + $this->assertEquals(0, $post->revision_history()->count()); + + $post->revisionsEnabled = false; + $post->title = 'Helloooooooooooo!'; + $post->save(); + $this->assertEquals(0, $post->revision_history()->count()); + } + + public function testUpdateSingleField() + { + $post = RevisionablePost::create(['title' => 'Hello World!']); + $this->assertEquals('Hello World!', $post->title); + $this->assertEquals(0, $post->revision_history()->count()); + + $post->title = 'Helloooooooooooo!'; + $post->save(); + $this->assertEquals(1, $post->revision_history()->count()); + + $item = $post->revision_history()->first(); + + $this->assertEquals('title', $item->field); + $this->assertEquals('Hello World!', $item->old_value); + $this->assertEquals('Helloooooooooooo!', $item->new_value); + $this->assertEquals(7, $item->user_id); + } + + public function testUpdateMultipleFields() + { + $post = RevisionablePost::create([ + 'title' => 'Hello World!', + 'slug' => 'hello-world', + 'description' => 'Good day, Commander', + 'is_published' => true, + 'published_at' => new \DateTime + ]); + + $this->assertEquals(0, $post->revision_history()->count()); + + $post->title = "Gday mate"; + $post->slug = "gday-mate"; + $post->description = 'Wazzaaaaaaaaaaaaap'; + $post->is_published = false; + $post->published_at = Carbon::now()->addDays(1); + $post->save(); + + $history = $post->revision_history; + + $this->assertEquals(5, $history->count()); + $this->assertEquals([ + 'title', + 'slug', + 'description', + 'is_published', + 'published_at' + ], $history->lists('field')); + } + + public function testExceedingRevisionLimit() + { + $post = RevisionablePost::create([ + 'title' => 'Hello World!', + 'slug' => 'hello-world', + 'description' => 'Good day, Commander', + 'is_published' => true, + 'published_at' => new \DateTime + ]); + + $this->assertEquals(0, $post->revision_history()->count()); + + $post->title = "Gday mate"; + $post->slug = "gday-mate"; + $post->description = 'Wazzaaaaaaaaaaaaap'; + $post->is_published = false; + $post->published_at = Carbon::now()->addDays(1); + $post->save(); + + $post->title = 'The Boss'; + $post->slug = 'the-boss'; + $post->description = 'Paid the cost to be the boss'; + $post->is_published = true; + $post->published_at = Carbon::now()->addDays(10); + $post->save(); + + // Count 10 changes above, limit is 8 + $this->assertEquals(8, $post->revision_history()->count()); + } + + public function testSoftDeletes() + { + $post = RevisionablePost::create(['title' => 'Hello World!']); + $this->assertEquals('Hello World!', $post->title); + $this->assertEquals(0, $post->revision_history()->count()); + + $post->title = 'Helloooooooooooo!'; + $post->save(); + $this->assertEquals(1, $post->revision_history()->count()); + + $post->delete(); + $this->assertEquals(2, $post->revision_history()->count()); + } + + public function testRevisionDateCast() + { + $post = RevisionablePost::create([ + 'title' => 'Hello World!', + 'published_at' => Carbon::now() + ]); + $this->assertEquals(0, $post->revision_history()->count()); + + $post->published_at = Carbon::now()->addDays(1); + $post->save(); + + $this->assertEquals(1, $post->revision_history()->count()); + + $item = $post->revision_history()->first(); + $this->assertEquals('published_at', $item->field); + $this->assertEquals('date', $item->cast); + $this->assertInstanceOf('Carbon\Carbon', $item->old_value); + $this->assertInstanceOf('Carbon\Carbon', $item->new_value); + } +} diff --git a/tests/Database/Traits/SimpleTreeTest.php b/tests/Database/Traits/SimpleTreeTest.php new file mode 100644 index 000000000..2bf877c3c --- /dev/null +++ b/tests/Database/Traits/SimpleTreeTest.php @@ -0,0 +1,240 @@ +seedSampleTree(); + } + + public function testGetNested() + { + $items = CategorySimple::getNested(); + + // Eager loaded + $items->each(function ($item) { + $this->assertTrue($item->relationLoaded('children')); + }); + + $this->assertEquals(3, $items->count()); + } + + public function testGetAllRoot() + { + $items = CategorySimple::getAllRoot(); + + // Not eager loaded + $items->each(function ($item) { + $this->assertFalse($item->relationLoaded('children')); + }); + + $this->assertEquals(3, $items->count()); + } + + public function testGetChildren() + { + // Not eager loaded + $item = CategorySimple::first(); + $this->assertFalse($item->relationLoaded('children')); + $this->assertEquals(6, $item->getChildren()->count()); + + // Not eager loaded + $item = CategorySimple::getAllRoot()->first(); + $this->assertFalse($item->relationLoaded('children')); + $this->assertEquals(6, $item->getChildren()->count()); + + // Eager loaded + $item = CategorySimple::getNested()->first(); + $this->assertTrue($item->relationLoaded('children')); + $this->assertEquals(6, $item->getChildren()->count()); + } + + public function testGetChildCount() + { + // Not eager loaded + $item = CategorySimple::first(); + $this->assertFalse($item->relationLoaded('children')); + $this->assertEquals(9, $item->getChildCount()); + + // Not eager loaded + $item = CategorySimple::getAllRoot()->first(); + $this->assertFalse($item->relationLoaded('children')); + $this->assertEquals(9, $item->getChildCount()); + + // Eager loaded + $item = CategorySimple::getNested()->first(); + $this->assertTrue($item->relationLoaded('children')); + $this->assertEquals(9, $item->getChildCount()); + } + + public function testGetAllChildren() + { + // Not eager loaded + $item = CategorySimple::first(); + $this->assertFalse($item->relationLoaded('children')); + $this->assertEquals(9, $item->getAllChildren()->count()); + + // Not eager loaded + $item = CategorySimple::getAllRoot()->first(); + $this->assertFalse($item->relationLoaded('children')); + $this->assertEquals(9, $item->getAllChildren()->count()); + + // Eager loaded + $item = CategorySimple::getNested()->first(); + $this->assertTrue($item->relationLoaded('children')); + $this->assertEquals(9, $item->getAllChildren()->count()); + } + + public function testListsNested() + { + $array = CategorySimple::listsNested('name', 'id'); + $this->assertEquals([ + 1 => 'Web development', + 2 => '   HTML5', + 3 => '   CSS3', + 4 => '   jQuery', + 5 => '   Bootstrap', + 6 => '   Laravel', + 7 => '   Winter CMS', + 8 => '      September', + 9 => '      October', + 10 => '      November', + 11 => 'Mobile development', + 12 => '   iOS', + 13 => '   iPhone', + 14 => '   iPad', + 15 => '   Android', + 16 => 'Graphic design', + 17 => '   Photoshop', + 18 => '   Illustrator', + 19 => '   Fireworks' + ], $array); + + $array = CategorySimple::listsNested('name', 'id', '--'); + $this->assertEquals([ + 1 => 'Web development', + 2 => '--HTML5', + 3 => '--CSS3', + 4 => '--jQuery', + 5 => '--Bootstrap', + 6 => '--Laravel', + 7 => '--Winter CMS', + 8 => '----September', + 9 => '----October', + 10 => '----November', + 11 => 'Mobile development', + 12 => '--iOS', + 13 => '--iPhone', + 14 => '--iPad', + 15 => '--Android', + 16 => 'Graphic design', + 17 => '--Photoshop', + 18 => '--Illustrator', + 19 => '--Fireworks' + ], $array); + + $array = CategorySimple::listsNested('id', 'name', '**'); + $this->assertEquals([ + 'Web development' => '1', + 'HTML5' => '**2', + 'CSS3' => '**3', + 'jQuery' => '**4', + 'Bootstrap' => '**5', + 'Laravel' => '**6', + 'Winter CMS' => '**7', + 'September' => '****8', + 'October' => '****9', + 'November' => '****10', + 'Mobile development' => '11', + 'iOS' => '**12', + 'iPhone' => '**13', + 'iPad' => '**14', + 'Android' => '**15', + 'Graphic design' => '16', + 'Photoshop' => '**17', + 'Illustrator' => '**18', + 'Fireworks' => '**19' + ], $array); + } + + public function testListsNestedUnknownColumn() + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Column mismatch in listsNested method'); + + CategorySimple::listsNested('custom_name', 'id'); + } + + public function testListsNestedFromCollection() + { + $array = CategorySimple::get()->listsNested('custom_name', 'id', '...'); + + $this->assertEquals([ + 1 => 'Web development (#1)', + 2 => '...HTML5 (#2)', + 3 => '...CSS3 (#3)', + 4 => '...jQuery (#4)', + 5 => '...Bootstrap (#5)', + 6 => '...Laravel (#6)', + 7 => '...Winter CMS (#7)', + 8 => '......September (#8)', + 9 => '......October (#9)', + 10 => '......November (#10)', + 11 => 'Mobile development (#11)', + 12 => '...iOS (#12)', + 13 => '...iPhone (#13)', + 14 => '...iPad (#14)', + 15 => '...Android (#15)', + 16 => 'Graphic design (#16)', + 17 => '...Photoshop (#17)', + 18 => '...Illustrator (#18)', + 19 => '...Fireworks (#19)' + ], $array); + } + + + public function seedSampleTree() + { + Model::unguard(); + + $webdev = CategorySimple::create([ + 'name' => 'Web development' + ]); + + $webdev->children()->create(['name' => 'HTML5']); + $webdev->children()->create(['name' => 'CSS3']); + $webdev->children()->create(['name' => 'jQuery']); + $webdev->children()->create(['name' => 'Bootstrap']); + $webdev->children()->create(['name' => 'Laravel']); + $winter = $webdev->children()->create(['name' => 'Winter CMS']); + $winter->children()->create(['name' => 'September']); + $winter->children()->create(['name' => 'October']); + $winter->children()->create(['name' => 'November']); + + $mobdev = CategorySimple::create([ + 'name' => 'Mobile development' + ]); + + $mobdev->children()->create(['name' => 'iOS']); + $mobdev->children()->create(['name' => 'iPhone']); + $mobdev->children()->create(['name' => 'iPad']); + $mobdev->children()->create(['name' => 'Android']); + + $design = CategorySimple::create([ + 'name' => 'Graphic design' + ]); + + $design->children()->create(['name' => 'Photoshop']); + $design->children()->create(['name' => 'Illustrator']); + $design->children()->create(['name' => 'Fireworks']); + + Model::reguard(); + } +} diff --git a/tests/Database/Traits/SluggableTest.php b/tests/Database/Traits/SluggableTest.php index 5ed0b67c8..f4934df8b 100644 --- a/tests/Database/Traits/SluggableTest.php +++ b/tests/Database/Traits/SluggableTest.php @@ -1,5 +1,10 @@ createTables(); } - public function testSlugGeneration() + public function testFillPost() { - /* - * Basic usage of slug Generator - */ - $testModel1 = TestModelSluggable::Create(['name' => 'test']); - $this->assertEquals($testModel1->slug, 'test'); + $post = SluggablePost::create(['title' => 'Hello World!']); + $this->assertEquals('hello-world', $post->slug); + } - $testModel2 = TestModelSluggable::Create(['name' => 'test']); - $this->assertEquals($testModel2->slug, 'test-2'); + public function testSetAttributeOnPost() + { + $post = new SluggablePost; + $post->title = "Let's go, rock show!"; + $post->save(); - $testModel3 = TestModelSluggable::Create(['name' => 'test']); - $this->assertEquals($testModel3->slug, 'test-3'); + $this->assertEquals('lets-go-rock-show', $post->slug); } - public function testSlugGenerationSoftDelete() + public function testSetSlugAttributeManually() { - /* - * Basic usage of slug Generator with softDelete - */ - $testSoftModel1 = TestModelSluggableSoftDelete::Create(['name' => 'test']); - $this->assertEquals($testSoftModel1->slug, 'test'); + $post = new SluggablePost; + $post->title = 'We parked in a comfortable spot'; + $post->slug = 'war-is-pain'; + $post->save(); - $testSoftModel2 = TestModelSluggableSoftDelete::Create(['name' => 'test']); - $this->assertEquals($testSoftModel2->slug, 'test-2'); + $this->assertEquals('war-is-pain', $post->slug); + } + + public function testConcatenatedSlug() + { + $post = new SluggablePost; + $post->title = 'Sweetness and Light'; + $post->description = 'Itchee and Scratchee'; + $post->save(); - $testSoftModel3 = TestModelSluggableSoftDelete::Create(['name' => 'test']); - $this->assertEquals($testSoftModel3->slug, 'test-3'); + $this->assertEquals('sweetness-and-light-itchee-and-scratchee', $post->long_slug); } - public function testSlugGenerationSoftDeleteAllow() + public function testDuplicateSlug() { - /* - * Basic usage of slug Generator with softDelete - * And allowTrashedSlugs - */ - $testSoftModelAllow1 = TestModelSluggableSoftDeleteAllow::Create(['name' => 'test']); - $this->assertEquals($testSoftModelAllow1->slug, 'test'); + $post1 = SluggablePost::create(['title' => 'Pace yourself']); + $post2 = SluggablePost::create(['title' => 'Pace yourself']); + $post3 = SluggablePost::create(['title' => 'Pace yourself']); - $testSoftModelAllow2 = TestModelSluggableSoftDeleteAllow::Create(['name' => 'test']); - $this->assertEquals($testSoftModelAllow2->slug, 'test-2'); + $this->assertEquals('pace-yourself', $post1->slug); + $this->assertEquals('pace-yourself-2', $post2->slug); + $this->assertEquals('pace-yourself-3', $post3->slug); + } - $testSoftModelAllow3 = TestModelSluggableSoftDeleteAllow::Create(['name' => 'test']); - $this->assertEquals($testSoftModelAllow3->slug, 'test-3'); + public function testCollisionWithSelf() + { + $post1 = SluggablePost::create(['title' => 'Watch yourself']); + $post2 = SluggablePost::create(['title' => 'Watch yourself']); + $post3 = SluggablePost::create(['title' => 'Watch yourself']); + + $this->assertEquals('watch-yourself', $post1->slug); + $this->assertEquals('watch-yourself-2', $post2->slug); + $this->assertEquals('watch-yourself-3', $post3->slug); + + $post3->slugAttributes(); + $post3->save(); + $post2->slugAttributes(); + $post2->save(); + $post1->slugAttributes(); + $post1->save(); + + $this->assertEquals('watch-yourself', $post1->slug); + $this->assertEquals('watch-yourself-2', $post2->slug); + $this->assertEquals('watch-yourself-3', $post3->slug); + } + + public function testSuffixCollision() + { + $post1 = SluggablePost::create(['title' => 'Type 1']); + $post2 = SluggablePost::create(['title' => 'Type 2']); + $post3 = SluggablePost::create(['title' => 'Type 3']); + $post4 = SluggablePost::create(['title' => 'Type 3']); + $post5 = SluggablePost::create(['title' => 'Type 3']); + + $this->assertEquals('type-1', $post1->slug); + $this->assertEquals('type-2', $post2->slug); + $this->assertEquals('type-3', $post3->slug); + $this->assertEquals('type-3-2', $post4->slug); + $this->assertEquals('type-3-3', $post5->slug); } public function testSlugGenerationWithSoftDeletion() diff --git a/tests/Database/Traits/SoftDeleteTest.php b/tests/Database/Traits/SoftDeleteTest.php index 997f8a020..0762a91d8 100644 --- a/tests/Database/Traits/SoftDeleteTest.php +++ b/tests/Database/Traits/SoftDeleteTest.php @@ -2,156 +2,89 @@ namespace Winter\Storm\Tests\Database\Traits; -use Winter\Storm\Database\Relations\BelongsToMany; +use Winter\Storm\Tests\Database\Fixtures\Author; +use Winter\Storm\Tests\Database\Fixtures\UserWithAuthor; +use Winter\Storm\Tests\Database\Fixtures\SoftDeleteAuthor; +use Winter\Storm\Tests\Database\Fixtures\UserWithSoftAuthor; +use Winter\Storm\Tests\Database\Fixtures\UserWithAuthorAndSoftDelete; +use Winter\Storm\Tests\Database\Fixtures\UserWithSoftAuthorAndSoftDelete; +use Winter\Storm\Database\Model; use Winter\Storm\Tests\DbTestCase; class SoftDeleteTest extends DbTestCase { - protected $seeded = []; - - public function setUp(): void + public function testDeleteOptionOnHardModel() { - parent::setUp(); - - $this->seeded = [ - 'posts' => [], - 'categories' => [] - ]; - - $this->createTables(); - $this->seedTables(); + Model::unguard(); + $user = UserWithAuthor::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $author = Author::create(['name' => 'Louie', 'email' => 'louie@example.com', 'user_id' => $user->id]); + Model::reguard(); + + $authorId = $author->id; + $user->delete(); // Hard + $this->assertNull(Author::find($authorId)); } - protected function createTables() + public function testSoftDeleteOptionOnHardModel() { - $this->getBuilder()->create('posts', function ($table) { - $table->increments('id'); - $table->string('title')->default(''); - $table->timestamps(); - $table->timestamp('deleted_at')->nullable(); - }); - - $this->getBuilder()->create('categories', function ($table) { - $table->increments('id'); - $table->string('name'); - $table->timestamps(); - }); - - $this->getBuilder()->create('categories_posts', function ($table) { - $table->primary(['post_id', 'category_id']); - $table->unsignedInteger('post_id'); - $table->unsignedInteger('category_id'); - $table->timestamp('deleted_at')->nullable(); - }); + Model::unguard(); + $user = UserWithSoftAuthor::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $author = Author::create(['name' => 'Louie', 'email' => 'louie@example.com', 'user_id' => $user->id]); + Model::reguard(); + + $authorId = $author->id; + $user->delete(); // Hard + $this->assertNotNull(Author::find($authorId)); // Do nothing } - protected function seedTables() + public function testSoftDeleteOptionOnSoftModel() { - $this->seeded['posts'][] = Post::create([ - 'title' => 'First Post', - ]); - $this->seeded['posts'][] = Post::create([ - 'title' => 'Second Post', - ]); - - $this->seeded['categories'][] = Category::create([ - 'name' => 'Category 1' - ]); - $this->seeded['categories'][] = Category::create([ - 'name' => 'Category 2' - ]); - - $this->seeded['posts'][0]->categories()->attach($this->seeded['categories'][0]); - $this->seeded['posts'][0]->categories()->attach($this->seeded['categories'][1]); - - $this->seeded['posts'][1]->categories()->attach($this->seeded['categories'][0]); - $this->seeded['posts'][1]->categories()->attach($this->seeded['categories'][1]); + Model::unguard(); + $user = UserWithSoftAuthorAndSoftDelete::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $author = SoftDeleteAuthor::create(['name' => 'Louie', 'email' => 'louie@example.com', 'user_id' => $user->id]); + Model::reguard(); + + $authorId = $author->id; + $user->delete(); // Soft + $this->assertNull(SoftDeleteAuthor::find($authorId)); + $this->assertNotNull(SoftDeleteAuthor::withTrashed()->find($authorId)); } - public function testDeleteAndRestore() + public function testDeleteOptionOnSoftModel() { - $post = Post::first(); - $this->assertTrue($post->deleted_at === null); - $this->assertTrue($post->categories()->where('deleted_at', null)->count() === 2); + Model::unguard(); + $user = UserWithAuthorAndSoftDelete::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $author = Author::create(['name' => 'Louie', 'email' => 'louie@example.com', 'user_id' => $user->id]); + Model::reguard(); - $post->delete(); + $authorId = $author->id; + $user->delete(); // Soft + $this->assertNotNull(Author::find($authorId)); // Do nothing - $post = Post::withTrashed()->first(); - $this->assertTrue($post->deleted_at != null); - $this->assertTrue($post->categories()->where('deleted_at', '!=', null)->count() === 2); - $post->restore(); + $userId = $user->id; + $user = UserWithAuthorAndSoftDelete::withTrashed()->find($userId); + $user->restore(); - $post = Post::first(); - $this->assertTrue($post->deleted_at === null); - $this->assertTrue($post->categories()->where('deleted_at', null)->count() === 2); + $user->forceDelete(); // Hard + $this->assertNull(Author::find($authorId)); } - public function testDeleteAndRestoreLaravelRelations() + public function testRestoreSoftDeleteRelation() { - $post = Post::first(); - $this->assertTrue($post->deleted_at === null); - $this->assertTrue($post->labels()->where('deleted_at', null)->count() === 2); - - $post->delete(); - - $post = Post::withTrashed()->first(); - $this->assertTrue($post->deleted_at != null); - $this->assertTrue($post->labels()->where('deleted_at', '!=', null)->count() === 2); - $post->restore(); - - $post = Post::first(); - $this->assertTrue($post->deleted_at === null); - $this->assertTrue($post->labels()->where('deleted_at', null)->count() === 2); - } -} + Model::unguard(); + $user = UserWithSoftAuthorAndSoftDelete::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $author = SoftDeleteAuthor::create(['name' => 'Louie', 'email' => 'louie@example.com', 'user_id' => $user->id]); + Model::reguard(); -class Post extends \Winter\Storm\Database\Model -{ - use \Winter\Storm\Database\Traits\SoftDelete; - - public $table = 'posts'; - - public $fillable = ['title']; - - protected $dates = [ - 'created_at', - 'updated_at', - 'deleted_at', - ]; + $authorId = $author->id; + $user->delete(); // Soft + $this->assertNull(SoftDeleteAuthor::find($authorId)); + $this->assertNotNull(SoftDeleteAuthor::withTrashed()->find($authorId)); - public $belongsToMany = [ - 'categories' => [ - Category::class, - 'table' => 'categories_posts', - 'key' => 'post_id', - 'otherKey' => 'category_id', - 'softDelete' => true, - ], - ]; + $userId = $user->id; + $user = UserWithSoftAuthorAndSoftDelete::withTrashed()->find($userId); + $user->restore(); - public function labels(): BelongsToMany - { - return $this->belongsToMany(Category::class, 'categories_posts', 'post_id', 'category_id')->softDeletable(); + $this->assertNotNull(SoftDeleteAuthor::find($authorId)); } } - -class Category extends \Winter\Storm\Database\Model -{ - public $table = 'categories'; - - public $fillable = ['name']; - - protected $dates = [ - 'created_at', - 'updated_at', - ]; - - public $belongsToMany = [ - 'posts' => [ - Post::class, - 'table' => 'categories_posts', - 'key' => 'category_id', - 'otherKey' => 'post_id', - ], - ]; -} From a2b12e5cb268792c50eebf331ca3f9670d6138d7 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Tue, 27 Feb 2024 11:24:15 +0800 Subject: [PATCH 25/59] Fix soft deletion --- src/Database/Traits/SoftDelete.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Database/Traits/SoftDelete.php b/src/Database/Traits/SoftDelete.php index 5cd264468..fa8d1426c 100644 --- a/src/Database/Traits/SoftDelete.php +++ b/src/Database/Traits/SoftDelete.php @@ -182,10 +182,12 @@ protected function performSoftDeleteOnRelations() return; } - if ($relation instanceof EloquentModel) { - $relation->delete(); - } elseif ($relation instanceof CollectionBase) { - $relation->each(function ($model) { + $records = $relation->getResults(); + + if ($records instanceof EloquentModel) { + $records->delete(); + } elseif ($records instanceof CollectionBase) { + $records->each(function ($model) { $model->delete(); }); } From 06f7b6907a57cb72ef1ba795133daacee7c6801a Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Tue, 27 Feb 2024 13:11:08 +0800 Subject: [PATCH 26/59] Add soft delete tests specifically with Laravel relations --- tests/Database/Fixtures/UserLaravel.php | 52 ++++++++++++ .../Fixtures/UserLaravelWithSoftAuthor.php | 13 +++ ...UserLaravelWithSoftAuthorAndSoftDelete.php | 8 ++ .../Fixtures/UserLaravelWithSoftDelete.php | 8 ++ tests/Database/Traits/SoftDeleteTest.php | 79 +++++++++++++++++++ 5 files changed, 160 insertions(+) create mode 100644 tests/Database/Fixtures/UserLaravel.php create mode 100644 tests/Database/Fixtures/UserLaravelWithSoftAuthor.php create mode 100644 tests/Database/Fixtures/UserLaravelWithSoftAuthorAndSoftDelete.php create mode 100644 tests/Database/Fixtures/UserLaravelWithSoftDelete.php diff --git a/tests/Database/Fixtures/UserLaravel.php b/tests/Database/Fixtures/UserLaravel.php new file mode 100644 index 000000000..ffc7058b7 --- /dev/null +++ b/tests/Database/Fixtures/UserLaravel.php @@ -0,0 +1,52 @@ +hasOne(Author::class, 'user_id')->dependent(); + } + + public static function migrateUp(Builder $builder): void + { + if ($builder->hasTable('database_tester_users')) { + return; + } + + $builder->create('database_tester_users', function ($table) { + $table->engine = 'InnoDB'; + $table->increments('id'); + $table->string('name')->nullable(); + $table->string('email')->nullable(); + $table->softDeletes(); + $table->timestamps(); + }); + } + + public static function migrateDown(Builder $builder): void + { + if (!$builder->hasTable('database_tester_users')) { + return; + } + + $builder->dropIfExists('database_tester_users'); + } +} diff --git a/tests/Database/Fixtures/UserLaravelWithSoftAuthor.php b/tests/Database/Fixtures/UserLaravelWithSoftAuthor.php new file mode 100644 index 000000000..10d6a1426 --- /dev/null +++ b/tests/Database/Fixtures/UserLaravelWithSoftAuthor.php @@ -0,0 +1,13 @@ +hasOne(SoftDeleteAuthor::class, 'user_id')->softDeletable(); + } +} diff --git a/tests/Database/Fixtures/UserLaravelWithSoftAuthorAndSoftDelete.php b/tests/Database/Fixtures/UserLaravelWithSoftAuthorAndSoftDelete.php new file mode 100644 index 000000000..71e374513 --- /dev/null +++ b/tests/Database/Fixtures/UserLaravelWithSoftAuthorAndSoftDelete.php @@ -0,0 +1,8 @@ +assertNull(Author::find($authorId)); } + public function testDeleteOptionOnHardModelLaravelRelation() + { + Model::unguard(); + $user = UserLaravel::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $author = Author::create(['name' => 'Louie', 'email' => 'louie@example.com', 'user_id' => $user->id]); + Model::reguard(); + + $authorId = $author->id; + $user->delete(); // Hard + $this->assertNull(Author::find($authorId)); + } + public function testSoftDeleteOptionOnHardModel() { Model::unguard(); @@ -37,6 +53,18 @@ public function testSoftDeleteOptionOnHardModel() $this->assertNotNull(Author::find($authorId)); // Do nothing } + public function testSoftDeleteOptionOnHardModelLaravelRelation() + { + Model::unguard(); + $user = UserLaravelWithSoftAuthor::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $author = Author::create(['name' => 'Louie', 'email' => 'louie@example.com', 'user_id' => $user->id]); + Model::reguard(); + + $authorId = $author->id; + $user->delete(); // Hard + $this->assertNotNull(Author::find($authorId)); // Do nothing + } + public function testSoftDeleteOptionOnSoftModel() { Model::unguard(); @@ -50,6 +78,19 @@ public function testSoftDeleteOptionOnSoftModel() $this->assertNotNull(SoftDeleteAuthor::withTrashed()->find($authorId)); } + public function testSoftDeleteOptionOnSoftModelLaravelRelation() + { + Model::unguard(); + $user = UserLaravelWithSoftAuthorAndSoftDelete::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $author = SoftDeleteAuthor::create(['name' => 'Louie', 'email' => 'louie@example.com', 'user_id' => $user->id]); + Model::reguard(); + + $authorId = $author->id; + $user->delete(); // Soft + $this->assertNull(SoftDeleteAuthor::find($authorId)); + $this->assertNotNull(SoftDeleteAuthor::withTrashed()->find($authorId)); + } + public function testDeleteOptionOnSoftModel() { Model::unguard(); @@ -69,6 +110,25 @@ public function testDeleteOptionOnSoftModel() $this->assertNull(Author::find($authorId)); } + public function testDeleteOptionOnSoftModelLaravelRelation() + { + Model::unguard(); + $user = UserLaravelWithSoftDelete::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $author = Author::create(['name' => 'Louie', 'email' => 'louie@example.com', 'user_id' => $user->id]); + Model::reguard(); + + $authorId = $author->id; + $user->delete(); // Soft + $this->assertNotNull(Author::find($authorId)); // Do nothing + + $userId = $user->id; + $user = UserLaravelWithSoftDelete::withTrashed()->find($userId); + $user->restore(); + + $user->forceDelete(); // Hard + $this->assertNull(Author::find($authorId)); + } + public function testRestoreSoftDeleteRelation() { Model::unguard(); @@ -87,4 +147,23 @@ public function testRestoreSoftDeleteRelation() $this->assertNotNull(SoftDeleteAuthor::find($authorId)); } + + public function testRestoreSoftDeleteRelationLaravelRelation() + { + Model::unguard(); + $user = UserLaravelWithSoftAuthorAndSoftDelete::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $author = SoftDeleteAuthor::create(['name' => 'Louie', 'email' => 'louie@example.com', 'user_id' => $user->id]); + Model::reguard(); + + $authorId = $author->id; + $user->delete(); // Soft + $this->assertNull(SoftDeleteAuthor::find($authorId)); + $this->assertNotNull(SoftDeleteAuthor::withTrashed()->find($authorId)); + + $userId = $user->id; + $user = UserLaravelWithSoftAuthorAndSoftDelete::withTrashed()->find($userId); + $user->restore(); + + $this->assertNotNull(SoftDeleteAuthor::find($authorId)); + } } From 2bde984acf90b6d32fbd174a06e895ec3728862e Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Thu, 28 Mar 2024 09:04:02 +0800 Subject: [PATCH 27/59] Merge branch 'fix-through-relations' into wip/laravel-style-relations Also add ability to define relations as "counted" relations only. --- src/Database/Concerns/HasRelationships.php | 11 +++ src/Database/Relations/AttachMany.php | 2 + src/Database/Relations/AttachOne.php | 2 + src/Database/Relations/BelongsTo.php | 2 + src/Database/Relations/BelongsToMany.php | 2 + .../Relations/Concerns/CanBeCounted.php | 68 +++++++++++++++++++ .../Relations/Concerns/DefinedConstraints.php | 21 ++++-- src/Database/Relations/HasMany.php | 2 + src/Database/Relations/HasManyThrough.php | 2 + src/Database/Relations/HasOne.php | 2 + src/Database/Relations/HasOneThrough.php | 2 + src/Database/Relations/MorphMany.php | 2 + src/Database/Relations/MorphOne.php | 2 + src/Database/Relations/MorphTo.php | 2 + src/Database/Relations/MorphToMany.php | 2 + .../Concerns/HasRelationshipsTest.php | 6 ++ tests/Database/Fixtures/Country.php | 11 +++ .../Database/Relations/HasManyThroughTest.php | 62 +++++++++++++++++ 18 files changed, 198 insertions(+), 5 deletions(-) create mode 100644 src/Database/Relations/Concerns/CanBeCounted.php diff --git a/src/Database/Concerns/HasRelationships.php b/src/Database/Concerns/HasRelationships.php index 5a031db0c..4d571ad48 100644 --- a/src/Database/Concerns/HasRelationships.php +++ b/src/Database/Concerns/HasRelationships.php @@ -543,6 +543,17 @@ class_uses_recursive($relation) $relation = $relation->noPush(); } + // Add count only flag, if required + if ( + in_array( + \Winter\Storm\Database\Relations\Concerns\CanBeCounted::class, + class_uses_recursive($relation) + ) + && (($definition['count'] ?? false) === true) + ) { + $relation = $relation->countOnly(); + } + if ($addConstraints) { // Add defined constraints $relation->addDefinedConstraints(); diff --git a/src/Database/Relations/AttachMany.php b/src/Database/Relations/AttachMany.php index aa4f256f5..a6b86d355 100644 --- a/src/Database/Relations/AttachMany.php +++ b/src/Database/Relations/AttachMany.php @@ -13,6 +13,7 @@ class AttachMany extends MorphManyBase implements Relation { use Concerns\AttachOneOrMany; + use Concerns\CanBeCounted; use Concerns\CanBeDependent; use Concerns\CanBeExtended; use Concerns\CanBePushed; @@ -143,6 +144,7 @@ public function getArrayDefinition(): array 'delete' => $this->isDependent(), 'public' => $this->public, 'push' => $this->isPushable(), + 'count' => $this->isCountOnly(), ]; } } diff --git a/src/Database/Relations/AttachOne.php b/src/Database/Relations/AttachOne.php index 66de43fb6..d965dc7a1 100644 --- a/src/Database/Relations/AttachOne.php +++ b/src/Database/Relations/AttachOne.php @@ -13,6 +13,7 @@ class AttachOne extends MorphOneBase implements Relation { use Concerns\AttachOneOrMany; + use Concerns\CanBeCounted; use Concerns\CanBeDependent; use Concerns\CanBeExtended; use Concerns\CanBePushed; @@ -125,6 +126,7 @@ public function getArrayDefinition(): array 'delete' => $this->isDependent(), 'public' => $this->public, 'push' => $this->isPushable(), + 'count' => $this->isCountOnly(), ]; } } diff --git a/src/Database/Relations/BelongsTo.php b/src/Database/Relations/BelongsTo.php index 7d0bdf14e..d4bb7a38c 100644 --- a/src/Database/Relations/BelongsTo.php +++ b/src/Database/Relations/BelongsTo.php @@ -13,6 +13,7 @@ class BelongsTo extends BelongsToBase implements Relation { use Concerns\BelongsOrMorphsTo; + use Concerns\CanBeCounted; use Concerns\CanBeExtended; use Concerns\CanBePushed; use Concerns\DeferOneOrMany; @@ -108,6 +109,7 @@ public function getArrayDefinition(): array 'key' => $this->getForeignKeyName(), 'otherKey' => $this->getOtherKey(), 'push' => $this->isPushable(), + 'count' => $this->isCountOnly(), ]; } } diff --git a/src/Database/Relations/BelongsToMany.php b/src/Database/Relations/BelongsToMany.php index c970296d6..35759e169 100644 --- a/src/Database/Relations/BelongsToMany.php +++ b/src/Database/Relations/BelongsToMany.php @@ -10,6 +10,7 @@ class BelongsToMany extends BelongsToManyBase implements Relation { use Concerns\BelongsOrMorphsToMany; + use Concerns\CanBeCounted; use Concerns\CanBeDetachable; use Concerns\CanBeExtended; use Concerns\CanBePushed; @@ -125,6 +126,7 @@ public function getArrayDefinition(): array 'otherKey' => $this->getRelatedKeyName(), 'push' => $this->isPushable(), 'detach' => $this->isDetachable(), + 'count' => $this->isCountOnly(), ]; if (count($this->pivotColumns)) { diff --git a/src/Database/Relations/Concerns/CanBeCounted.php b/src/Database/Relations/Concerns/CanBeCounted.php new file mode 100644 index 000000000..aa7805d56 --- /dev/null +++ b/src/Database/Relations/Concerns/CanBeCounted.php @@ -0,0 +1,68 @@ +countOnly()` to + * the relationship definition method. For example: + * + * ```php + * public function messages() + * { + * return $this->hasMany(Message::class)->countOnly(); + * } + * ``` + * + * If you are using the array-style definition, you can use the `count` key to mark the relationship as a counter only. + * + * ```php + * public $hasMany = [ + * 'messages' => [Message::class, 'count' => true] + * ]; + * ``` + * + * @author Ben Thomson + * @copyright Winter CMS Maintainers + */ +trait CanBeCounted +{ + /** + * Is this relation a counter? + */ + protected bool $countOnly = false; + + /** + * Mark the relationship as a count-only relationship. + */ + public function countOnly(): static + { + $this->countOnly = true; + + return $this; + } + + /** + * Mark the relationship as a full relationship. + */ + public function notCountOnly(): static + { + $this->countOnly = false; + + return $this; + } + + /** + * Determine if the relationship is only a counter. + */ + public function isCountOnly(): bool + { + return $this->countOnly; + } +} diff --git a/src/Database/Relations/Concerns/DefinedConstraints.php b/src/Database/Relations/Concerns/DefinedConstraints.php index 3df5fd83b..bcb2e163e 100644 --- a/src/Database/Relations/Concerns/DefinedConstraints.php +++ b/src/Database/Relations/Concerns/DefinedConstraints.php @@ -1,6 +1,8 @@ parent->getRelationDefinition($this->relationName); + if ($this instanceof HasOneThrough || $this instanceof HasManyThrough) { + $args = $this->farParent->getRelationDefinition($this->relationName); + } else { + $args = $this->parent->getRelationDefinition($this->relationName); + } $this->addDefinedConstraintsToRelation($this, $args); - $this->addDefinedConstraintsToQuery($this, $args); } @@ -63,12 +68,18 @@ public function addDefinedConstraintsToRelation($relation, ?array $args = null) $relation->countMode = true; } + if ($relation instanceof HasOneThrough || $relation instanceof HasManyThrough) { + $foreignKey = $relation->getQualifiedFirstKeyName(); + } else { + $foreignKey = $relation->getForeignKey(); + } + $countSql = $this->parent->getConnection()->raw('count(*) as count'); $relation - ->select($relation->getForeignKey(), $countSql) - ->groupBy($relation->getForeignKey()) - ->orderBy($relation->getForeignKey()) + ->select($foreignKey, $countSql) + ->groupBy($foreignKey) + ->orderBy($foreignKey) ; } } diff --git a/src/Database/Relations/HasMany.php b/src/Database/Relations/HasMany.php index 313f96240..db1c2f48e 100644 --- a/src/Database/Relations/HasMany.php +++ b/src/Database/Relations/HasMany.php @@ -14,6 +14,7 @@ class HasMany extends HasManyBase implements Relation { use Concerns\HasOneOrMany; + use Concerns\CanBeCounted; use Concerns\CanBeDependent; use Concerns\CanBeExtended; use Concerns\CanBePushed; @@ -101,6 +102,7 @@ public function getArrayDefinition(): array 'otherKey' => $this->getOtherKey(), 'delete' => $this->isDependent(), 'push' => $this->isPushable(), + 'count' => $this->isCountOnly(), ]; } } diff --git a/src/Database/Relations/HasManyThrough.php b/src/Database/Relations/HasManyThrough.php index 4574856f7..6ababbb7e 100644 --- a/src/Database/Relations/HasManyThrough.php +++ b/src/Database/Relations/HasManyThrough.php @@ -12,6 +12,7 @@ */ class HasManyThrough extends HasManyThroughBase { + use Concerns\CanBeCounted; use Concerns\CanBeExtended; use Concerns\CanBePushed; use Concerns\DefinedConstraints; @@ -52,6 +53,7 @@ public function getArrayDefinition(): array 'otherKey' => $this->getLocalKeyName(), 'secondOtherKey' => $this->getSecondLocalKeyName(), 'push' => $this->isPushable(), + 'count' => $this->isCountOnly(), ]; } } diff --git a/src/Database/Relations/HasOne.php b/src/Database/Relations/HasOne.php index f009381f2..047d90a57 100644 --- a/src/Database/Relations/HasOne.php +++ b/src/Database/Relations/HasOne.php @@ -12,6 +12,7 @@ class HasOne extends HasOneBase implements Relation { use Concerns\HasOneOrMany; + use Concerns\CanBeCounted; use Concerns\CanBeDependent; use Concerns\CanBeExtended; use Concerns\CanBePushed; @@ -102,6 +103,7 @@ public function getArrayDefinition(): array 'otherKey' => $this->getOtherKey(), 'delete' => $this->isDependent(), 'push' => $this->isPushable(), + 'count' => $this->isCountOnly(), ]; } } diff --git a/src/Database/Relations/HasOneThrough.php b/src/Database/Relations/HasOneThrough.php index 8d8390aa5..ab0e8d260 100644 --- a/src/Database/Relations/HasOneThrough.php +++ b/src/Database/Relations/HasOneThrough.php @@ -12,6 +12,7 @@ */ class HasOneThrough extends HasOneThroughBase { + use Concerns\CanBeCounted; use Concerns\CanBeExtended; use Concerns\CanBePushed; use Concerns\DefinedConstraints; @@ -52,6 +53,7 @@ public function getArrayDefinition(): array 'otherKey' => $this->getLocalKeyName(), 'secondOtherKey' => $this->getSecondLocalKeyName(), 'push' => $this->isPushable(), + 'count' => $this->isCountOnly(), ]; } } diff --git a/src/Database/Relations/MorphMany.php b/src/Database/Relations/MorphMany.php index 82be6606f..db6373e3d 100644 --- a/src/Database/Relations/MorphMany.php +++ b/src/Database/Relations/MorphMany.php @@ -14,6 +14,7 @@ class MorphMany extends MorphManyBase implements Relation { use Concerns\MorphOneOrMany; + use Concerns\CanBeCounted; use Concerns\CanBeDependent; use Concerns\CanBeExtended; use Concerns\CanBePushed; @@ -117,6 +118,7 @@ public function getArrayDefinition(): array 'id' => $this->getForeignKeyName(), 'delete' => $this->isDependent(), 'push' => $this->isPushable(), + 'count' => $this->isCountOnly(), ]; } } diff --git a/src/Database/Relations/MorphOne.php b/src/Database/Relations/MorphOne.php index c913e37de..4d6b766b0 100644 --- a/src/Database/Relations/MorphOne.php +++ b/src/Database/Relations/MorphOne.php @@ -12,6 +12,7 @@ class MorphOne extends MorphOneBase implements Relation { use Concerns\MorphOneOrMany; + use Concerns\CanBeCounted; use Concerns\CanBeDependent; use Concerns\CanBeExtended; use Concerns\CanBePushed; @@ -120,6 +121,7 @@ public function getArrayDefinition(): array 'id' => $this->getForeignKeyName(), 'delete' => $this->isDependent(), 'push' => $this->isPushable(), + 'count' => $this->isCountOnly(), ]; } } diff --git a/src/Database/Relations/MorphTo.php b/src/Database/Relations/MorphTo.php index 456c10aeb..2ddc5b72c 100644 --- a/src/Database/Relations/MorphTo.php +++ b/src/Database/Relations/MorphTo.php @@ -12,6 +12,7 @@ class MorphTo extends MorphToBase implements Relation { use Concerns\BelongsOrMorphsTo; + use Concerns\CanBeCounted; use Concerns\CanBeExtended; use Concerns\CanBePushed; use Concerns\DeferOneOrMany; @@ -84,6 +85,7 @@ public function getArrayDefinition(): array 'key' => $this->getForeignKeyName(), 'otherKey' => $this->getOwnerKeyName(), 'push' => $this->isPushable(), + 'count' => $this->isCountOnly(), ]; } } diff --git a/src/Database/Relations/MorphToMany.php b/src/Database/Relations/MorphToMany.php index ebe8bfd14..65de77982 100644 --- a/src/Database/Relations/MorphToMany.php +++ b/src/Database/Relations/MorphToMany.php @@ -20,6 +20,7 @@ class MorphToMany extends BaseMorphToMany implements Relation { use Concerns\BelongsOrMorphsToMany; + use Concerns\CanBeCounted; use Concerns\CanBeDetachable; use Concerns\CanBeExtended; use Concerns\CanBePushed; @@ -173,6 +174,7 @@ public function getArrayDefinition(): array 'inverse' => $this->getInverse(), 'push' => $this->isPushable(), 'detach' => $this->isDetachable(), + 'count' => $this->isCountOnly(), ]; if (count($this->pivotColumns)) { diff --git a/tests/Database/Concerns/HasRelationshipsTest.php b/tests/Database/Concerns/HasRelationshipsTest.php index a7d91d994..9fb9b4c56 100644 --- a/tests/Database/Concerns/HasRelationshipsTest.php +++ b/tests/Database/Concerns/HasRelationshipsTest.php @@ -146,6 +146,7 @@ public function testGetRelationDefinition() 'otherKey' => 'id', 'delete' => false, 'push' => true, + 'count' => false, ], $author->getRelationDefinition('contactNumber')); $this->assertEquals([ Post::class, @@ -153,6 +154,7 @@ public function testGetRelationDefinition() 'otherKey' => 'id', 'delete' => false, 'push' => true, + 'count' => false, ], $author->getRelationDefinition('messages')); $this->assertEquals([ Role::class, @@ -161,6 +163,7 @@ public function testGetRelationDefinition() 'otherKey' => 'id', 'push' => true, 'detach' => true, + 'count' => false, ], $author->getRelationDefinition('scopes')); $this->assertEquals([ Meta::class, @@ -168,6 +171,7 @@ public function testGetRelationDefinition() 'id' => 'taggable_id', 'delete' => false, 'push' => true, + 'count' => false, ], $author->getRelationDefinition('info')); $this->assertEquals([ EventLog::class, @@ -175,6 +179,7 @@ public function testGetRelationDefinition() 'id' => 'related_id', 'delete' => true, 'push' => true, + 'count' => false, ], $author->getRelationDefinition('auditLogs')); $this->assertEquals([ Tag::class, @@ -185,6 +190,7 @@ public function testGetRelationDefinition() 'relatedKey' => 'id', 'inverse' => false, 'push' => true, + 'count' => false, 'pivot' => ['added_by'], 'detach' => true, ], $author->getRelationDefinition('labels')); diff --git a/tests/Database/Fixtures/Country.php b/tests/Database/Fixtures/Country.php index 56ee69405..3eb478e43 100644 --- a/tests/Database/Fixtures/Country.php +++ b/tests/Database/Fixtures/Country.php @@ -3,6 +3,7 @@ namespace Winter\Storm\Tests\Database\Fixtures; use Illuminate\Database\Schema\Builder; +use Winter\Storm\Database\Attributes\Relation; use Winter\Storm\Database\Model; use Winter\Storm\Database\Relations\HasManyThrough; @@ -30,6 +31,11 @@ class Country extends Model 'posts' => [ Post::class, 'through' => Author::class, + ], + 'posts_count' => [ + Post::class, + 'through' => Author::class, + 'count' => true, ] ]; @@ -38,6 +44,11 @@ public function messages(): HasManyThrough return $this->hasManyThrough(Post::class, Author::class); } + public function messagesCount(): HasManyThrough + { + return $this->hasManyThrough(Post::class, Author::class)->countOnly(); + } + public static function migrateUp(Builder $builder): void { if ($builder->hasTable('database_tester_countries')) { diff --git a/tests/Database/Relations/HasManyThroughTest.php b/tests/Database/Relations/HasManyThroughTest.php index a73879b9a..6256bc84d 100644 --- a/tests/Database/Relations/HasManyThroughTest.php +++ b/tests/Database/Relations/HasManyThroughTest.php @@ -78,4 +78,66 @@ public function testGetLaravelRelation() $post4->id ], $country->messages->pluck('id')->toArray()); } + + public function testCount() + { + Model::unguard(); + $country1 = Country::create(['name' => 'Australia']); + $country2 = Country::create(['name' => 'Canada']); + $author1 = Author::create(['name' => 'Stevie', 'email' => 'stevie@email.tld']); + $author2 = Author::create(['name' => 'Louie', 'email' => 'louie@email.tld']); + $post1 = Post::create(['title' => "First post", 'description' => "Yay!!"]); + $post2 = Post::create(['title' => "Second post", 'description' => "Woohoo!!"]); + $post3 = Post::create(['title' => "Third post", 'description' => "Yipiee!!"]); + Model::reguard(); + + // Set data + $author1->country = $country1; + $author2->country = $country2; + + $author1->posts = new Collection([$post1, $post2]); + $author2->posts = new Collection([$post3]); + + $author1->save(); + $author2->save(); + + $country1 = Country::with([ + 'posts_count' + ])->find($country1->id); + $country2 = Country::with([ + 'posts_count' + ])->find($country2->id); + + $this->assertEquals(2, $country1->posts_count->first()->count); + $this->assertEquals(1, $country2->posts_count()->first()->count); + } + + public function testCountLaravelRelation() + { + Model::unguard(); + $country1 = Country::create(['name' => 'Australia']); + $country2 = Country::create(['name' => 'Canada']); + $author1 = Author::create(['name' => 'Stevie', 'email' => 'stevie@email.tld']); + $author2 = Author::create(['name' => 'Louie', 'email' => 'louie@email.tld']); + $post1 = Post::create(['title' => "First post", 'description' => "Yay!!"]); + $post2 = Post::create(['title' => "Second post", 'description' => "Woohoo!!"]); + $post3 = Post::create(['title' => "Third post", 'description' => "Yipiee!!"]); + Model::reguard(); + + // Set data + $author1->country = $country1; + $author2->country = $country2; + + $author1->messages = new Collection([$post1, $post2]); + $author2->messages = new Collection([$post3]); + + $author1->save(); + $author2->save(); + + $country1 = Country::find($country1->id); + $country2 = Country::find($country2->id); + + $this->assertEquals(2, $country1->messagesCount->first()->count); + $this->assertEquals(1, $country2->messagesCount()->first()->count); + } } From 8337ae76cbabfb84cf409164e78a39181195f19f Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Mon, 29 Jul 2024 11:42:34 +0800 Subject: [PATCH 28/59] Fix database test cases not always running migrations --- phpunit.xml.dist | 4 ++++ tests/DbTestCase.php | 22 ++++++++-------------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index c33e73c08..552bbd2a7 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -21,4 +21,8 @@ ./tests + + + + diff --git a/tests/DbTestCase.php b/tests/DbTestCase.php index 64c148b88..1bd4b5fca 100644 --- a/tests/DbTestCase.php +++ b/tests/DbTestCase.php @@ -4,11 +4,8 @@ use ReflectionClass; use Illuminate\Database\Schema\Builder; -use Illuminate\Support\Facades\App; -use Illuminate\Support\Facades\Artisan; +use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; -use Symfony\Component\Console\Output\BufferedOutput; -use Winter\Storm\Database\Connectors\ConnectionFactory; use Winter\Storm\Database\Model; use Winter\Storm\Database\Pivot; use Winter\Storm\Events\Dispatcher; @@ -24,6 +21,8 @@ */ class DbTestCase extends TestCase { + use RefreshDatabase; + /** * @var string[] Stores models that have been automatically migrated. */ @@ -32,15 +31,6 @@ class DbTestCase extends TestCase public function setUp(): void { parent::setUp(); - - $config = [ - 'driver' => 'sqlite', - 'database' => ':memory:', - ]; - App::make(ConnectionFactory::class)->make($config, 'testing'); - DB::setDefaultConnection('testing'); - Artisan::call('migrate', ['--database' => 'testing', '--path' => '../../../../src/Database/Migrations']); - Model::setEventDispatcher($this->modelDispatcher()); } @@ -48,7 +38,6 @@ public function tearDown(): void { $this->flushModelEventListeners(); $this->rollbackModels(); - Artisan::call('migrate:rollback', ['--database' => 'testing', '--path' => '../../../../src/Database/Migrations']); parent::tearDown(); } @@ -135,4 +124,9 @@ protected function flushModelEventListeners() Model::flushEventListeners(); } + + protected function defineDatabaseMigrations() + { + $this->loadMigrationsFrom(dirname(__DIR__) . '/src/Database/Migrations'); + } } From e0e4676bac41e290731ac987e3224c84d7acdff9 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Mon, 29 Jul 2024 11:45:30 +0800 Subject: [PATCH 29/59] Fix count-only relations. When using the Laravel style relation method and countOnly flag, it was not applying the constraint automatically. --- src/Database/Concerns/HasRelationships.php | 5 +- .../Relations/Concerns/CanBeCounted.php | 40 +++++++++++++ .../Relations/Concerns/DefinedConstraints.php | 58 ++++++++++--------- .../Database/Relations/HasManyThroughTest.php | 8 +-- 4 files changed, 76 insertions(+), 35 deletions(-) diff --git a/src/Database/Concerns/HasRelationships.php b/src/Database/Concerns/HasRelationships.php index 4d571ad48..f7a535ed3 100644 --- a/src/Database/Concerns/HasRelationships.php +++ b/src/Database/Concerns/HasRelationships.php @@ -550,13 +550,16 @@ class_uses_recursive($relation) class_uses_recursive($relation) ) && (($definition['count'] ?? false) === true) + && $addConstraints ) { $relation = $relation->countOnly(); } + $relation->addDefinedConstraintsToRelation(); + if ($addConstraints) { // Add defined constraints - $relation->addDefinedConstraints(); + $relation->addDefinedConstraintsToQuery(); } return $relation; diff --git a/src/Database/Relations/Concerns/CanBeCounted.php b/src/Database/Relations/Concerns/CanBeCounted.php index aa7805d56..8d6cd9cc7 100644 --- a/src/Database/Relations/Concerns/CanBeCounted.php +++ b/src/Database/Relations/Concerns/CanBeCounted.php @@ -2,6 +2,11 @@ namespace Winter\Storm\Database\Relations\Concerns; +use Winter\Storm\Database\Relations\BelongsToMany; +use Winter\Storm\Database\Relations\HasManyThrough; +use Winter\Storm\Database\Relations\HasOneThrough; +use Winter\Storm\Database\Relations\Relation; + /** * This trait is used to mark certain relationships as being a counter only. * @@ -45,6 +50,23 @@ public function countOnly(): static { $this->countOnly = true; + if ($this instanceof BelongsToMany) { + $this->countMode = true; + } + + $foreignKey = ($this instanceof HasOneThrough || $this instanceof HasManyThrough) + ? $this->getQualifiedFirstKeyName() + : $this->getForeignKey(); + $parent = ($this instanceof HasOneThrough || $this instanceof HasManyThrough) + ? $this->farParent + : $this->parent; + + $countSql = $this->parent->getConnection()->raw('count(*) as count'); + $this + ->select($foreignKey, $countSql) + ->groupBy($foreignKey) + ->orderBy($foreignKey); + return $this; } @@ -65,4 +87,22 @@ public function isCountOnly(): bool { return $this->countOnly; } + + public function applyCountQueryToRelation(Relation $relation) + { + if ($relation instanceof BelongsToMany) { + $relation->countMode = true; + } + + $foreignKey = ($relation instanceof HasOneThrough || $relation instanceof HasManyThrough) + ? $relation->getQualifiedFirstKeyName() + : $relation->getForeignKey(); + + $countSql = $this->parent->getConnection()->raw('count(*) as count'); + $relation + ->select($foreignKey, $countSql) + ->groupBy($foreignKey) + ->orderBy($foreignKey) + ; + } } diff --git a/src/Database/Relations/Concerns/DefinedConstraints.php b/src/Database/Relations/Concerns/DefinedConstraints.php index 43f82a57d..3b6ae2445 100644 --- a/src/Database/Relations/Concerns/DefinedConstraints.php +++ b/src/Database/Relations/Concerns/DefinedConstraints.php @@ -1,6 +1,7 @@ -parent->getRelationDefinition($this->relationName); + if (is_null($relation)) { + $relation = $this; + } + if (is_null($args)) { + $args = $this->getRelationArgs(); } /* @@ -57,26 +64,6 @@ public function addDefinedConstraintsToRelation($relation, ?array $args = null) if (array_get($args, 'timestamps')) { $relation->withTimestamps(); } - - /* - * Count "helper" relation - */ - if ($count = array_get($args, 'count')) { - if ($relation instanceof BelongsToManyBase) { - $relation->countMode = true; - } - - $foreignKey = ($relation instanceof HasOneThrough || $relation instanceof HasManyThrough) - ? $relation->getQualifiedFirstKeyName() - : $relation->getForeignKey(); - - $countSql = $this->parent->getConnection()->raw('count(*) as count'); - $relation - ->select($foreignKey, $countSql) - ->groupBy($foreignKey) - ->orderBy($foreignKey) - ; - } } /** @@ -85,10 +72,13 @@ public function addDefinedConstraintsToRelation($relation, ?array $args = null) * @param \Illuminate\Database\Eloquent\Relations\Relation|\Winter\Storm\Database\QueryBuilder $query * @param array|null $args */ - public function addDefinedConstraintsToQuery($query, ?array $args = null) + public function addDefinedConstraintsToQuery($query = null, ?array $args = null) { - if ($args === null) { - $args = $this->parent->getRelationDefinition($this->relationName); + if (is_null($query)) { + $query = $this; + } + if (is_null($args)) { + $args = $this->getRelationArgs(); } /* @@ -127,4 +117,16 @@ public function addDefinedConstraintsToQuery($query, ?array $args = null) $query->$scope($this->parent); } } + + /** + * Get the relation definition for the related model. + * + * @return array + */ + protected function getRelationArgs(): array + { + return ($this instanceof HasOneThrough || $this instanceof HasManyThrough) + ? $this->farParent->getRelationDefinition($this->relationName) + : $this->parent->getRelationDefinition($this->relationName); + } } diff --git a/tests/Database/Relations/HasManyThroughTest.php b/tests/Database/Relations/HasManyThroughTest.php index 6256bc84d..ffaa5bf9a 100644 --- a/tests/Database/Relations/HasManyThroughTest.php +++ b/tests/Database/Relations/HasManyThroughTest.php @@ -101,12 +101,8 @@ public function testCount() $author1->save(); $author2->save(); - $country1 = Country::with([ - 'posts_count' - ])->find($country1->id); - $country2 = Country::with([ - 'posts_count' - ])->find($country2->id); + $country1 = Country::find($country1->id); + $country2 = Country::find($country2->id); $this->assertEquals(2, $country1->posts_count->first()->count); $this->assertEquals(1, $country2->posts_count()->first()->count); From 1137eb55be68fe934f2260736cbce20983684f92 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Mon, 29 Jul 2024 12:04:00 +0800 Subject: [PATCH 30/59] Fix definition --- phpstan-baseline.neon | 303 ++++++++++-------- .../Relations/Concerns/CanBeCounted.php | 11 +- 2 files changed, 172 insertions(+), 142 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index ec103c649..cac473cad 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -136,72 +136,62 @@ parameters: path: src/Database/QueryBuilder.php - - message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\:\\:\\$countMode\\.$#" - count: 1 - path: src/Database/Relations/AttachMany.php - - - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" count: 1 path: src/Database/Relations/AttachMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withPivot\\(\\)\\.$#" count: 1 path: src/Database/Relations/AttachMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withTimestamps\\(\\)\\.$#" count: 1 path: src/Database/Relations/AttachMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withPivot\\(\\)\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\AttachMany\\:\\:getRelationExistenceQueryForSelfJoin\\(\\)\\.$#" count: 1 path: src/Database/Relations/AttachMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withTimestamps\\(\\)\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" count: 1 path: src/Database/Relations/AttachMany.php - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\AttachMany\\:\\:getRelationExistenceQueryForSelfJoin\\(\\)\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" count: 1 path: src/Database/Relations/AttachMany.php - - message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\:\\:\\$countMode\\.$#" - count: 1 - path: src/Database/Relations/AttachOne.php - - - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" count: 1 path: src/Database/Relations/AttachOne.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withPivot\\(\\)\\.$#" count: 1 path: src/Database/Relations/AttachOne.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withTimestamps\\(\\)\\.$#" count: 1 path: src/Database/Relations/AttachOne.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withPivot\\(\\)\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\AttachOne\\:\\:getRelationExistenceQueryForSelfJoin\\(\\)\\.$#" count: 1 path: src/Database/Relations/AttachOne.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withTimestamps\\(\\)\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" count: 1 path: src/Database/Relations/AttachOne.php - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\AttachOne\\:\\:getRelationExistenceQueryForSelfJoin\\(\\)\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" count: 1 path: src/Database/Relations/AttachOne.php @@ -211,37 +201,37 @@ parameters: path: src/Database/Relations/AttachOne.php - - message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\:\\:\\$countMode\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:bindEventOnce\\(\\)\\.$#" count: 1 path: src/Database/Relations/BelongsTo.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:bindEventOnce\\(\\)\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" count: 1 path: src/Database/Relations/BelongsTo.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withPivot\\(\\)\\.$#" count: 1 path: src/Database/Relations/BelongsTo.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withTimestamps\\(\\)\\.$#" count: 1 path: src/Database/Relations/BelongsTo.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\BelongsTo\\:\\:getForeignKey\\(\\)\\.$#" count: 1 path: src/Database/Relations/BelongsTo.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withPivot\\(\\)\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" count: 1 path: src/Database/Relations/BelongsTo.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withTimestamps\\(\\)\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" count: 1 path: src/Database/Relations/BelongsTo.php @@ -250,11 +240,6 @@ parameters: count: 1 path: src/Database/Relations/BelongsToMany.php - - - message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\:\\:\\$countMode\\.$#" - count: 1 - path: src/Database/Relations/BelongsToMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:bindDeferred\\(\\)\\.$#" count: 1 @@ -291,85 +276,99 @@ parameters: path: src/Database/Relations/BelongsToMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" count: 1 path: src/Database/Relations/BelongsToMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withPivot\\(\\)\\.$#" count: 1 path: src/Database/Relations/BelongsToMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withTimestamps\\(\\)\\.$#" count: 1 path: src/Database/Relations/BelongsToMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withPivot\\(\\)\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Query\\\\Builder\\:\\:lists\\(\\)\\.$#" count: 1 path: src/Database/Relations/BelongsToMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withTimestamps\\(\\)\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\BelongsToMany\\:\\:flushDuplicateCache\\(\\)\\.$#" count: 1 path: src/Database/Relations/BelongsToMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Query\\\\Builder\\:\\:lists\\(\\)\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" count: 1 path: src/Database/Relations/BelongsToMany.php - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\BelongsToMany\\:\\:flushDuplicateCache\\(\\)\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" count: 1 path: src/Database/Relations/BelongsToMany.php - - message: "#^Parameter \\#2 \\$columns of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\::(simpleP|p)aginate\\(\\) expects array, int\\|null given\\.$#" - count: 2 + message: "#^Parameter \\#2 \\$columns of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\\\:\\:paginate\\(\\) expects array, int\\|null given\\.$#" + count: 1 path: src/Database/Relations/BelongsToMany.php - - message: "#^Parameter \\#2 \\$currentPage \\(int(\\|null)?\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\BelongsToMany::(simpleP|p)aginate\\(\\) should be compatible with parameter \\$columns \\(array\\\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\::(simpleP|p)aginate\\(\\)$#" - count: 2 + message: "#^Parameter \\#2 \\$columns of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\\\:\\:simplePaginate\\(\\) expects array, int\\|null given\\.$#" + count: 1 path: src/Database/Relations/BelongsToMany.php - - message: "#^Parameter \\#3 \\$columns \\(array\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\BelongsToMany::(simpleP|p)aginate\\(\\) should be compatible with parameter \\$pageName \\(string\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\::(simpleP|p)aginate\\(\\)$#" - count: 2 + message: "#^Parameter \\#2 \\$currentPage \\(int\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\BelongsToMany\\:\\:paginate\\(\\) should be compatible with parameter \\$columns \\(array\\\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\\\:\\:paginate\\(\\)$#" + count: 1 path: src/Database/Relations/BelongsToMany.php - - message: "#^Parameter \\#3 \\$pageName of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\::(simpleP|p)aginate\\(\\) expects string, array given\\.$#" - count: 2 + message: "#^Parameter \\#2 \\$currentPage \\(int\\|null\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\BelongsToMany\\:\\:simplePaginate\\(\\) should be compatible with parameter \\$columns \\(array\\\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\\\:\\:simplePaginate\\(\\)$#" + count: 1 path: src/Database/Relations/BelongsToMany.php - - message: "#^Parameter \\#4 \\$pageName \\(string\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\BelongsToMany::(simpleP|p)aginate\\(\\) should be compatible with parameter \\$page \\(int\\|null\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\::(simpleP|p)aginate\\(\\)$#" - count: 2 + message: "#^Parameter \\#3 \\$columns \\(array\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\BelongsToMany\\:\\:paginate\\(\\) should be compatible with parameter \\$pageName \\(string\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\\\:\\:paginate\\(\\)$#" + count: 1 path: src/Database/Relations/BelongsToMany.php - - message: "#^Parameter \\#4 \\$page of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\::(simpleP|p)aginate\\(\\) expects int\\|null, string given.$#" - paths: - - src/Database/Relations/BelongsToMany.php - - src/Database/Relations/MorphToMany.php + message: "#^Parameter \\#3 \\$columns \\(array\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\BelongsToMany\\:\\:simplePaginate\\(\\) should be compatible with parameter \\$pageName \\(string\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\\\:\\:simplePaginate\\(\\)$#" + count: 1 + path: src/Database/Relations/BelongsToMany.php - - message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\:\\:\\$countMode\\.$#" + message: "#^Parameter \\#3 \\$pageName of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\\\:\\:paginate\\(\\) expects string, array given\\.$#" count: 1 - path: src/Database/Relations/HasMany.php + path: src/Database/Relations/BelongsToMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" + message: "#^Parameter \\#3 \\$pageName of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\\\:\\:simplePaginate\\(\\) expects string, array given\\.$#" count: 1 - path: src/Database/Relations/HasMany.php + path: src/Database/Relations/BelongsToMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" + message: "#^Parameter \\#4 \\$page of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\\\:\\:paginate\\(\\) expects int\\|null, string given\\.$#" count: 1 - path: src/Database/Relations/HasMany.php + path: src/Database/Relations/BelongsToMany.php + + - + message: "#^Parameter \\#4 \\$page of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\\\:\\:simplePaginate\\(\\) expects int\\|null, string given\\.$#" + count: 1 + path: src/Database/Relations/BelongsToMany.php + + - + message: "#^Parameter \\#4 \\$pageName \\(string\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\BelongsToMany\\:\\:paginate\\(\\) should be compatible with parameter \\$page \\(int\\|null\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\\\:\\:paginate\\(\\)$#" + count: 1 + path: src/Database/Relations/BelongsToMany.php + + - + message: "#^Parameter \\#4 \\$pageName \\(string\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\BelongsToMany\\:\\:simplePaginate\\(\\) should be compatible with parameter \\$page \\(int\\|null\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\\\:\\:simplePaginate\\(\\)$#" + count: 1 + path: src/Database/Relations/BelongsToMany.php - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" @@ -387,34 +386,29 @@ parameters: path: src/Database/Relations/HasMany.php - - message: "#^Call to private method update\\(\\) of parent class Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\HasMany\\\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" count: 1 path: src/Database/Relations/HasMany.php - - message: "#^Call to private method whereNotIn\\(\\) of parent class Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\HasMany\\\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" count: 1 path: src/Database/Relations/HasMany.php - - message: "#^If condition is always true\\.$#" + message: "#^Call to private method update\\(\\) of parent class Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\HasMany\\\\.$#" count: 1 path: src/Database/Relations/HasMany.php - - message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\:\\:\\$countMode\\.$#" - count: 1 - path: src/Database/Relations/HasManyThrough.php - - - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" + message: "#^Call to private method whereNotIn\\(\\) of parent class Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\HasMany\\\\.$#" count: 1 - path: src/Database/Relations/HasManyThrough.php + path: src/Database/Relations/HasMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" + message: "#^If condition is always true\\.$#" count: 1 - path: src/Database/Relations/HasManyThrough.php + path: src/Database/Relations/HasMany.php - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" @@ -432,37 +426,37 @@ parameters: path: src/Database/Relations/HasManyThrough.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:getQualifiedFirstKeyName\\(\\)\\.$#" - count: 12 - path: src/Database/Relations/Concerns/DefinedConstraints.php + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" + count: 1 + path: src/Database/Relations/HasManyThrough.php - - message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\:\\:\\$countMode\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" count: 1 - path: src/Database/Relations/HasOne.php + path: src/Database/Relations/HasManyThrough.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" count: 1 path: src/Database/Relations/HasOne.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withPivot\\(\\)\\.$#" count: 1 path: src/Database/Relations/HasOne.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withTimestamps\\(\\)\\.$#" count: 1 path: src/Database/Relations/HasOne.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withPivot\\(\\)\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" count: 1 path: src/Database/Relations/HasOne.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withTimestamps\\(\\)\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" count: 1 path: src/Database/Relations/HasOne.php @@ -472,62 +466,62 @@ parameters: path: src/Database/Relations/HasOne.php - - message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\:\\:\\$countMode\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" count: 1 path: src/Database/Relations/HasOneThrough.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withPivot\\(\\)\\.$#" count: 1 path: src/Database/Relations/HasOneThrough.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withTimestamps\\(\\)\\.$#" count: 1 path: src/Database/Relations/HasOneThrough.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" count: 1 path: src/Database/Relations/HasOneThrough.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withPivot\\(\\)\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" count: 1 path: src/Database/Relations/HasOneThrough.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withTimestamps\\(\\)\\.$#" - count: 1 + message: "#^Instanceof between \\*NEVER\\* and Winter\\\\Storm\\\\Database\\\\Relations\\\\HasManyThrough will always evaluate to false\\.$#" + count: 4 path: src/Database/Relations/HasOneThrough.php - - message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\:\\:\\$countMode\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" count: 1 path: src/Database/Relations/MorphMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withPivot\\(\\)\\.$#" count: 1 path: src/Database/Relations/MorphMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withTimestamps\\(\\)\\.$#" count: 1 path: src/Database/Relations/MorphMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphMany\\:\\:getForeignKey\\(\\)\\.$#" count: 1 path: src/Database/Relations/MorphMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withPivot\\(\\)\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" count: 1 path: src/Database/Relations/MorphMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withTimestamps\\(\\)\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" count: 1 path: src/Database/Relations/MorphMany.php @@ -547,32 +541,32 @@ parameters: path: src/Database/Relations/MorphMany.php - - message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\:\\:\\$countMode\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" count: 1 path: src/Database/Relations/MorphOne.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withPivot\\(\\)\\.$#" count: 1 path: src/Database/Relations/MorphOne.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withTimestamps\\(\\)\\.$#" count: 1 path: src/Database/Relations/MorphOne.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphOne\\:\\:getForeignKey\\(\\)\\.$#" count: 1 path: src/Database/Relations/MorphOne.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withPivot\\(\\)\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" count: 1 path: src/Database/Relations/MorphOne.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withTimestamps\\(\\)\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" count: 1 path: src/Database/Relations/MorphOne.php @@ -582,103 +576,133 @@ parameters: path: src/Database/Relations/MorphOne.php - - message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\:\\:\\$countMode\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:bindEventOnce\\(\\)\\.$#" count: 1 path: src/Database/Relations/MorphTo.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:bindEventOnce\\(\\)\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" count: 1 path: src/Database/Relations/MorphTo.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withPivot\\(\\)\\.$#" count: 1 path: src/Database/Relations/MorphTo.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withTimestamps\\(\\)\\.$#" count: 1 path: src/Database/Relations/MorphTo.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphTo\\:\\:getForeignKey\\(\\)\\.$#" count: 1 path: src/Database/Relations/MorphTo.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withPivot\\(\\)\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" count: 1 path: src/Database/Relations/MorphTo.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withTimestamps\\(\\)\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" count: 1 path: src/Database/Relations/MorphTo.php - - message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\:\\:\\$countMode\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" count: 1 path: src/Database/Relations/MorphToMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withPivot\\(\\)\\.$#" count: 1 path: src/Database/Relations/MorphToMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withTimestamps\\(\\)\\.$#" count: 1 path: src/Database/Relations/MorphToMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Query\\\\Builder\\:\\:lists\\(\\)\\.$#" count: 1 path: src/Database/Relations/MorphToMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withPivot\\(\\)\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphToMany\\:\\:flushDuplicateCache\\(\\)\\.$#" count: 1 path: src/Database/Relations/MorphToMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withTimestamps\\(\\)\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" count: 1 path: src/Database/Relations/MorphToMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Query\\\\Builder\\:\\:lists\\(\\)\\.$#" + message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" count: 1 path: src/Database/Relations/MorphToMany.php - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphToMany\\:\\:flushDuplicateCache\\(\\)\\.$#" + message: "#^Parameter \\#2 \\$columns of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\\\:\\:paginate\\(\\) expects array, int\\|null given\\.$#" count: 1 path: src/Database/Relations/MorphToMany.php - - message: "#^Parameter \\#2 \\$columns of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\::(simpleP|p)aginate\\(\\) expects array, int\\|null given\\.$#" - count: 2 + message: "#^Parameter \\#2 \\$columns of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\\\:\\:simplePaginate\\(\\) expects array, int\\|null given\\.$#" + count: 1 path: src/Database/Relations/MorphToMany.php - - message: "#^Parameter \\#2 \\$currentPage \\(int(\\|null)?\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphToMany::(simpleP|p)aginate\\(\\) should be compatible with parameter \\$columns \\(array\\\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\::(simpleP|p)aginate\\(\\)$#" - count: 2 + message: "#^Parameter \\#2 \\$currentPage \\(int\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphToMany\\:\\:paginate\\(\\) should be compatible with parameter \\$columns \\(array\\\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\\\:\\:paginate\\(\\)$#" + count: 1 path: src/Database/Relations/MorphToMany.php - - message: "#^Parameter \\#3 \\$columns \\(array\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphToMany::(simpleP|p)aginate\\(\\) should be compatible with parameter \\$pageName \\(string\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\::(simpleP|p)aginate\\(\\)$#" - count: 2 + message: "#^Parameter \\#2 \\$currentPage \\(int\\|null\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphToMany\\:\\:simplePaginate\\(\\) should be compatible with parameter \\$columns \\(array\\\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\\\:\\:simplePaginate\\(\\)$#" + count: 1 path: src/Database/Relations/MorphToMany.php - - message: "#^Parameter \\#3 \\$pageName of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\::(simpleP|p)aginate\\(\\) expects string, array given\\.$#" - count: 2 + message: "#^Parameter \\#3 \\$columns \\(array\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphToMany\\:\\:paginate\\(\\) should be compatible with parameter \\$pageName \\(string\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\\\:\\:paginate\\(\\)$#" + count: 1 path: src/Database/Relations/MorphToMany.php - - message: "#^Parameter \\#4 \\$pageName \\(string\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphToMany::(simpleP|p)aginate\\(\\) should be compatible with parameter \\$page \\(int\\|null\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\::(simpleP|p)aginate\\(\\)$#" - count: 2 + message: "#^Parameter \\#3 \\$columns \\(array\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphToMany\\:\\:simplePaginate\\(\\) should be compatible with parameter \\$pageName \\(string\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\\\:\\:simplePaginate\\(\\)$#" + count: 1 + path: src/Database/Relations/MorphToMany.php + + - + message: "#^Parameter \\#3 \\$pageName of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\\\:\\:paginate\\(\\) expects string, array given\\.$#" + count: 1 + path: src/Database/Relations/MorphToMany.php + + - + message: "#^Parameter \\#3 \\$pageName of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\\\:\\:simplePaginate\\(\\) expects string, array given\\.$#" + count: 1 + path: src/Database/Relations/MorphToMany.php + + - + message: "#^Parameter \\#4 \\$page of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\\\:\\:paginate\\(\\) expects int\\|null, string given\\.$#" + count: 1 + path: src/Database/Relations/MorphToMany.php + + - + message: "#^Parameter \\#4 \\$page of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\\\:\\:simplePaginate\\(\\) expects int\\|null, string given\\.$#" + count: 1 + path: src/Database/Relations/MorphToMany.php + + - + message: "#^Parameter \\#4 \\$pageName \\(string\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphToMany\\:\\:paginate\\(\\) should be compatible with parameter \\$page \\(int\\|null\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\\\:\\:paginate\\(\\)$#" + count: 1 + path: src/Database/Relations/MorphToMany.php + + - + message: "#^Parameter \\#4 \\$pageName \\(string\\) of method Winter\\\\Storm\\\\Database\\\\Relations\\\\MorphToMany\\:\\:simplePaginate\\(\\) should be compatible with parameter \\$page \\(int\\|null\\) of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\\\:\\:simplePaginate\\(\\)$#" + count: 1 path: src/Database/Relations/MorphToMany.php - @@ -696,6 +720,21 @@ parameters: count: 1 path: src/Database/TreeCollection.php + - + message: "#^Call to an undefined method Illuminate\\\\Routing\\\\Router\\:\\:after\\(\\)\\.$#" + count: 1 + path: src/Foundation/Application.php + + - + message: "#^Call to an undefined method Illuminate\\\\Routing\\\\Router\\:\\:before\\(\\)\\.$#" + count: 1 + path: src/Foundation/Application.php + + - + message: "#^Call to an undefined method NunoMaduro\\\\Collision\\\\Adapters\\\\Laravel\\\\ExceptionHandler\\:\\:error\\(\\)\\.$#" + count: 1 + path: src/Foundation/Application.php + - message: "#^Parameter \\#2 \\$data \\(array\\) of method Winter\\\\Storm\\\\Mail\\\\Mailer\\:\\:queue\\(\\) should be compatible with parameter \\$queue \\(string\\|null\\) of method Illuminate\\\\Contracts\\\\Mail\\\\MailQueue\\:\\:queue\\(\\)$#" count: 1 @@ -745,13 +784,3 @@ parameters: message: "#^Call to function is_null\\(\\) with Closure will always evaluate to false\\.$#" count: 1 path: src/Validation/Factory.php - - - - message: "#^Call to an undefined method Illuminate\\\\Routing\\\\Router\\:\\:before\\(\\)\\.$#" - count: 1 - path: src/Foundation/Application.php - - - - message: "#^Call to an undefined method Illuminate\\\\Routing\\\\Router\\:\\:after\\(\\)\\.$#" - count: 1 - path: src/Foundation/Application.php diff --git a/src/Database/Relations/Concerns/CanBeCounted.php b/src/Database/Relations/Concerns/CanBeCounted.php index 8d6cd9cc7..f82a3c8b3 100644 --- a/src/Database/Relations/Concerns/CanBeCounted.php +++ b/src/Database/Relations/Concerns/CanBeCounted.php @@ -2,6 +2,7 @@ namespace Winter\Storm\Database\Relations\Concerns; +use Illuminate\Database\Query\Builder; use Winter\Storm\Database\Relations\BelongsToMany; use Winter\Storm\Database\Relations\HasManyThrough; use Winter\Storm\Database\Relations\HasOneThrough; @@ -46,7 +47,7 @@ trait CanBeCounted /** * Mark the relationship as a count-only relationship. */ - public function countOnly(): static + public function countOnly(): Builder { $this->countOnly = true; @@ -61,13 +62,13 @@ public function countOnly(): static ? $this->farParent : $this->parent; - $countSql = $this->parent->getConnection()->raw('count(*) as count'); - $this + $countSql = $parent->getConnection()->raw('count(*) as count'); + + return $this + ->getBaseQuery() ->select($foreignKey, $countSql) ->groupBy($foreignKey) ->orderBy($foreignKey); - - return $this; } /** From c82a43f8f93cda0ffae4683d4c438cb165ba2d86 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Mon, 29 Jul 2024 13:32:00 +0800 Subject: [PATCH 31/59] Apply count constraint directly on relation --- .../Relations/Concerns/CanBeCounted.php | 21 +------------------ 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/src/Database/Relations/Concerns/CanBeCounted.php b/src/Database/Relations/Concerns/CanBeCounted.php index f82a3c8b3..7d2cad50f 100644 --- a/src/Database/Relations/Concerns/CanBeCounted.php +++ b/src/Database/Relations/Concerns/CanBeCounted.php @@ -47,7 +47,7 @@ trait CanBeCounted /** * Mark the relationship as a count-only relationship. */ - public function countOnly(): Builder + public function countOnly(): static { $this->countOnly = true; @@ -65,7 +65,6 @@ public function countOnly(): Builder $countSql = $parent->getConnection()->raw('count(*) as count'); return $this - ->getBaseQuery() ->select($foreignKey, $countSql) ->groupBy($foreignKey) ->orderBy($foreignKey); @@ -88,22 +87,4 @@ public function isCountOnly(): bool { return $this->countOnly; } - - public function applyCountQueryToRelation(Relation $relation) - { - if ($relation instanceof BelongsToMany) { - $relation->countMode = true; - } - - $foreignKey = ($relation instanceof HasOneThrough || $relation instanceof HasManyThrough) - ? $relation->getQualifiedFirstKeyName() - : $relation->getForeignKey(); - - $countSql = $this->parent->getConnection()->raw('count(*) as count'); - $relation - ->select($foreignKey, $countSql) - ->groupBy($foreignKey) - ->orderBy($foreignKey) - ; - } } From 3fcb603a1848a0622d731a3369c8daf941bff06c Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Mon, 29 Jul 2024 13:37:22 +0800 Subject: [PATCH 32/59] Fix Stan issues --- phpstan-baseline.neon | 119 ------------------------------------------ phpstan.neon | 4 ++ 2 files changed, 4 insertions(+), 119 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index cac473cad..be028df28 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -155,16 +155,6 @@ parameters: count: 1 path: src/Database/Relations/AttachMany.php - - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/AttachMany.php - - - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/AttachMany.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" count: 1 @@ -185,16 +175,6 @@ parameters: count: 1 path: src/Database/Relations/AttachOne.php - - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/AttachOne.php - - - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/AttachOne.php - - message: "#^Call to private method delete\\(\\) of parent class Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\MorphOne\\\\.$#" count: 3 @@ -225,16 +205,6 @@ parameters: count: 1 path: src/Database/Relations/BelongsTo.php - - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/BelongsTo.php - - - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/BelongsTo.php - - message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:\\$sessionKey\\.$#" count: 1 @@ -300,16 +270,6 @@ parameters: count: 1 path: src/Database/Relations/BelongsToMany.php - - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/BelongsToMany.php - - - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/BelongsToMany.php - - message: "#^Parameter \\#2 \\$columns of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\\\:\\:paginate\\(\\) expects array, int\\|null given\\.$#" count: 1 @@ -385,16 +345,6 @@ parameters: count: 1 path: src/Database/Relations/HasMany.php - - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/HasMany.php - - - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/HasMany.php - - message: "#^Call to private method update\\(\\) of parent class Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\HasMany\\\\.$#" count: 1 @@ -425,16 +375,6 @@ parameters: count: 1 path: src/Database/Relations/HasManyThrough.php - - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/HasManyThrough.php - - - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/HasManyThrough.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" count: 1 @@ -450,16 +390,6 @@ parameters: count: 1 path: src/Database/Relations/HasOne.php - - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/HasOne.php - - - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/HasOne.php - - message: "#^Call to private method update\\(\\) of parent class Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\HasOne\\\\.$#" count: 2 @@ -480,16 +410,6 @@ parameters: count: 1 path: src/Database/Relations/HasOneThrough.php - - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/HasOneThrough.php - - - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/HasOneThrough.php - - message: "#^Instanceof between \\*NEVER\\* and Winter\\\\Storm\\\\Database\\\\Relations\\\\HasManyThrough will always evaluate to false\\.$#" count: 4 @@ -515,15 +435,6 @@ parameters: count: 1 path: src/Database/Relations/MorphMany.php - - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/MorphMany.php - - - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/MorphMany.php - message: "#^Call to private method update\\(\\) of parent class Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\MorphMany\\\\.$#" @@ -560,16 +471,6 @@ parameters: count: 1 path: src/Database/Relations/MorphOne.php - - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/MorphOne.php - - - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/MorphOne.php - - message: "#^Call to private method update\\(\\) of parent class Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\MorphOne\\\\.$#" count: 2 @@ -600,16 +501,6 @@ parameters: count: 1 path: src/Database/Relations/MorphTo.php - - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/MorphTo.php - - - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/MorphTo.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:withDefault\\(\\)\\.$#" count: 1 @@ -635,16 +526,6 @@ parameters: count: 1 path: src/Database/Relations/MorphToMany.php - - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/MorphToMany.php - - - - message: "#^Call to an undefined method Winter\\\\Storm\\\\Database\\\\Relations\\\\Relation\\:\\:select\\(\\)\\.$#" - count: 1 - path: src/Database/Relations/MorphToMany.php - - message: "#^Parameter \\#2 \\$columns of method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\\\:\\:paginate\\(\\) expects array, int\\|null given\\.$#" count: 1 diff --git a/phpstan.neon b/phpstan.neon index 291360afc..37b9e0a2a 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -12,6 +12,10 @@ parameters: - src/Parse/PHP/ArrayPrinter.php - src/Foundation/Console/KeyGenerateCommand.php - src/Scaffold/GeneratorCommand.php + ignoreErrors: + - message: '#Call to private method select\(\)#' + paths: + - src/Database/Relations/Concerns/CanBeCounted.php disableSchemaScan: true databaseMigrationsPath: - src/Auth/Migrations From c4abef88cb1c9909395e3d8e4cc176775e08d35b Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Mon, 29 Jul 2024 16:03:29 +0800 Subject: [PATCH 33/59] Fix support for pivot models --- src/Database/Model.php | 8 +++----- src/Database/Relations/BelongsToMany.php | 9 +++++++++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/Database/Model.php b/src/Database/Model.php index cfa25b35b..b61d18b01 100644 --- a/src/Database/Model.php +++ b/src/Database/Model.php @@ -849,12 +849,10 @@ public function newPivot(EloquentModel $parent, array $attributes, $table, $exis */ public function newRelationPivot($relationName, $parent, $attributes, $table, $exists) { - $definition = $this->getRelationDefinition($relationName); + $relation = $this->{$relationName}(); + $pivotModel = $relation->getPivotClass(); - if (!is_null($definition) && array_key_exists('pivotModel', $definition)) { - $pivotModel = $definition['pivotModel']; - return $pivotModel::fromAttributes($parent, $attributes, $table, $exists); - } + return $pivotModel::fromAttributes($parent, $attributes, $table, $exists); } // diff --git a/src/Database/Relations/BelongsToMany.php b/src/Database/Relations/BelongsToMany.php index 35759e169..9d2b8a94f 100644 --- a/src/Database/Relations/BelongsToMany.php +++ b/src/Database/Relations/BelongsToMany.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany as BelongsToManyBase; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; +use Winter\Storm\Database\Pivot; class BelongsToMany extends BelongsToManyBase implements Relation { @@ -35,6 +36,14 @@ public function __construct( $this->extendableRelationConstruct(); } + /** + * {@inheritDoc} + */ + public function getPivotClass() + { + return $this->using ?? Pivot::class; + } + /** * {@inheritDoc} */ From 46778f5dc8e0b50220f31bff379453ed0d9a7ca8 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Tue, 30 Jul 2024 06:56:52 +0800 Subject: [PATCH 34/59] Update src/Database/Relations/Concerns/DefinedConstraints.php Co-authored-by: Luke Towers --- src/Database/Relations/Concerns/DefinedConstraints.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Database/Relations/Concerns/DefinedConstraints.php b/src/Database/Relations/Concerns/DefinedConstraints.php index 3b6ae2445..b4b171309 100644 --- a/src/Database/Relations/Concerns/DefinedConstraints.php +++ b/src/Database/Relations/Concerns/DefinedConstraints.php @@ -120,8 +120,6 @@ public function addDefinedConstraintsToQuery($query = null, ?array $args = null) /** * Get the relation definition for the related model. - * - * @return array */ protected function getRelationArgs(): array { From 11064edc5b1d2b9d4ceefbb95f0aa2c4b253779c Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Tue, 30 Jul 2024 11:35:52 +0800 Subject: [PATCH 35/59] Update src/Database/Relations/Concerns/CanBeCounted.php Co-authored-by: Luke Towers --- src/Database/Relations/Concerns/CanBeCounted.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Relations/Concerns/CanBeCounted.php b/src/Database/Relations/Concerns/CanBeCounted.php index 7d2cad50f..59985cab0 100644 --- a/src/Database/Relations/Concerns/CanBeCounted.php +++ b/src/Database/Relations/Concerns/CanBeCounted.php @@ -20,7 +20,7 @@ * the relationship definition method. For example: * * ```php - * public function messages() + * public function totalMessages() * { * return $this->hasMany(Message::class)->countOnly(); * } From 4d1d1d4ffd10c86ce639694de7dae72f7b388236 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Tue, 30 Jul 2024 11:36:15 +0800 Subject: [PATCH 36/59] Update src/Database/Relations/Concerns/CanBeCounted.php Co-authored-by: Luke Towers --- src/Database/Relations/Concerns/CanBeCounted.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Relations/Concerns/CanBeCounted.php b/src/Database/Relations/Concerns/CanBeCounted.php index 59985cab0..46e1d5258 100644 --- a/src/Database/Relations/Concerns/CanBeCounted.php +++ b/src/Database/Relations/Concerns/CanBeCounted.php @@ -30,7 +30,7 @@ * * ```php * public $hasMany = [ - * 'messages' => [Message::class, 'count' => true] + * 'total_messages' => [Message::class, 'count' => true] * ]; * ``` * From 192b47ddeddf5b4d1599ed95dae739d37bc4e135 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Tue, 30 Jul 2024 11:43:17 +0800 Subject: [PATCH 37/59] Update src/Database/Relations/BelongsToMany.php Co-authored-by: Luke Towers --- src/Database/Relations/BelongsToMany.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Database/Relations/BelongsToMany.php b/src/Database/Relations/BelongsToMany.php index 9d2b8a94f..b23582668 100644 --- a/src/Database/Relations/BelongsToMany.php +++ b/src/Database/Relations/BelongsToMany.php @@ -70,8 +70,7 @@ public function setSimpleValue($value): void */ if ($value instanceof Model) { $value = $value->getKey(); - } - elseif (is_array($value)) { + } elseif (is_array($value)) { foreach ($value as $_key => $_value) { if ($_value instanceof Model) { $value[$_key] = $_value->getKey(); From 57c22bac089acfb7442ff6d3bf84e151fc2267ed Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Tue, 30 Jul 2024 11:45:33 +0800 Subject: [PATCH 38/59] Update src/Database/Relations/BelongsToMany.php Co-authored-by: Luke Towers --- src/Database/Relations/BelongsToMany.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Database/Relations/BelongsToMany.php b/src/Database/Relations/BelongsToMany.php index b23582668..520968847 100644 --- a/src/Database/Relations/BelongsToMany.php +++ b/src/Database/Relations/BelongsToMany.php @@ -78,9 +78,7 @@ public function setSimpleValue($value): void } } - /* - * Convert scalar to array - */ + // Convert scalar to array if (!is_array($value) && !$value instanceof Collection) { $value = [$value]; } From 9d6b8299d0542438c7e47b183be30a5346c45ba7 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Tue, 30 Jul 2024 11:45:54 +0800 Subject: [PATCH 39/59] Update src/Database/Relations/BelongsToMany.php Co-authored-by: Luke Towers --- src/Database/Relations/BelongsToMany.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Database/Relations/BelongsToMany.php b/src/Database/Relations/BelongsToMany.php index 520968847..97adff51f 100644 --- a/src/Database/Relations/BelongsToMany.php +++ b/src/Database/Relations/BelongsToMany.php @@ -83,9 +83,7 @@ public function setSimpleValue($value): void $value = [$value]; } - /* - * Setting the relationship - */ + // Setting the relationship $relationCollection = $value instanceof Collection ? $value : $relationModel->whereIn($relationModel->getKeyName(), $value)->get(); From 2951b5c8823b719f0b20621697862aaccfe9f5e7 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Tue, 30 Jul 2024 11:46:10 +0800 Subject: [PATCH 40/59] Update src/Database/Relations/BelongsToMany.php Co-authored-by: Luke Towers --- src/Database/Relations/BelongsToMany.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Database/Relations/BelongsToMany.php b/src/Database/Relations/BelongsToMany.php index 97adff51f..5dfb9f79c 100644 --- a/src/Database/Relations/BelongsToMany.php +++ b/src/Database/Relations/BelongsToMany.php @@ -65,9 +65,7 @@ public function setSimpleValue($value): void return; } - /* - * Convert models to keys - */ + // Convert models to keys if ($value instanceof Model) { $value = $value->getKey(); } elseif (is_array($value)) { From 248397b3e6f7e870e0cb86f701f5791c620bb11a Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Tue, 30 Jul 2024 11:46:48 +0800 Subject: [PATCH 41/59] Update src/Database/Relations/BelongsToMany.php Co-authored-by: Luke Towers --- src/Database/Relations/BelongsToMany.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Database/Relations/BelongsToMany.php b/src/Database/Relations/BelongsToMany.php index 5dfb9f79c..c82a69de8 100644 --- a/src/Database/Relations/BelongsToMany.php +++ b/src/Database/Relations/BelongsToMany.php @@ -51,9 +51,7 @@ public function setSimpleValue($value): void { $relationModel = $this->getRelated(); - /* - * Nulling the relationship - */ + // Nulling the relationship if (!$value) { // Disassociate in memory immediately $this->parent->setRelation($this->relationName, $relationModel->newCollection()); From 500c32b508450aff51d333df30e8f41caf6022e2 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Tue, 30 Jul 2024 11:54:47 +0800 Subject: [PATCH 42/59] Update src/Database/Model.php Co-authored-by: Luke Towers --- src/Database/Model.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Model.php b/src/Database/Model.php index b61d18b01..57800f5fd 100644 --- a/src/Database/Model.php +++ b/src/Database/Model.php @@ -852,7 +852,7 @@ public function newRelationPivot($relationName, $parent, $attributes, $table, $e $relation = $this->{$relationName}(); $pivotModel = $relation->getPivotClass(); - return $pivotModel::fromAttributes($parent, $attributes, $table, $exists); + return $pivotModel::fromRawAttributes($parent, $attributes, $table, $exists); } // From e48418b2f8b27f7faf27a98318d0898183bc6fe4 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Tue, 30 Jul 2024 11:55:11 +0800 Subject: [PATCH 43/59] Update src/Database/Relations/BelongsToMany.php Co-authored-by: Luke Towers --- src/Database/Relations/BelongsToMany.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Database/Relations/BelongsToMany.php b/src/Database/Relations/BelongsToMany.php index c82a69de8..a4b48dcd3 100644 --- a/src/Database/Relations/BelongsToMany.php +++ b/src/Database/Relations/BelongsToMany.php @@ -106,8 +106,7 @@ public function getSimpleValue() $related = $this->getRelated(); $value = $this->parent->getRelation($this->relationName)->pluck($related->getKeyName())->all(); - } - else { + } else { $value = $this->allRelatedIds($sessionKey)->all(); } From 49d581d4173eadc7afad77b2098c1dc5d96421e0 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Wed, 31 Jul 2024 13:58:55 +0800 Subject: [PATCH 44/59] Update src/Database/Relations/Concerns/DefinedConstraints.php Co-authored-by: Luke Towers --- src/Database/Relations/Concerns/DefinedConstraints.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Database/Relations/Concerns/DefinedConstraints.php b/src/Database/Relations/Concerns/DefinedConstraints.php index b4b171309..1fa460076 100644 --- a/src/Database/Relations/Concerns/DefinedConstraints.php +++ b/src/Database/Relations/Concerns/DefinedConstraints.php @@ -14,8 +14,7 @@ trait DefinedConstraints /** * Set the defined constraints on the relation query. * - * This method is kept for backwards compatibility, but is no longer being called directly by Storm when - * initializing relations. Constraints are now applied on an as-needed basis. + * @deprecated 1.3.0 Constraints are now applied on an as-needed basis. * * @return void */ From 9840073f6b747b3b05f6aa214acf04d57ad517e7 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Fri, 16 Aug 2024 09:53:58 +0800 Subject: [PATCH 45/59] Optimise conditions for applying traits and constraints to relation --- src/Database/Concerns/HasRelationships.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Database/Concerns/HasRelationships.php b/src/Database/Concerns/HasRelationships.php index f7a535ed3..dfc493d00 100644 --- a/src/Database/Concerns/HasRelationships.php +++ b/src/Database/Concerns/HasRelationships.php @@ -512,45 +512,45 @@ protected function handleRelation(string $relationName, bool $addConstraints = t // Add dependency, if required if ( - in_array( + ($definition['delete'] ?? false) === true + && in_array( \Winter\Storm\Database\Relations\Concerns\CanBeDependent::class, class_uses_recursive($relation) ) - && (($definition['delete'] ?? false) === true) ) { $relation = $relation->dependent(); } // Remove detachable, if required if ( - in_array( + ($definition['detach'] ?? true) === false + && in_array( \Winter\Storm\Database\Relations\Concerns\CanBeDetachable::class, class_uses_recursive($relation) ) - && (($definition['detach'] ?? true) === false) ) { $relation = $relation->notDetachable(); } // Remove pushable flag, if required if ( - in_array( + ($definition['push'] ?? true) === false + && in_array( \Winter\Storm\Database\Relations\Concerns\CanBePushed::class, class_uses_recursive($relation) ) - && (($definition['push'] ?? true) === false) ) { $relation = $relation->noPush(); } // Add count only flag, if required if ( - in_array( + ($definition['count'] ?? false) === true + && $addConstraints + && in_array( \Winter\Storm\Database\Relations\Concerns\CanBeCounted::class, class_uses_recursive($relation) ) - && (($definition['count'] ?? false) === true) - && $addConstraints ) { $relation = $relation->countOnly(); } From dd7897386ea33505141098c3228fb7aa29e94454 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Fri, 16 Aug 2024 09:57:10 +0800 Subject: [PATCH 46/59] Fix Stan issues --- phpstan-baseline.neon | 5 ----- src/Database/Relations/BelongsToMany.php | 4 +++- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index be028df28..0e626dc55 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -611,11 +611,6 @@ parameters: count: 1 path: src/Foundation/Application.php - - - message: "#^Call to an undefined method NunoMaduro\\\\Collision\\\\Adapters\\\\Laravel\\\\ExceptionHandler\\:\\:error\\(\\)\\.$#" - count: 1 - path: src/Foundation/Application.php - - message: "#^Parameter \\#2 \\$data \\(array\\) of method Winter\\\\Storm\\\\Mail\\\\Mailer\\:\\:queue\\(\\) should be compatible with parameter \\$queue \\(string\\|null\\) of method Illuminate\\\\Contracts\\\\Mail\\\\MailQueue\\:\\:queue\\(\\)$#" count: 1 diff --git a/src/Database/Relations/BelongsToMany.php b/src/Database/Relations/BelongsToMany.php index a4b48dcd3..fe5d053ee 100644 --- a/src/Database/Relations/BelongsToMany.php +++ b/src/Database/Relations/BelongsToMany.php @@ -41,7 +41,9 @@ public function __construct( */ public function getPivotClass() { - return $this->using ?? Pivot::class; + return !empty($this->using) + ? $this->using + : Pivot::class; } /** From 5f6eb371cb39d0961748ed451cfdb8ec852fb529 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Fri, 16 Aug 2024 10:13:22 +0800 Subject: [PATCH 47/59] Stop double-defining of soft delete trait's dynamic methods and properties --- src/Database/Traits/SoftDelete.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Database/Traits/SoftDelete.php b/src/Database/Traits/SoftDelete.php index fa8d1426c..940396474 100644 --- a/src/Database/Traits/SoftDelete.php +++ b/src/Database/Traits/SoftDelete.php @@ -87,6 +87,11 @@ public static function bootSoftDelete() MorphToMany::class, ] as $relationClass) { $relationClass::extend(function () { + // Prevent double-defining the dynamically added properties and methods below + if ($this->methodExists('softDeletable')) { + return; + } + $this->addDynamicProperty('isSoftDeletable', false); $this->addDynamicProperty('deletedAtColumn', 'deleted_at'); From 9b056bf652f3e15f6ebb4de59bb314143e407abd Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Mon, 2 Sep 2024 14:01:18 +0800 Subject: [PATCH 48/59] Use alternative method for defining soft deletable relations Instead of extending the Relation class, we will make the soft delete methods available immediately to the relations. However, you can only *enable* soft delete if the related model uses the soft delete trait, thereby ensuring the correct soft delete functionality is available to the model when required. --- src/Database/Concerns/HasRelationships.php | 11 +++ src/Database/Relations/AttachMany.php | 1 + src/Database/Relations/AttachOne.php | 1 + .../Relations/Concerns/CanBeSoftDeleted.php | 85 +++++++++++++++++++ src/Database/Relations/HasMany.php | 1 + src/Database/Relations/HasManyThrough.php | 1 + src/Database/Relations/HasOne.php | 1 + src/Database/Relations/MorphMany.php | 1 + src/Database/Relations/MorphOne.php | 1 + src/Database/Traits/SoftDelete.php | 46 ---------- tests/Database/Traits/SoftDeleteTest.php | 27 ++++++ 11 files changed, 130 insertions(+), 46 deletions(-) create mode 100644 src/Database/Relations/Concerns/CanBeSoftDeleted.php diff --git a/src/Database/Concerns/HasRelationships.php b/src/Database/Concerns/HasRelationships.php index dfc493d00..31d0e181d 100644 --- a/src/Database/Concerns/HasRelationships.php +++ b/src/Database/Concerns/HasRelationships.php @@ -521,6 +521,17 @@ class_uses_recursive($relation) $relation = $relation->dependent(); } + // Add soft delete, if required + if ( + ($definition['softDelete'] ?? false) === true + && in_array( + \Winter\Storm\Database\Relations\Concerns\CanBeSoftDeleted::class, + class_uses_recursive($relation) + ) + ) { + $relation = $relation->softDeletable(); + } + // Remove detachable, if required if ( ($definition['detach'] ?? true) === false diff --git a/src/Database/Relations/AttachMany.php b/src/Database/Relations/AttachMany.php index a6b86d355..310dbfb62 100644 --- a/src/Database/Relations/AttachMany.php +++ b/src/Database/Relations/AttachMany.php @@ -17,6 +17,7 @@ class AttachMany extends MorphManyBase implements Relation use Concerns\CanBeDependent; use Concerns\CanBeExtended; use Concerns\CanBePushed; + use Concerns\CanBeSoftDeleted; use Concerns\DefinedConstraints; use Concerns\HasRelationName; diff --git a/src/Database/Relations/AttachOne.php b/src/Database/Relations/AttachOne.php index d965dc7a1..05f4fa7ef 100644 --- a/src/Database/Relations/AttachOne.php +++ b/src/Database/Relations/AttachOne.php @@ -17,6 +17,7 @@ class AttachOne extends MorphOneBase implements Relation use Concerns\CanBeDependent; use Concerns\CanBeExtended; use Concerns\CanBePushed; + use Concerns\CanBeSoftDeleted; use Concerns\DefinedConstraints; use Concerns\HasRelationName; diff --git a/src/Database/Relations/Concerns/CanBeSoftDeleted.php b/src/Database/Relations/Concerns/CanBeSoftDeleted.php new file mode 100644 index 000000000..e3513d093 --- /dev/null +++ b/src/Database/Relations/Concerns/CanBeSoftDeleted.php @@ -0,0 +1,85 @@ +softDeletable()` + * to the relationship definition method. For example: + * + * ```php + * public function messages() + * { + * return $this->hasMany(Message::class)->softDeletable(); + * } + * ``` + * + * If you are using the array-style definition, you can use the `softDelete` key to mark the relationship as + * soft-deletable. + * + * ```php + * public $hasMany = [ + * 'messages' => [Message::class, 'softDelete' => true] + * ]; + * ``` + * + * Please note that the related model must import the `Winter\Storm\Database\Traits\SoftDelete` trait in order to be + * marked as soft-deletable. + * + * @author Ben Thomson + * @copyright Winter CMS Maintainers + */ +trait CanBeSoftDeleted +{ + /** + * Is this relation dependent on the primary model? + */ + protected bool $isSoftDeletable = false; + + /** + * Defines the column that stores the "deleted at" timestamp. + */ + protected string $deletedAtColumn = 'deleted_at'; + + /** + * Mark the relationship as soft deletable. + */ + public function softDeletable(): static + { + if (in_array('Winter\Storm\Database\Traits\SoftDelete', class_uses_recursive($this->related))) { + $this->isSoftDeletable = true; + } + + return $this; + } + + /** + * Mark the relationship as not soft deletable (will be hard-deleted instead if the `dependent` option is set). + */ + public function notSoftDeletable(): static + { + $this->isSoftDeletable = false; + + return $this; + } + + /** + * Determine if the related model is soft-deleted when the primary model is deleted. + */ + public function isSoftDeletable(): bool + { + return $this->isSoftDeletable; + } + + /** + * Gets the column that stores the "deleted at" timestamp. + */ + public function getDeletedAtColumn(): string + { + return $this->deletedAtColumn; + } +} diff --git a/src/Database/Relations/HasMany.php b/src/Database/Relations/HasMany.php index db1c2f48e..57a47dea0 100644 --- a/src/Database/Relations/HasMany.php +++ b/src/Database/Relations/HasMany.php @@ -18,6 +18,7 @@ class HasMany extends HasManyBase implements Relation use Concerns\CanBeDependent; use Concerns\CanBeExtended; use Concerns\CanBePushed; + use Concerns\CanBeSoftDeleted; use Concerns\DefinedConstraints; use Concerns\HasRelationName; diff --git a/src/Database/Relations/HasManyThrough.php b/src/Database/Relations/HasManyThrough.php index 6ababbb7e..270608f92 100644 --- a/src/Database/Relations/HasManyThrough.php +++ b/src/Database/Relations/HasManyThrough.php @@ -15,6 +15,7 @@ class HasManyThrough extends HasManyThroughBase use Concerns\CanBeCounted; use Concerns\CanBeExtended; use Concerns\CanBePushed; + use Concerns\CanBeSoftDeleted; use Concerns\DefinedConstraints; use Concerns\HasRelationName; diff --git a/src/Database/Relations/HasOne.php b/src/Database/Relations/HasOne.php index 047d90a57..16d0c5fb2 100644 --- a/src/Database/Relations/HasOne.php +++ b/src/Database/Relations/HasOne.php @@ -16,6 +16,7 @@ class HasOne extends HasOneBase implements Relation use Concerns\CanBeDependent; use Concerns\CanBeExtended; use Concerns\CanBePushed; + use Concerns\CanBeSoftDeleted; use Concerns\DefinedConstraints; use Concerns\HasRelationName; diff --git a/src/Database/Relations/MorphMany.php b/src/Database/Relations/MorphMany.php index db6373e3d..74375ba06 100644 --- a/src/Database/Relations/MorphMany.php +++ b/src/Database/Relations/MorphMany.php @@ -18,6 +18,7 @@ class MorphMany extends MorphManyBase implements Relation use Concerns\CanBeDependent; use Concerns\CanBeExtended; use Concerns\CanBePushed; + use Concerns\CanBeSoftDeleted; use Concerns\DefinedConstraints; use Concerns\HasRelationName; diff --git a/src/Database/Relations/MorphOne.php b/src/Database/Relations/MorphOne.php index 4d6b766b0..9827174b3 100644 --- a/src/Database/Relations/MorphOne.php +++ b/src/Database/Relations/MorphOne.php @@ -16,6 +16,7 @@ class MorphOne extends MorphOneBase implements Relation use Concerns\CanBeDependent; use Concerns\CanBeExtended; use Concerns\CanBePushed; + use Concerns\CanBeSoftDeleted; use Concerns\DefinedConstraints; use Concerns\HasRelationName; diff --git a/src/Database/Traits/SoftDelete.php b/src/Database/Traits/SoftDelete.php index 940396474..5e372a0ca 100644 --- a/src/Database/Traits/SoftDelete.php +++ b/src/Database/Traits/SoftDelete.php @@ -6,13 +6,7 @@ use Illuminate\Database\Eloquent\Collection as CollectionBase; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Eloquent\SoftDeletingScope; -use Winter\Storm\Database\Relations\AttachMany; -use Winter\Storm\Database\Relations\AttachOne; use Winter\Storm\Database\Relations\BelongsToMany; -use Winter\Storm\Database\Relations\HasMany; -use Winter\Storm\Database\Relations\HasOne; -use Winter\Storm\Database\Relations\MorphMany; -use Winter\Storm\Database\Relations\MorphOne; use Winter\Storm\Database\Relations\MorphToMany; /** @@ -75,46 +69,6 @@ public static function bootSoftDelete() */ return $model->fireEvent('model.afterRestore', halt: true); }); - - foreach ([ - AttachMany::class, - AttachOne::class, - BelongsToMany::class, - HasMany::class, - HasOne::class, - MorphMany::class, - MorphOne::class, - MorphToMany::class, - ] as $relationClass) { - $relationClass::extend(function () { - // Prevent double-defining the dynamically added properties and methods below - if ($this->methodExists('softDeletable')) { - return; - } - - $this->addDynamicProperty('isSoftDeletable', false); - $this->addDynamicProperty('deletedAtColumn', 'deleted_at'); - - $this->addDynamicMethod('softDeletable', function (string $deletedAtColumn = 'deleted_at') { - $this->isSoftDeletable = true; - $this->deletedAtColumn = $deletedAtColumn; - return $this; - }); - - $this->addDynamicMethod('notSoftDeletable', function () { - $this->isSoftDeletable = false; - return $this; - }); - - $this->addDynamicMethod('isSoftDeletable', function () { - return $this->isSoftDeletable; - }); - - $this->addDynamicMethod('getDeletedAtColumn', function () { - return $this->deletedAtColumn; - }); - }, true); - } } /** diff --git a/tests/Database/Traits/SoftDeleteTest.php b/tests/Database/Traits/SoftDeleteTest.php index caa7698fa..3ed4d8dd1 100644 --- a/tests/Database/Traits/SoftDeleteTest.php +++ b/tests/Database/Traits/SoftDeleteTest.php @@ -9,6 +9,9 @@ use Winter\Storm\Tests\Database\Fixtures\UserWithAuthorAndSoftDelete; use Winter\Storm\Tests\Database\Fixtures\UserWithSoftAuthorAndSoftDelete; use Winter\Storm\Database\Model; +use Winter\Storm\Tests\Database\Fixtures\Category; +use Winter\Storm\Tests\Database\Fixtures\EventLog; +use Winter\Storm\Tests\Database\Fixtures\Post; use Winter\Storm\Tests\Database\Fixtures\UserLaravel; use Winter\Storm\Tests\Database\Fixtures\UserLaravelWithSoftAuthor; use Winter\Storm\Tests\Database\Fixtures\UserLaravelWithSoftAuthorAndSoftDelete; @@ -166,4 +169,28 @@ public function testRestoreSoftDeleteRelationLaravelRelation() $this->assertNotNull(SoftDeleteAuthor::find($authorId)); } + + public function testCannotMakeModelSoftDeleteIfNotUsingTrait() + { + $categoryModel = new Category(); + $relation = $categoryModel->hasMany(Post::class); + + $this->assertFalse($relation->isSoftDeletable()); + + $relation->softDeletable(); + + // Should still be false because the model does not use the trait + $this->assertFalse($relation->isSoftDeletable()); + + $postModel = new Post(); + + $relation = $postModel->morphMany(EventLog::class, 'related'); + + $this->assertFalse($relation->isSoftDeletable()); + + $relation->softDeletable(); + + // This should now be true because EventLog does use the trait + $this->assertTrue($relation->isSoftDeletable()); + } } From 267187b006f29a42fdfdef8e1f7955d998b64288 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Mon, 2 Sep 2024 14:07:43 +0800 Subject: [PATCH 49/59] Stan fixes --- src/Database/Dongle.php | 4 ---- src/Html/Helper.php | 4 ---- 2 files changed, 8 deletions(-) diff --git a/src/Database/Dongle.php b/src/Database/Dongle.php index 8f650239d..10bfdeabb 100644 --- a/src/Database/Dongle.php +++ b/src/Database/Dongle.php @@ -65,10 +65,6 @@ public function parse($sql) public function parseGroupConcat($sql) { $result = preg_replace_callback('/group_concat\((.+)\)/i', function ($matches) { - if (!isset($matches[1])) { - return $matches[0]; - } - switch ($this->driver) { default: case 'mysql': diff --git a/src/Html/Helper.php b/src/Html/Helper.php index ab617d3e1..136844b2a 100644 --- a/src/Html/Helper.php +++ b/src/Html/Helper.php @@ -35,10 +35,6 @@ public static function nameToArray($string) } if (preg_match('/^([^\]]+)(?:\[(.+)\])+$/', $string, $matches)) { - if (count($matches) < 2) { - return $result; - } - $result = explode('][', $matches[2]); array_unshift($result, $matches[1]); } From eb815a918281555d7c8be2dad24d5bf699e044d9 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Mon, 7 Oct 2024 05:44:26 +0000 Subject: [PATCH 50/59] Add support for dynamic Laravel-style relations through Extension --- src/Database/Attributes/Relation.php | 2 +- src/Database/Concerns/HasRelationships.php | 152 +++++++++++++++--- src/Database/Model.php | 2 +- .../Relations/DynamicRelationTest.php | 48 ++++++ 4 files changed, 177 insertions(+), 27 deletions(-) create mode 100644 tests/Database/Relations/DynamicRelationTest.php diff --git a/src/Database/Attributes/Relation.php b/src/Database/Attributes/Relation.php index e988f49a9..dc9730234 100644 --- a/src/Database/Attributes/Relation.php +++ b/src/Database/Attributes/Relation.php @@ -4,7 +4,7 @@ use Attribute; -#[Attribute(Attribute::TARGET_METHOD)] +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_FUNCTION)] class Relation { } diff --git a/src/Database/Concerns/HasRelationships.php b/src/Database/Concerns/HasRelationships.php index 31d0e181d..72df180b7 100644 --- a/src/Database/Concerns/HasRelationships.php +++ b/src/Database/Concerns/HasRelationships.php @@ -4,7 +4,6 @@ use InvalidArgumentException; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Collection as CollectionBase; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Relation as EloquentRelation; use Winter\Storm\Database\Attributes\Relation; @@ -160,9 +159,9 @@ trait HasRelationships ]; /** - * @var array Stores relations that have resolved to Laravel-style relation objects. + * @var array> Stores relations that have resolved to Laravel-style relation objects. */ - protected static array $resolvedRelations = []; + protected static array $resolvedRelationMethods = []; // // Relations @@ -171,9 +170,9 @@ trait HasRelationships /** * Checks if model has a relationship by supplied name. */ - public function hasRelation(string $name): bool + public function hasRelation(string $name, bool $includeMethods = true): bool { - return $this->getRelationDefinition($name) !== null; + return $this->getRelationDefinition($name, $includeMethods) !== null; } /** @@ -206,13 +205,13 @@ public function getDefinedRelations(): array * If the name resolves to a relation method, the method's returned relation object will be converted back to an * array definition. */ - public function getRelationDefinition(string $name): ?array + public function getRelationDefinition(string $name, bool $includeMethods = true): ?array { - if (method_exists($this, $name) && $this->isRelationMethod($name)) { + if ($includeMethods && $this->isRelationMethod($name)) { return $this->relationMethodDefinition($name); } - if (($type = $this->getRelationType($name)) !== null) { + if (($type = $this->getRelationType($name, $includeMethods)) !== null) { return (array) $this->getRelationTypeDefinition($type, $name) + $this->getRelationDefaults($type); } @@ -275,10 +274,10 @@ public function getRelationDefinitions(): array /** * Returns a relationship type based on a supplied name. */ - public function getRelationType(string $name): ?string + public function getRelationType(string $name, bool $includeMethods = true): ?string { - if (method_exists($this, $name)) { - return array_search(get_class($this->{$name}()), static::$relationTypes) ?: null; + if ($includeMethods && $this->isRelationMethod($name)) { + return $this->getRelationMethodType($name); } foreach (array_keys(static::$relationTypes) as $type) { @@ -581,7 +580,7 @@ class_uses_recursive($relation) */ protected function getRelationCaller(): ?string { - $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); + $trace = debug_backtrace(0); $handled = Arr::first($trace, function ($trace) { return $trace['function'] === 'handleRelation'; @@ -591,16 +590,38 @@ protected function getRelationCaller(): ?string return null; } - $caller = Arr::first($trace, function ($trace) { - return !in_array( + $currentKey = null; + + $caller = Arr::first($trace, function ($trace, $key) use (&$currentKey) { + $result = !in_array( $trace['class'], [ \Illuminate\Database\Eloquent\Model::class, \Winter\Storm\Database\Model::class, ] ); + + if ($result) { + $currentKey = $key; + } + + return $result; }); + // If we're dealing with a closure, we're likely dealing with an extension. We'll need to go deeper! + if (str_contains($caller['function'], '{closure}')) { + [$stepOne, $stepTwo] = array_slice($trace, $currentKey + 1, 2); + + if ($stepOne['function'] !== 'call_user_func_array') { + return null; + } + if ($stepTwo['class'] !== \Winter\Storm\Database\Model::class || $stepTwo['function'] !== 'extendableCall') { + return null; + } + + return $stepTwo['args'][0]; + } + return !is_null($caller) ? $caller['function'] : null; } @@ -654,40 +675,116 @@ public function getRelationMethods(): array } } + if (count($this->extensionData['methods'] ?? [])) { + foreach ($this->extensionData['methods'] as $name => $method) { + if ($this->isRelationMethod($method)) { + $relationMethods[] = $name; + } + } + } + + if (count($this->extensionData['dynamicMethods'] ?? [])) { + foreach ($this->extensionData['dynamicMethods'] as $name => $method) { + if ($this->isRelationMethod($method)) { + $relationMethods[] = $name; + } + } + } + return $relationMethods; } /** - * Determines if the provided method name is a relation method. + * Determine the relation type of a relation method. * - * A relation method either specifies the `Relation` attribute or has a return type that matches a relation. + * This is used to determine if a method is a relation method, first and foremost, and then to determine the type of the relation. */ - public function isRelationMethod(string $name): bool + protected function getRelationMethodType(string $name): ?string { - if (!method_exists($this, $name)) { - return false; + if (!$this->methodExists($name)) { + return null; } - $method = new \ReflectionMethod($this, $name); + if (isset(static::$resolvedRelationMethods[$name])) { + if (!static::$resolvedRelationMethods[$name]['isRelation']) { + return null; + } + + return static::$resolvedRelationMethods[$name]['type']; + } + + // Directly defined relation methods + if (method_exists($this, $name)) { + $method = new \ReflectionMethod($this, $name); + } elseif (isset($this->extensionData['methods'][$name])) { + $method = new \ReflectionFunction($this->extensionData['methods'][$name]->getClosure()); + } elseif (isset($this->extensionData['dynamicMethods'][$name])) { + $method = new \ReflectionFunction($this->extensionData['dynamicMethods'][$name]->getClosure()); + } if (count($method->getAttributes(Relation::class))) { - return true; + $type = array_search(get_class($this->$name()), static::$relationTypes); + if (!$type) { + static::$resolvedRelationMethods[$name] = [ + 'isRelation' => false, + ]; + } + + static::$resolvedRelationMethods[$name] = [ + 'isRelation' => true, + 'type' => $type, + ]; + + return $type; } $returnType = $method->getReturnType(); if (is_null($returnType)) { - return false; + static::$resolvedRelationMethods[$name] = [ + 'isRelation' => false, + ]; + + return null; } if ( $returnType instanceof \ReflectionNamedType && in_array($returnType->getName(), array_values(static::$relationTypes)) ) { - return true; + $type = array_search($returnType->getName(), static::$relationTypes); + + static::$resolvedRelationMethods[$name] = [ + 'isRelation' => true, + 'type' => $type, + ]; + + return $type; + } + + static::$resolvedRelationMethods[$name] = [ + 'isRelation' => false, + ]; + + return null; + } + + /** + * Determines if the provided method name is a relation method. + * + * A relation method either specifies the `Relation` attribute or has a return type that matches a relation. + */ + protected function isRelationMethod(string $name): bool + { + if (!$this->methodExists($name)) { + return false; } - return false; + if (isset(static::$resolvedRelationMethods[$name])) { + return static::$resolvedRelationMethods[$name]['isRelation']; + } + + return $this->getRelationMethodType($name) !== null; } /** @@ -699,7 +796,12 @@ protected function relationMethodDefinition(string $name): array return []; } - return $this->{$name}()->getArrayDefinition(); + if (isset(static::$resolvedRelationMethods[$name]['definition'])) { + return static::$resolvedRelationMethods[$name]['definition']; + } + + $definition = $this->{$name}()->getArrayDefinition() + $this->getRelationDefaults($this->getRelationType($name)); + return static::$resolvedRelationMethods[$name]['definition'] = $definition; } /** diff --git a/src/Database/Model.php b/src/Database/Model.php index 68ca608a6..b6736b47f 100644 --- a/src/Database/Model.php +++ b/src/Database/Model.php @@ -770,7 +770,7 @@ public function __call($name, $params) * Never call handleRelation() anywhere else as it could * break getRelationCaller(), use $this->{$name}() instead */ - if ($this->hasRelation($name)) { + if ($this->hasRelation($name, false)) { return $this->handleRelation($name); } diff --git a/tests/Database/Relations/DynamicRelationTest.php b/tests/Database/Relations/DynamicRelationTest.php new file mode 100644 index 000000000..1750d5226 --- /dev/null +++ b/tests/Database/Relations/DynamicRelationTest.php @@ -0,0 +1,48 @@ + 'First post', 'description' => 'Yay!!']); + $author1 = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $author2 = Author::create(['name' => 'Louie', 'email' => 'louie@example.com']); + Model::reguard(); + + Post::extend(function (Post $model) { + $model->addDynamicMethod('creator', function () use ($model): BelongsTo { + return $model->belongsTo(Author::class, 'author_id'); + }); + }); + + $post = new Post; + + $this->assertTrue($post->hasRelation('creator')); + $this->assertEquals('belongsTo', $post->getRelationType('creator')); + + // Set by Model object + $post->creator = $author1; + $this->assertEquals($author1->id, $post->author_id); + $this->assertEquals('Stevie', $post->creator->name); + + // Set by primary key + $post->creator = $author2->id; + $this->assertEquals($author2->id, $post->author_id); + $this->assertEquals('Louie', $post->creator->name); + + // Nullify + $post->creator = null; + $this->assertNull($post->author_id); + $this->assertNull($post->creator); + } +} From f8ee9d7073287e35fdd765b7e5f29a91b538b328 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Mon, 7 Oct 2024 05:46:01 +0000 Subject: [PATCH 51/59] Stan fix --- src/Database/Concerns/HasRelationships.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Database/Concerns/HasRelationships.php b/src/Database/Concerns/HasRelationships.php index 72df180b7..7c6de4c8f 100644 --- a/src/Database/Concerns/HasRelationships.php +++ b/src/Database/Concerns/HasRelationships.php @@ -720,6 +720,8 @@ protected function getRelationMethodType(string $name): ?string $method = new \ReflectionFunction($this->extensionData['methods'][$name]->getClosure()); } elseif (isset($this->extensionData['dynamicMethods'][$name])) { $method = new \ReflectionFunction($this->extensionData['dynamicMethods'][$name]->getClosure()); + } else { + return null; } if (count($method->getAttributes(Relation::class))) { From 2c8972b82e9f5f0fcf34a096f452c53e6ff0e316 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Tue, 8 Oct 2024 00:42:15 +0000 Subject: [PATCH 52/59] Fix infinite loop, improve support for dynamic Laravel relations, improve performance To prevent a particular infinite loop, we need to define the Relation type when using the Relation attribute for a method so that we don't have to instantiate the relation in order to find out what type it is. --- src/Database/Attributes/Relation.php | 45 +++++ src/Database/Concerns/HasRelationships.php | 185 ++++++++++++------ src/Database/Model.php | 2 +- .../Concerns/HasRelationshipsTest.php | 122 ++++++++++++ tests/Database/Fixtures/Author.php | 9 +- 5 files changed, 299 insertions(+), 64 deletions(-) diff --git a/src/Database/Attributes/Relation.php b/src/Database/Attributes/Relation.php index dc9730234..b11f7c736 100644 --- a/src/Database/Attributes/Relation.php +++ b/src/Database/Attributes/Relation.php @@ -3,8 +3,53 @@ namespace Winter\Storm\Database\Attributes; use Attribute; +use Winter\Storm\Database\Relations\AttachMany; +use Winter\Storm\Database\Relations\AttachOne; +use Winter\Storm\Database\Relations\BelongsTo; +use Winter\Storm\Database\Relations\BelongsToMany; +use Winter\Storm\Database\Relations\HasMany; +use Winter\Storm\Database\Relations\HasManyThrough; +use Winter\Storm\Database\Relations\HasOne; +use Winter\Storm\Database\Relations\HasOneThrough; +use Winter\Storm\Database\Relations\MorphMany; +use Winter\Storm\Database\Relations\MorphOne; +use Winter\Storm\Database\Relations\MorphTo; +use Winter\Storm\Database\Relations\MorphToMany; #[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_FUNCTION)] class Relation { + private string $type; + + public static array $relationTypes = [ + 'hasOne' => HasOne::class, + 'hasMany' => HasMany::class, + 'belongsTo' => BelongsTo::class, + 'belongsToMany' => BelongsToMany::class, + 'morphTo' => MorphTo::class, + 'morphOne' => MorphOne::class, + 'morphMany' => MorphMany::class, + 'morphToMany' => MorphToMany::class, + 'morphedByMany' => MorphToMany::class, + 'attachOne' => AttachOne::class, + 'attachMany' => AttachMany::class, + 'hasOneThrough' => HasOneThrough::class, + 'hasManyThrough' => HasManyThrough::class, + ]; + + public function __construct(string $type) + { + if (in_array($type, array_keys(static::$relationTypes))) { + $this->type = $type; + } elseif (in_array($type, array_values(static::$relationTypes))) { + $this->type = array_search($type, static::$relationTypes); + } else { + throw new \Exception('Invalid relation type'); + } + } + + public function getType(): string + { + return $this->type; + } } diff --git a/src/Database/Concerns/HasRelationships.php b/src/Database/Concerns/HasRelationships.php index 7c6de4c8f..c35a44bf1 100644 --- a/src/Database/Concerns/HasRelationships.php +++ b/src/Database/Concerns/HasRelationships.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Relation as EloquentRelation; use Winter\Storm\Database\Attributes\Relation; +use Winter\Storm\Database\Model as DatabaseModel; use Winter\Storm\Database\Relations\AttachMany; use Winter\Storm\Database\Relations\AttachOne; use Winter\Storm\Database\Relations\BelongsTo; @@ -159,10 +160,16 @@ trait HasRelationships ]; /** - * @var array> Stores relations that have resolved to Laravel-style relation objects. + * @var array> Stores methods that have resolved to Laravel-style relation objects by class name. */ protected static array $resolvedRelationMethods = []; + /** + * @var array> Stores methods that have not been resolved to Laravel-style relation objects by class name. + * Used mainly for testing extension methods. + */ + protected static array $resolvedNonRelationMethods = []; + // // Relations // @@ -170,9 +177,13 @@ trait HasRelationships /** * Checks if model has a relationship by supplied name. */ - public function hasRelation(string $name, bool $includeMethods = true): bool + public function hasRelation(string $name, bool $propertyOnly = false): bool { - return $this->getRelationDefinition($name, $includeMethods) !== null; + if (!$propertyOnly && $this->isRelationMethod($name, true)) { + return true; + } + + return $this->getRelationDefinition($name, false) !== null; } /** @@ -187,12 +198,12 @@ public function getDefinedRelations(): array $relations = []; foreach (array_keys(static::$relationTypes) as $type) { - foreach (array_keys($this->getRelationTypeDefinitions($type)) as $name) { + foreach (array_keys($this->getRelationTypeDefinitions($type, false)) as $name) { $relations[$name] = $this->handleRelation($name, false); } } - foreach ($this->getRelationMethods() as $relation) { + foreach ($this->getRelationMethods(true) as $relation) { $relations[$relation] = $this->{$relation}(); } @@ -212,7 +223,7 @@ public function getRelationDefinition(string $name, bool $includeMethods = true) } if (($type = $this->getRelationType($name, $includeMethods)) !== null) { - return (array) $this->getRelationTypeDefinition($type, $name) + $this->getRelationDefaults($type); + return (array) $this->getRelationTypeDefinition($type, $name, $includeMethods) + $this->getRelationDefaults($type); } return null; @@ -221,15 +232,25 @@ public function getRelationDefinition(string $name, bool $includeMethods = true) /** * Returns all defined relations of given type. */ - public function getRelationTypeDefinitions(string $type): array + public function getRelationTypeDefinitions(string $type, bool $includeMethods = true): array { + $relations = []; + if (in_array($type, array_keys(static::$relationTypes))) { - return array_map(function ($relation) { + $relations = array_map(function ($relation) { return (is_string($relation)) ? [$relation] : $relation; }, $this->{$type}); + + if ($includeMethods) { + foreach ($this->getRelationMethods() as $method) { + if ($this->getRelationMethodType($method) === $type) { + $relations[$method] = $this->relationMethodDefinition($method); + } + } + } } - return []; + return $relations; } /** @@ -237,9 +258,9 @@ public function getRelationTypeDefinitions(string $type): array * * If no relation exists by the given name and type, `null` will be returned. */ - public function getRelationTypeDefinition(string $type, string $name): array|null + public function getRelationTypeDefinition(string $type, string $name, bool $includeMethods = true): array|null { - $definitions = $this->getRelationTypeDefinitions($type); + $definitions = $this->getRelationTypeDefinitions($type, $includeMethods); if (isset($definitions[$name])) { return $definitions[$name]; @@ -251,12 +272,12 @@ public function getRelationTypeDefinition(string $type, string $name): array|nul /** * Returns relationship details for all relations defined on this model. */ - public function getRelationDefinitions(): array + public function getRelationDefinitions(bool $includeMethods = true): array { $result = []; foreach (array_keys(static::$relationTypes) as $type) { - $result[$type] = $this->getRelationTypeDefinitions($type); + $result[$type] = $this->getRelationTypeDefinitions($type, $includeMethods); /* * Apply default values for the relation type @@ -281,7 +302,7 @@ public function getRelationType(string $name, bool $includeMethods = true): ?str } foreach (array_keys(static::$relationTypes) as $type) { - if ($this->getRelationTypeDefinition($type, $name) !== null) { + if ($this->getRelationTypeDefinition($type, $name, $includeMethods) !== null) { return $type; } } @@ -413,7 +434,7 @@ protected function handleRelation(string $relationName, bool $addConstraints = t case 'morphOne': $relation = $this->morphOne( $relatedClass, - $definition['name'], + $definition['name'] ?? $relationName, $definition['type'] ?? null, $definition['id'] ?? null, $definition['key'] ?? null, @@ -422,7 +443,7 @@ protected function handleRelation(string $relationName, bool $addConstraints = t case 'morphMany': $relation = $this->morphMany( $relatedClass, - $definition['name'], + $definition['name'] ?? $relationName, $definition['type'] ?? null, $definition['id'] ?? null, $definition['key'] ?? null, @@ -431,7 +452,7 @@ protected function handleRelation(string $relationName, bool $addConstraints = t case 'morphToMany': $relation = $this->morphToMany( $relatedClass, - $definition['name'], + $definition['name'] ?? $relationName, $definition['table'] ?? null, $definition['key'] ?? null, $definition['otherKey'] ?? null, @@ -446,7 +467,7 @@ protected function handleRelation(string $relationName, bool $addConstraints = t case 'morphedByMany': $relation = $this->morphedByMany( $relatedClass, - $definition['name'], + $definition['name'] ?? $relationName, $definition['table'] ?? null, $definition['key'] ?? null, $definition['otherKey'] ?? null, @@ -665,27 +686,44 @@ protected function performDeleteOnRelations(): void /** * Retrieves all methods that either contain the `Relation` attribute or have a return type that matches a relation. */ - public function getRelationMethods(): array + public function getRelationMethods(bool $ignoreResolved = false): array { $relationMethods = []; - foreach (get_class_methods($this) as $method) { - if (!in_array($method, ['attachOne', 'attachMany']) && $this->isRelationMethod($method)) { - $relationMethods[] = $method; + if ($ignoreResolved || !isset(static::$resolvedRelationMethods[static::class])) { + $validMethods = []; + $reflection = new \ReflectionClass($this); + + foreach ($reflection->getMethods() as $method) { + if ( + $method->getDeclaringClass()->getName() === DatabaseModel::class + || $method->getDeclaringClass()->getName() === Model::class + ) { + continue; + } + $validMethods[] = $method->getName(); } + + foreach ($validMethods as $method) { + if (!in_array($method, ['attachOne', 'attachMany']) && $this->isRelationMethod($method)) { + $relationMethods[] = $method; + } + } + } else { + $relationMethods += array_keys(static::$resolvedRelationMethods[static::class]); } if (count($this->extensionData['methods'] ?? [])) { - foreach ($this->extensionData['methods'] as $name => $method) { - if ($this->isRelationMethod($method)) { + foreach (array_keys($this->extensionData['methods']) as $name) { + if ($this->isRelationMethod($name)) { $relationMethods[] = $name; } } } if (count($this->extensionData['dynamicMethods'] ?? [])) { - foreach ($this->extensionData['dynamicMethods'] as $name => $method) { - if ($this->isRelationMethod($method)) { + foreach (array_keys($this->extensionData['dynamicMethods']) as $name) { + if ($this->isRelationMethod($name)) { $relationMethods[] = $name; } } @@ -705,35 +743,40 @@ protected function getRelationMethodType(string $name): ?string return null; } - if (isset(static::$resolvedRelationMethods[$name])) { - if (!static::$resolvedRelationMethods[$name]['isRelation']) { - return null; - } + if (isset(static::$resolvedRelationMethods[static::class][$name])) { + return static::$resolvedRelationMethods[static::class][$name]['type']; + } - return static::$resolvedRelationMethods[$name]['type']; + if ( + isset(static::$resolvedNonRelationMethods[static::class]) + && in_array($name, static::$resolvedNonRelationMethods[static::class]) + ) { + return null; } // Directly defined relation methods if (method_exists($this, $name)) { $method = new \ReflectionMethod($this, $name); } elseif (isset($this->extensionData['methods'][$name])) { - $method = new \ReflectionFunction($this->extensionData['methods'][$name]->getClosure()); + $extension = $this->extensionData['methods'][$name]; + $object = $this->extensionData['extensions'][$extension]; + $method = new \ReflectionMethod($object, $name); } elseif (isset($this->extensionData['dynamicMethods'][$name])) { $method = new \ReflectionFunction($this->extensionData['dynamicMethods'][$name]->getClosure()); } else { + if (!isset(static::$resolvedNonRelationMethods[static::class])) { + static::$resolvedNonRelationMethods[static::class] = [$name]; + } else { + static::$resolvedNonRelationMethods[static::class][] = $name; + } return null; } if (count($method->getAttributes(Relation::class))) { - $type = array_search(get_class($this->$name()), static::$relationTypes); - if (!$type) { - static::$resolvedRelationMethods[$name] = [ - 'isRelation' => false, - ]; - } + $attribute = $method->getAttributes(Relation::class)[0]; + $type = $attribute->newInstance()->getType(); - static::$resolvedRelationMethods[$name] = [ - 'isRelation' => true, + static::$resolvedRelationMethods[static::class][$name] = [ 'type' => $type, ]; @@ -743,10 +786,11 @@ protected function getRelationMethodType(string $name): ?string $returnType = $method->getReturnType(); if (is_null($returnType)) { - static::$resolvedRelationMethods[$name] = [ - 'isRelation' => false, - ]; - + if (!isset(static::$resolvedNonRelationMethods[static::class])) { + static::$resolvedNonRelationMethods[static::class] = [$name]; + } else { + static::$resolvedNonRelationMethods[static::class][] = $name; + } return null; } @@ -756,18 +800,18 @@ protected function getRelationMethodType(string $name): ?string ) { $type = array_search($returnType->getName(), static::$relationTypes); - static::$resolvedRelationMethods[$name] = [ - 'isRelation' => true, + static::$resolvedRelationMethods[static::class][$name] = [ 'type' => $type, ]; return $type; } - static::$resolvedRelationMethods[$name] = [ - 'isRelation' => false, - ]; - + if (!isset(static::$resolvedNonRelationMethods[static::class])) { + static::$resolvedNonRelationMethods[static::class] = [$name]; + } else { + static::$resolvedNonRelationMethods[static::class][] = $name; + } return null; } @@ -776,34 +820,57 @@ protected function getRelationMethodType(string $name): ?string * * A relation method either specifies the `Relation` attribute or has a return type that matches a relation. */ - protected function isRelationMethod(string $name): bool + protected function isRelationMethod(string $name, bool $ignoreResolved = false): bool { if (!$this->methodExists($name)) { return false; } - if (isset(static::$resolvedRelationMethods[$name])) { - return static::$resolvedRelationMethods[$name]['isRelation']; + if (!$ignoreResolved) { + if (isset(static::$resolvedRelationMethods[static::class][$name])) { + return true; + } + + if ( + isset(static::$resolvedNonRelationMethods[static::class]) + && in_array($name, static::$resolvedNonRelationMethods[static::class]) + ) { + return false; + } } - return $this->getRelationMethodType($name) !== null; + return $this->getRelationMethodType($name, $ignoreResolved) !== null; } /** * Generates a definition array for a relation method. */ - protected function relationMethodDefinition(string $name): array + protected function relationMethodDefinition(string $name, bool $ignoreResolved = false): array { if (!$this->isRelationMethod($name)) { return []; } - if (isset(static::$resolvedRelationMethods[$name]['definition'])) { - return static::$resolvedRelationMethods[$name]['definition']; + if (!$ignoreResolved) { + if (isset(static::$resolvedRelationMethods[static::class][$name]['definition'])) { + return static::$resolvedRelationMethods[static::class][$name]['definition']; + } + + if ( + isset(static::$resolvedNonRelationMethods[static::class]) + && in_array($name, static::$resolvedNonRelationMethods[static::class]) + ) { + return []; + } } - $definition = $this->{$name}()->getArrayDefinition() + $this->getRelationDefaults($this->getRelationType($name)); - return static::$resolvedRelationMethods[$name]['definition'] = $definition; + $definition = null; + + EloquentRelation::noConstraints(function () use ($name, &$definition) { + $definition = $this->{$name}()->getArrayDefinition() + $this->getRelationDefaults($this->getRelationType($name)); + }); + + return static::$resolvedRelationMethods[static::class][$name]['definition'] = $definition; } /** diff --git a/src/Database/Model.php b/src/Database/Model.php index b6736b47f..81dbd59ca 100644 --- a/src/Database/Model.php +++ b/src/Database/Model.php @@ -770,7 +770,7 @@ public function __call($name, $params) * Never call handleRelation() anywhere else as it could * break getRelationCaller(), use $this->{$name}() instead */ - if ($this->hasRelation($name, false)) { + if ($this->hasRelation($name, true)) { return $this->handleRelation($name); } diff --git a/tests/Database/Concerns/HasRelationshipsTest.php b/tests/Database/Concerns/HasRelationshipsTest.php index 9fb9b4c56..8e7fa9aef 100644 --- a/tests/Database/Concerns/HasRelationshipsTest.php +++ b/tests/Database/Concerns/HasRelationshipsTest.php @@ -16,6 +16,7 @@ use Winter\Storm\Tests\Database\Fixtures\Phone; use Winter\Storm\Tests\Database\Fixtures\Post; use Winter\Storm\Tests\Database\Fixtures\Role; +use Winter\Storm\Tests\Database\Fixtures\SoftDeleteUser; use Winter\Storm\Tests\Database\Fixtures\Tag; use Winter\Storm\Tests\Database\Fixtures\User; use Winter\Storm\Tests\DbTestCase; @@ -197,4 +198,125 @@ public function testGetRelationDefinition() $this->assertNull($author->getRelationDefinition('invalid')); } + + public function testGetRelationDefinitions() + { + $author = new Author(); + $definitions = $author->getRelationDefinitions(); + + $this->assertCount(3, $definitions['belongsTo']); + + $this->assertEquals([ + 'user' => [User::class, 'delete' => true], + 'country' => [Country::class], + 'user_soft' => [SoftDeleteUser::class, 'key' => 'user_id', 'softDelete' => true], + ], $definitions['belongsTo']); + + $this->assertCount(2, $definitions['hasMany']); + + $this->assertEquals([ + 'posts' => [Post::class], // Property-style + 'messages' => [ // Laravel-style + Post::class, + 'key' => 'author_id', + 'otherKey' => 'id', + 'delete' => false, + 'push' => true, + 'count' => false + ], + ], $definitions['hasMany']); + + $this->assertCount(2, $definitions['hasOne']); + + $this->assertEquals([ + 'phone' => [Phone::class], // Property-style + 'contactNumber' => [ // Laravel-style + Phone::class, + 'key' => 'author_id', + 'otherKey' => 'id', + 'delete' => false, + 'push' => true, + 'count' => false + ], + ], $definitions['hasOne']); + + $this->assertCount(3, $definitions['belongsToMany']); + + $this->assertEquals([ + 'roles' => [ // Property-style + 'Winter\Storm\Tests\Database\Fixtures\Role', + 'table' => 'database_tester_authors_roles' + ], + 'scopes' => [ // Laravel-style + Role::class, + 'table' => 'database_tester_authors_roles', + 'key' => 'author_id', + 'otherKey' => 'id', + 'push' => true, + 'detach' => true, + 'count' => false, + ], + 'executiveAuthors' => [ // Laravel-style + Role::class, + 'table' => 'database_tester_authors_roles', + 'key' => 'author_id', + 'otherKey' => 'id', + 'push' => true, + 'detach' => true, + 'count' => false, + ], + ], $definitions['belongsToMany']); + + $this->assertCount(2, $definitions['morphMany']); + + $this->assertEquals([ + 'event_log' => [EventLog::class, 'name' => 'related', 'delete' => true, 'softDelete' => true], // Property-style + 'auditLogs' => [ // Laravel-style + EventLog::class, + 'type' => 'related_type', + 'id' => 'related_id', + 'delete' => true, + 'push' => true, + 'count' => false, + ], + ], $definitions['morphMany']); + + $this->assertCount(2, $definitions['morphOne']); + + $this->assertEquals([ + 'meta' => [Meta::class, 'name' => 'taggable'], // Property-style + 'info' => [ // Laravel-style + Meta::class, + 'type' => 'taggable_type', + 'id' => 'taggable_id', + 'delete' => false, + 'push' => true, + 'count' => false, + ], + ], $definitions['morphOne']); + + $this->assertCount(2, $definitions['morphToMany']); + + $this->assertEquals([ + 'tags' => [ // Property-style + Tag::class, + 'name' => 'taggable', + 'table' => 'database_tester_taggables', + 'pivot' => ['added_by'] + ], + 'labels' => [ // Laravel-style + Tag::class, + 'table' => 'database_tester_taggables', + 'key' => 'taggable_id', + 'otherKey' => 'tag_id', + 'parentKey' => 'id', + 'relatedKey' => 'id', + 'inverse' => false, + 'push' => true, + 'count' => false, + 'pivot' => ['added_by'], + 'detach' => true, + ], + ], $definitions['morphToMany']); + } } diff --git a/tests/Database/Fixtures/Author.php b/tests/Database/Fixtures/Author.php index 039586a91..448921898 100644 --- a/tests/Database/Fixtures/Author.php +++ b/tests/Database/Fixtures/Author.php @@ -5,6 +5,7 @@ use Illuminate\Database\Schema\Builder; use Winter\Storm\Database\Attributes\Relation; use Winter\Storm\Database\Model; +use Winter\Storm\Database\Relations\BelongsToMany; use Winter\Storm\Database\Relations\HasMany; use Winter\Storm\Database\Relations\HasOne; use Winter\Storm\Database\Relations\MorphToMany; @@ -74,19 +75,19 @@ public function messages(): HasMany return $this->hasMany(Post::class); } - #[Relation] + #[Relation('belongsToMany')] public function scopes() { return $this->belongsToMany(Role::class, 'database_tester_authors_roles'); } - #[Relation] + #[Relation(BelongsToMany::class)] public function executiveAuthors() { return $this->belongsToMany(Role::class, 'database_tester_authors_roles')->wherePivot('is_executive', 1); } - #[Relation] + #[Relation('morphOne')] public function info() { return $this->morphOne(Meta::class, 'taggable'); @@ -97,7 +98,7 @@ public function labels(): MorphToMany return $this->morphToMany(Tag::class, 'taggable', 'database_tester_taggables')->withPivot('added_by'); } - #[Relation] + #[Relation('morphMany')] public function auditLogs() { return $this->morphMany(EventLog::class, 'related')->dependent(); From bbddb54082e1dd62cb4d873ba4237f8df869992f Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Tue, 8 Oct 2024 00:46:13 +0000 Subject: [PATCH 53/59] Stan fix --- src/Database/Concerns/HasRelationships.php | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/Database/Concerns/HasRelationships.php b/src/Database/Concerns/HasRelationships.php index c35a44bf1..daad39ddb 100644 --- a/src/Database/Concerns/HasRelationships.php +++ b/src/Database/Concerns/HasRelationships.php @@ -737,21 +737,23 @@ public function getRelationMethods(bool $ignoreResolved = false): array * * This is used to determine if a method is a relation method, first and foremost, and then to determine the type of the relation. */ - protected function getRelationMethodType(string $name): ?string + protected function getRelationMethodType(string $name, bool $ignoreResolved = false): ?string { if (!$this->methodExists($name)) { return null; } - if (isset(static::$resolvedRelationMethods[static::class][$name])) { - return static::$resolvedRelationMethods[static::class][$name]['type']; - } + if (!$ignoreResolved) { + if (isset(static::$resolvedRelationMethods[static::class][$name])) { + return static::$resolvedRelationMethods[static::class][$name]['type']; + } - if ( - isset(static::$resolvedNonRelationMethods[static::class]) - && in_array($name, static::$resolvedNonRelationMethods[static::class]) - ) { - return null; + if ( + isset(static::$resolvedNonRelationMethods[static::class]) + && in_array($name, static::$resolvedNonRelationMethods[static::class]) + ) { + return null; + } } // Directly defined relation methods From 9867c260fbdc905c7211310ac89cc911ee0a8419 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Tue, 8 Oct 2024 01:14:15 +0000 Subject: [PATCH 54/59] Allow relation flags to be toggled --- src/Database/Relations/Concerns/CanBeCounted.php | 8 +++++++- src/Database/Relations/Concerns/CanBeDependent.php | 4 ++-- src/Database/Relations/Concerns/CanBeDetachable.php | 4 ++-- src/Database/Relations/Concerns/CanBePushed.php | 6 +++--- src/Database/Relations/Concerns/CanBeSoftDeleted.php | 4 ++-- 5 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/Database/Relations/Concerns/CanBeCounted.php b/src/Database/Relations/Concerns/CanBeCounted.php index 46e1d5258..9839af316 100644 --- a/src/Database/Relations/Concerns/CanBeCounted.php +++ b/src/Database/Relations/Concerns/CanBeCounted.php @@ -47,8 +47,14 @@ trait CanBeCounted /** * Mark the relationship as a count-only relationship. */ - public function countOnly(): static + public function countOnly(bool $enabled = true): static { + if (!$enabled) { + $this->countOnly = false; + + return $this; + } + $this->countOnly = true; if ($this instanceof BelongsToMany) { diff --git a/src/Database/Relations/Concerns/CanBeDependent.php b/src/Database/Relations/Concerns/CanBeDependent.php index ce547093b..f8eec4595 100644 --- a/src/Database/Relations/Concerns/CanBeDependent.php +++ b/src/Database/Relations/Concerns/CanBeDependent.php @@ -46,9 +46,9 @@ trait CanBeDependent /** * Mark the relationship as dependent on the primary model. */ - public function dependent(): static + public function dependent(bool $enabled = true): static { - $this->dependent = true; + $this->dependent = $enabled; return $this; } diff --git a/src/Database/Relations/Concerns/CanBeDetachable.php b/src/Database/Relations/Concerns/CanBeDetachable.php index 2efb71039..126c9b156 100644 --- a/src/Database/Relations/Concerns/CanBeDetachable.php +++ b/src/Database/Relations/Concerns/CanBeDetachable.php @@ -44,9 +44,9 @@ trait CanBeDetachable /** * Allow this relationship to be detached when the primary model is deleted. */ - public function detachable(): static + public function detachable(bool $enabled = true): static { - $this->detachable = true; + $this->detachable = $enabled; return $this; } diff --git a/src/Database/Relations/Concerns/CanBePushed.php b/src/Database/Relations/Concerns/CanBePushed.php index 8d86d64be..a727cbd59 100644 --- a/src/Database/Relations/Concerns/CanBePushed.php +++ b/src/Database/Relations/Concerns/CanBePushed.php @@ -42,9 +42,9 @@ trait CanBePushed /** * Allow this relationship to be saved when the `push()` method is used on the primary model. */ - public function push(): static + public function pushable(bool $enabled = true): static { - $this->isPushable = true; + $this->isPushable = $enabled; return $this; } @@ -52,7 +52,7 @@ public function push(): static /** * Disallow this relationship from being saved when the `push()` method is used on the primary model. */ - public function noPush(): static + public function notPushable(): static { $this->isPushable = false; diff --git a/src/Database/Relations/Concerns/CanBeSoftDeleted.php b/src/Database/Relations/Concerns/CanBeSoftDeleted.php index e3513d093..75786504e 100644 --- a/src/Database/Relations/Concerns/CanBeSoftDeleted.php +++ b/src/Database/Relations/Concerns/CanBeSoftDeleted.php @@ -48,10 +48,10 @@ trait CanBeSoftDeleted /** * Mark the relationship as soft deletable. */ - public function softDeletable(): static + public function softDeletable(bool $enabled = true): static { if (in_array('Winter\Storm\Database\Traits\SoftDelete', class_uses_recursive($this->related))) { - $this->isSoftDeletable = true; + $this->isSoftDeletable = $enabled; } return $this; From 13e66cff9379669d072f36948ebed8e240b8fca4 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Tue, 8 Oct 2024 01:15:00 +0000 Subject: [PATCH 55/59] Minor --- src/Database/Concerns/HasRelationships.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Concerns/HasRelationships.php b/src/Database/Concerns/HasRelationships.php index daad39ddb..459bf9f9e 100644 --- a/src/Database/Concerns/HasRelationships.php +++ b/src/Database/Concerns/HasRelationships.php @@ -571,7 +571,7 @@ class_uses_recursive($relation) class_uses_recursive($relation) ) ) { - $relation = $relation->noPush(); + $relation = $relation->notPushable(); } // Add count only flag, if required From 55603fc00f5780bd7805e42bce4a209305ef9c02 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Tue, 8 Oct 2024 02:00:54 +0000 Subject: [PATCH 56/59] Detect relation conflicts When a relation is defined in both the relation properties and as a method with the same name, an exception will be thrown. --- src/Database/Concerns/HasRelationships.php | 28 ++++++++++ .../Fixtures/DuplicateRelationNote.php | 56 +++++++++++++++++++ .../Relations/DuplicateRelationTest.php | 51 +++++++++++++++++ .../Relations/DynamicRelationTest.php | 1 - 4 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 tests/Database/Fixtures/DuplicateRelationNote.php create mode 100644 tests/Database/Relations/DuplicateRelationTest.php diff --git a/src/Database/Concerns/HasRelationships.php b/src/Database/Concerns/HasRelationships.php index 459bf9f9e..d3400b5db 100644 --- a/src/Database/Concerns/HasRelationships.php +++ b/src/Database/Concerns/HasRelationships.php @@ -20,6 +20,7 @@ use Winter\Storm\Database\Relations\MorphOne; use Winter\Storm\Database\Relations\MorphTo; use Winter\Storm\Database\Relations\MorphToMany; +use Winter\Storm\Exception\ApplicationException; use Winter\Storm\Support\Arr; /** @@ -179,6 +180,8 @@ trait HasRelationships */ public function hasRelation(string $name, bool $propertyOnly = false): bool { + $this->detectRelationConflict($name); + if (!$propertyOnly && $this->isRelationMethod($name, true)) { return true; } @@ -218,6 +221,8 @@ public function getDefinedRelations(): array */ public function getRelationDefinition(string $name, bool $includeMethods = true): ?array { + $this->detectRelationConflict($name); + if ($includeMethods && $this->isRelationMethod($name)) { return $this->relationMethodDefinition($name); } @@ -357,6 +362,27 @@ protected function getRelationDefaults(string $type): array } } + /** + * Detects if the relation is specified both as a relation method and in the relation properties. + * + * If the relation is defined in both places, an exception will be thrown. + * + * @throws \Winter\Storm\Exception\ApplicationException + */ + protected function detectRelationConflict(string $name): void + { + if ( + $this->getRelationType($name, false) !== null + && $this->isRelationMethod($name) + ) { + throw new ApplicationException(sprintf( + 'Relation "%s" in model "%s" is defined both as a relation method and in the relation properties config.', + $name, + get_called_class() + )); + } + } + /** * Creates a Laravel relation object from a Winter relation definition array. * @@ -365,6 +391,8 @@ protected function getRelationDefaults(string $type): array */ protected function handleRelation(string $relationName, bool $addConstraints = true): EloquentRelation { + $this->detectRelationConflict($relationName); + $relationType = $this->getRelationType($relationName); $definition = $this->getRelationDefinition($relationName); $relatedClass = $definition[0] ?? null; diff --git a/tests/Database/Fixtures/DuplicateRelationNote.php b/tests/Database/Fixtures/DuplicateRelationNote.php new file mode 100644 index 000000000..2163469f7 --- /dev/null +++ b/tests/Database/Fixtures/DuplicateRelationNote.php @@ -0,0 +1,56 @@ + [ + Author::class, + ] + ]; + + public function author(): BelongsTo + { + return $this->belongsTo(Author::class); + } + + /** + * @var string The database table used by the model. + */ + public $table = 'database_tester_notes'; + + + public static function migrateUp(Builder $builder): void + { + if ($builder->hasTable('database_tester_notes')) { + return; + } + + $builder->create('database_tester_notes', function ($table) { + $table->engine = 'InnoDB'; + $table->increments('id'); + $table->integer('author_id')->nullable(); + $table->string('note')->nullable(); + $table->timestamps(); + }); + } + + public static function migrateDown(Builder $builder): void + { + if (!$builder->hasTable('database_tester_notes')) { + return; + } + + $builder->dropIfExists('database_tester_notes'); + } +} \ No newline at end of file diff --git a/tests/Database/Relations/DuplicateRelationTest.php b/tests/Database/Relations/DuplicateRelationTest.php new file mode 100644 index 000000000..fc784d92a --- /dev/null +++ b/tests/Database/Relations/DuplicateRelationTest.php @@ -0,0 +1,51 @@ + 'Stevie', 'email' => 'stevie@example.com']); + $note = DuplicateRelationNote::create(['note' => 'This is a note']); + Model::reguard(); + + $note->author()->associate($author); + $note->save(); + + $this->assertEquals($author->id, $note->author_id); + } + + public function testMethodPropertyWhenMethodRelationExists() + { + $this->expectException(ApplicationException::class); + $this->expectExceptionMessageMatches('/Relation "author" in model "' . preg_quote(DuplicateRelationNote::class) . '" is defined both/'); + + Model::unguard(); + $author = Author::create(['name' => 'Stevie', 'email' => 'stevie@example.com']); + $note = DuplicateRelationNote::create(['note' => 'This is a note']); + Model::reguard(); + + $note->author = $author; + $note->save(); + + $this->assertEquals($author->id, $note->author_id); + } + + public function testGetRelationDefinitionWhenBothExist() + { + $this->expectException(ApplicationException::class); + $this->expectExceptionMessageMatches('/Relation "author" in model "' . preg_quote(DuplicateRelationNote::class) . '" is defined both/'); + + $note = new DuplicateRelationNote; + $relation = $note->getRelationDefinition('author'); + } +} diff --git a/tests/Database/Relations/DynamicRelationTest.php b/tests/Database/Relations/DynamicRelationTest.php index 1750d5226..5989f3fbf 100644 --- a/tests/Database/Relations/DynamicRelationTest.php +++ b/tests/Database/Relations/DynamicRelationTest.php @@ -2,7 +2,6 @@ namespace Winter\Storm\Tests\Database\Relations; -use Winter\Storm\Database\Attributes\Relation; use Winter\Storm\Database\Model; use Winter\Storm\Database\Relations\BelongsTo; use Winter\Storm\Tests\Database\Fixtures\Author; From 0775c363104f4ced8ce7e4d0dcdf2b4286ede451 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Tue, 8 Oct 2024 02:03:43 +0000 Subject: [PATCH 57/59] Fix code smell --- tests/Database/Fixtures/DuplicateRelationNote.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Database/Fixtures/DuplicateRelationNote.php b/tests/Database/Fixtures/DuplicateRelationNote.php index 2163469f7..eda7a8522 100644 --- a/tests/Database/Fixtures/DuplicateRelationNote.php +++ b/tests/Database/Fixtures/DuplicateRelationNote.php @@ -53,4 +53,4 @@ public static function migrateDown(Builder $builder): void $builder->dropIfExists('database_tester_notes'); } -} \ No newline at end of file +} From 98a22476ffc2c1f6f75fb41536b7897952cb1ccb Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Tue, 8 Oct 2024 05:01:55 +0000 Subject: [PATCH 58/59] Use SystemException rather than ApplicationException --- src/Database/Concerns/HasRelationships.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Database/Concerns/HasRelationships.php b/src/Database/Concerns/HasRelationships.php index d3400b5db..60b1ee31e 100644 --- a/src/Database/Concerns/HasRelationships.php +++ b/src/Database/Concerns/HasRelationships.php @@ -20,7 +20,7 @@ use Winter\Storm\Database\Relations\MorphOne; use Winter\Storm\Database\Relations\MorphTo; use Winter\Storm\Database\Relations\MorphToMany; -use Winter\Storm\Exception\ApplicationException; +use Winter\Storm\Exception\SystemException; use Winter\Storm\Support\Arr; /** @@ -367,7 +367,7 @@ protected function getRelationDefaults(string $type): array * * If the relation is defined in both places, an exception will be thrown. * - * @throws \Winter\Storm\Exception\ApplicationException + * @throws \Winter\Storm\Exception\SystemException */ protected function detectRelationConflict(string $name): void { @@ -375,7 +375,7 @@ protected function detectRelationConflict(string $name): void $this->getRelationType($name, false) !== null && $this->isRelationMethod($name) ) { - throw new ApplicationException(sprintf( + throw new SystemException(sprintf( 'Relation "%s" in model "%s" is defined both as a relation method and in the relation properties config.', $name, get_called_class() From 7a875c8e48fa1adf1c130036b705ba1670b7fcfe Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Tue, 8 Oct 2024 05:09:04 +0000 Subject: [PATCH 59/59] Fix tests --- tests/Database/Relations/DuplicateRelationTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Database/Relations/DuplicateRelationTest.php b/tests/Database/Relations/DuplicateRelationTest.php index fc784d92a..106cddb55 100644 --- a/tests/Database/Relations/DuplicateRelationTest.php +++ b/tests/Database/Relations/DuplicateRelationTest.php @@ -3,7 +3,7 @@ namespace Winter\Storm\Tests\Database\Relations; use Winter\Storm\Database\Model; -use Winter\Storm\Exception\ApplicationException; +use Winter\Storm\Exception\SystemException; use Winter\Storm\Tests\Database\Fixtures\Author; use Winter\Storm\Tests\Database\Fixtures\DuplicateRelationNote; use Winter\Storm\Tests\DbTestCase; @@ -26,7 +26,7 @@ public function testMethodRelationWhenPropertyRelationExists() public function testMethodPropertyWhenMethodRelationExists() { - $this->expectException(ApplicationException::class); + $this->expectException(SystemException::class); $this->expectExceptionMessageMatches('/Relation "author" in model "' . preg_quote(DuplicateRelationNote::class) . '" is defined both/'); Model::unguard(); @@ -42,7 +42,7 @@ public function testMethodPropertyWhenMethodRelationExists() public function testGetRelationDefinitionWhenBothExist() { - $this->expectException(ApplicationException::class); + $this->expectException(SystemException::class); $this->expectExceptionMessageMatches('/Relation "author" in model "' . preg_quote(DuplicateRelationNote::class) . '" is defined both/'); $note = new DuplicateRelationNote;