diff --git a/composer.json b/composer.json index f0ed515..5fcf8cd 100644 --- a/composer.json +++ b/composer.json @@ -16,8 +16,10 @@ "illuminate/database": "^10" }, "require-dev": { - "phpunit/phpunit": "^10", "friendsofphp/php-cs-fixer": "^3", + "nunomaduro/larastan": "^2.0", + "orchestra/testbench": "^8.9", + "phpunit/phpunit": "^10", "squizlabs/php_codesniffer": "^3.5" }, "autoload": { @@ -31,10 +33,13 @@ } }, "scripts": { - "test": "vendor/bin/phpunit", - "test-coverage": "vendor/bin/phpunit --coverage-html coverage", - "fix": "./vendor/bin/php-cs-fixer fix", - "lint": "./vendor/bin/phpcs --error-severity=1 --warning-severity=8 --extensions=php" + "test": "@php vendor/bin/phpunit", + "test-coverage": "@php vendor/bin/phpunit --coverage-html coverage", + "fix": "@php ./vendor/bin/php-cs-fixer fix", + "lint": "@php ./vendor/bin/phpcs --error-severity=1 --warning-severity=8 --extensions=php", + "analyse": [ + "@php ./vendor/bin/phpstan analyse --memory-limit=2G" + ] }, "config": { "sort-packages": true diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..b81977c --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,11 @@ +includes: + - ./vendor/nunomaduro/larastan/extension.neon + +parameters: + + paths: + - src + - tests + + # Level 9 is the highest level + level: 5 diff --git a/src/HasManyMerged.php b/src/HasManyMerged.php index 8c7ede7..bf638f7 100644 --- a/src/HasManyMerged.php +++ b/src/HasManyMerged.php @@ -7,28 +7,17 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\Relation; -class HasManyMerged extends Relation +/** + * @template TRelatedModel of Model + * @extends HasOneOrManyMerged + */ +class HasManyMerged extends HasOneOrManyMerged { - /** - * The foreign keys of the parent model. - * - * @var string[] - */ - protected $foreignKeys; - - /** - * The local key of the parent model. - * - * @var string - */ - protected $localKey; - /** * Create a new has one or many relationship instance. * - * @param Builder $query + * @param Builder $query * @param Model $parent * @param array $foreignKeys * @param string $localKey @@ -42,28 +31,6 @@ public function __construct(Builder $query, Model $parent, array $foreignKeys, s parent::__construct($query, $parent); } - /** - * Set the base constraints on the relation query. - * Note: Used to load relations of one model. - * - * @return void - */ - public function addConstraints(): void - { - if (static::$constraints) { - $foreignKeys = $this->foreignKeys; - - $this->query->where(function ($query) use ($foreignKeys): void { - foreach ($foreignKeys as $foreignKey) { - $query->orWhere(function ($query) use ($foreignKey): void { - $query->where($foreignKey, '=', $this->getParentKey()) - ->whereNotNull($foreignKey); - }); - } - }); - } - } - /** * Get the key value of the parent's local key. * Info: From HasOneOrMany class. @@ -85,48 +52,6 @@ public function getQualifiedParentKeyName() return $this->parent->qualifyColumn($this->localKey); } - /** - * Set the constraints for an eager load of the relation. - * Note: Used to load relations of multiple models at once. - * - * @param array $models - */ - public function addEagerConstraints(array $models): void - { - $foreignKeys = $this->foreignKeys; - $orWhereIn = $this->orWhereInMethod($this->parent, $this->localKey); - - $this->query->where(function ($query) use ($foreignKeys, $models, $orWhereIn): void { - foreach ($foreignKeys as $foreignKey) { - $query->{$orWhereIn}($foreignKey, $this->getKeys($models, $this->localKey)); - } - }); - } - - /** - * Add the constraints for an internal relationship existence query. - * - * Essentially, these queries compare on column names like whereColumn. - * - * @param \Illuminate\Database\Eloquent\Builder $query - * @param \Illuminate\Database\Eloquent\Builder $parentQuery - * @param array|mixed $columns - * @return \Illuminate\Database\Eloquent\Builder - */ - public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) - { - $foreignKeys = $this->foreignKeys; - - return $query->select($columns)->where(function ($query) use ($foreignKeys): void { - foreach ($foreignKeys as $foreignKey) { - $query->orWhere(function ($query) use ($foreignKey): void { - $query->whereColumn($this->getQualifiedParentKeyName(), '=', $foreignKey) - ->whereNotNull($foreignKey); - }); - } - }); - } - /** * Get the name of the "where in" method for eager loading. * Note: Similar to whereInMethod of Relation class. @@ -188,74 +113,13 @@ public function match(array $models, Collection $results, $relation) return $models; } - /** - * Build model dictionary keyed by the relation's foreign key. - * Note: Custom code. - * - * @param Collection $results - * @return array - */ - protected function buildDictionary(Collection $results): array - { - $dictionary = []; - $foreignKeyNames = $this->getForeignKeyNames(); - - foreach ($results as $result) { - foreach ($foreignKeyNames as $foreignKeyName) { - $foreignKeyValue = $result->{$foreignKeyName}; - if (! isset($dictionary[$foreignKeyValue])) { - $dictionary[$foreignKeyValue] = []; - } - - $dictionary[$foreignKeyValue][] = $result; - } - } - - return $dictionary; - } - - /** - * Get the plain foreign key. - * - * @return string[] - */ - public function getForeignKeyNames(): array - { - return array_map(function (string $qualifiedForeignKeyName) { - $segments = explode('.', $qualifiedForeignKeyName); - - return end($segments); - }, $this->getQualifiedForeignKeyNames()); - } - - /** - * Get the foreign key for the relationship. - * - * @return string[] - */ - public function getQualifiedForeignKeyNames(): array - { - return $this->foreignKeys; - } - /** * Get the results of the relationship. * - * @return mixed + * @phpstan-return \Traversable */ public function getResults() { return $this->get(); } - - /** - * Execute the query as a "select" statement. - * - * @param array $columns - * @return \Illuminate\Database\Eloquent\Collection - */ - public function get($columns = ['*']) - { - return parent::get($columns); - } } diff --git a/src/HasOneMerged.php b/src/HasOneMerged.php new file mode 100644 index 0000000..80e19bb --- /dev/null +++ b/src/HasOneMerged.php @@ -0,0 +1,114 @@ + + */ +class HasOneMerged extends HasOneOrManyMerged +{ + /** + * Create a new has one or many relationship instance. + * + * @param Builder $query + * @param Model $parent + * @param array $foreignKeys + * @param string $localKey + * @return void + */ + public function __construct(Builder $query, Model $parent, array $foreignKeys, string $localKey) + { + $this->foreignKeys = $foreignKeys; + $this->localKey = $localKey; + + parent::__construct($query, $parent); + } + + /** + * Initialize the relation on a set of models. + * + * @param array $models + * @param string $relation + * @return array + */ + public function initRelation(array $models, $relation) + { + // TODO!!! + + // Info: From HasMany class + foreach ($models as $model) { + $model->setRelation($relation, $this->related->newCollection()); + } + + return $models; + } + + /** + * Match the eagerly loaded results to their parents. + * Info: From HasMany class. + * + * @param array $models + * @param Collection $results + * @param string $relation + * @return array + */ + public function match(array $models, Collection $results, $relation) + { + $dictionary = $this->buildDictionary($results); + + // Once we have the dictionary we can simply spin through the parent models to + // link them up with their children using the keyed dictionary to make the + // matching very convenient and easy work. Then we'll just return them. + foreach ($models as $model) { + if (isset($dictionary[$key = $model->getAttribute($this->localKey)])) { + $model->setRelation( + $relation, + reset($dictionary[$key]) + ); + } + } + + return $models; + } + + /** + * Get the plain foreign key. + * + * @return string[] + */ + public function getForeignKeyNames(): array + { + return array_map(function (string $qualifiedForeignKeyName) { + $segments = explode('.', $qualifiedForeignKeyName); + + return end($segments); + }, $this->getQualifiedForeignKeyNames()); + } + + /** + * Get the foreign key for the relationship. + * + * @return string[] + */ + public function getQualifiedForeignKeyNames(): array + { + return $this->foreignKeys; + } + + /** + * Get the results of the relationship. + * + * @phpstan-return ?TRelatedModel + */ + public function getResults() + { + return $this->first(); + } +} diff --git a/src/HasOneMergedRelation.php b/src/HasOneMergedRelation.php new file mode 100644 index 0000000..f87725b --- /dev/null +++ b/src/HasOneMergedRelation.php @@ -0,0 +1,34 @@ +getKeyName(); + + $foreignKeys = array_map(function ($foreignKey) use ($instance) { + return $instance->getTable() . '.' . $foreignKey; + }, $foreignKeys); + + return new HasOneMerged($instance->newQuery(), $this, $foreignKeys, $localKey); + } + + /** + * Get the primary key for the model. + * + * @return string + */ + abstract public function getKeyName(); +} diff --git a/src/HasOneOrManyMerged.php b/src/HasOneOrManyMerged.php new file mode 100644 index 0000000..c2d2c7d --- /dev/null +++ b/src/HasOneOrManyMerged.php @@ -0,0 +1,186 @@ + + */ +abstract class HasOneOrManyMerged extends Relation +{ + /** + * The foreign keys of the parent model. + * + * @var string[] + */ + protected $foreignKeys; + + /** + * The local key of the parent model. + * + * @var string + */ + protected $localKey; + + /** + * Set the base constraints on the relation query. + * Note: Used to load relations of one model. + * + * @return void + */ + public function addConstraints(): void + { + if (static::$constraints) { + $foreignKeys = $this->foreignKeys; + + $this->query->where(function ($query) use ($foreignKeys): void { + foreach ($foreignKeys as $foreignKey) { + $query->orWhere(function ($query) use ($foreignKey): void { + $query->where($foreignKey, '=', $this->getParentKey()) + ->whereNotNull($foreignKey); + }); + } + }); + } + } + + /** + * Set the constraints for an eager load of the relation. + * Note: Used to load relations of multiple models at once. + * + * @param array $models + */ + public function addEagerConstraints(array $models): void + { + $foreignKeys = $this->foreignKeys; + $orWhereIn = $this->orWhereInMethod($this->parent, $this->localKey); + + $this->query->where(function ($query) use ($foreignKeys, $models, $orWhereIn): void { + foreach ($foreignKeys as $foreignKey) { + $query->{$orWhereIn}($foreignKey, $this->getKeys($models, $this->localKey)); + } + }); + } + + /** + * Get the name of the "where in" method for eager loading. + * Note: Similar to whereInMethod of Relation class. + * + * @param Model $model + * @param string $key + * @return string + */ + protected function orWhereInMethod(Model $model, string $key): string + { + return $model->getKeyName() === last(explode('.', $key)) + && in_array($model->getKeyType(), ['int', 'integer']) + ? 'orWhereIntegerInRaw' + : 'orWhereIn'; + } + + /** + * Build model dictionary keyed by the relation's foreign key. + * Note: Custom code. + * + * @param Collection $results + * @return array + */ + protected function buildDictionary(Collection $results): array + { + $dictionary = []; + $foreignKeyNames = $this->getForeignKeyNames(); + + foreach ($results as $result) { + foreach ($foreignKeyNames as $foreignKeyName) { + $foreignKeyValue = $result->{$foreignKeyName}; + if (! isset($dictionary[$foreignKeyValue])) { + $dictionary[$foreignKeyValue] = []; + } + + $dictionary[$foreignKeyValue][] = $result; + } + } + + return $dictionary; + } + + /** + * Add the constraints for an internal relationship existence query. + * + * Essentially, these queries compare on column names like whereColumn. + * Note: Custom code. + * + * @param Builder $query + * @param Builder $parentQuery + * @param array|mixed $columns + * @return Builder + */ + public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) + { + $foreignKeys = $this->foreignKeys; + + return $query->select($columns)->where(function ($query) use ($foreignKeys): void { + foreach ($foreignKeys as $foreignKey) { + $query->orWhere(function ($query) use ($foreignKey): void { + $query->whereColumn($this->getQualifiedParentKeyName(), '=', $foreignKey) + ->whereNotNull($foreignKey); + }); + } + }); + } + + /** + * Get the key value of the parent's local key. + * Info: From HasOneOrMany class. + * + * @return mixed + */ + public function getParentKey() + { + return $this->parent->getAttribute($this->localKey); + } + + /** + * Get the fully qualified parent key name. + * Info: From HasOneOrMany class. + * + * @return string + */ + public function getQualifiedParentKeyName() + { + return $this->parent->qualifyColumn($this->localKey); + } + + /** + * Get the plain foreign key. + * Note: Custom code. + * + * @return string[] + */ + public function getForeignKeyNames(): array + { + return array_map(function (string $qualifiedForeignKeyName) { + $segments = explode('.', $qualifiedForeignKeyName); + + return end($segments); + }, $this->getQualifiedForeignKeyNames()); + } + + /** + * Get the foreign key for the relationship. + * Note: Custom code. + * + * @return string[] + */ + public function getQualifiedForeignKeyNames(): array + { + return $this->foreignKeys; + } +} diff --git a/tests/HasOneMergedTest.php b/tests/HasOneMergedTest.php new file mode 100644 index 0000000..c64fe47 --- /dev/null +++ b/tests/HasOneMergedTest.php @@ -0,0 +1,112 @@ +create([ + 'id' => 11, + 'other_unique_id' => 1, + 'name' => 'Tester 1', + ]); + User::query()->create([ + 'id' => 12, + 'other_unique_id' => 2, + 'name' => 'Tester 2', + ]); + + Message::query()->create([ + 'id' => 1, + 'content' => 'A - This is a message!', + 'sender_user_id' => 1, + 'receiver_user_id' => 1, + 'content_integer' => 1, + 'created_at' => Carbon::now()->subMinutes(4), + ]); + Message::query()->create([ + 'id' => 2, + 'content' => 'B - This is a message!', + 'sender_user_id' => 1, + 'receiver_user_id' => 2, + 'content_integer' => 1, + 'created_at' => Carbon::now()->subMinutes(3), + ]); + Message::query()->create([ + 'id' => 3, + 'content' => 'C - This is a message!', + 'sender_user_id' => 2, + 'receiver_user_id' => 1, + 'content_integer' => 1, + 'created_at' => Carbon::now()->subMinutes(2), + ]); + Message::query()->create([ + 'id' => 4, + 'content' => 'D - This is a message!', + 'sender_user_id' => 2, + 'receiver_user_id' => 2, + 'content_integer' => 2, + 'created_at' => Carbon::now()->subMinutes(1), + ]); + } + + public function testHasOneMergedWithTwoUsersWereBothAreSenderOrReceiverOfTheSameFourMessagesWithLazyLoading(): void + { + // Arrange + $this->createTwoUsersWereBothAreSenderOrReceiverOfTheSameFourMessages(); + + // Act + $this->db::connection()->enableQueryLog(); + $user1 = User::find(11); + $user2 = User::find(12); + $latestMessageUser1 = $user1->latestMessage; + $oldestMessageUser1 = $user1->oldestMessage; + $latestMessageUser2 = $user2->latestMessage; + $oldestMessageUser2 = $user2->oldestMessage; + $queries = $this->db::getQueryLog(); + $this->db::connection()->disableQueryLog(); + fwrite(STDERR, print_r($queries, true)); + + // Assert + $this->assertEquals(6, count($queries)); + $this->assertSame(3, $latestMessageUser1->id); + $this->assertSame(4, $latestMessageUser2->id); + $this->assertSame(1, $oldestMessageUser1->id); + $this->assertSame(2, $oldestMessageUser2->id); + } + + public function testHasOneMergedWithTwoUsersWereBothAreSenderOrReceiverOfTheSameFourMessagesWithEagerLoading(): void + { + // Arrange + $this->createTwoUsersWereBothAreSenderOrReceiverOfTheSameFourMessages(); + + // Act + $this->db::connection()->enableQueryLog(); + $users = User::with(['latestMessage', 'oldestMessage'])->get(); + $user1 = $users->firstWhere('id', 11); + $user2 = $users->firstWhere('id', 12); + $latestMessageUser1 = $user1->latestMessage; + $oldestMessageUser1 = $user1->oldestMessage; + $latestMessageUser2 = $user2->latestMessage; + $oldestMessageUser2 = $user2->oldestMessage; + $queries = $this->db::getQueryLog(); + $this->db::connection()->disableQueryLog(); + fwrite(STDERR, print_r($queries, true)); + + // Assert + $this->assertEquals(3, count($queries)); + $this->assertSame(3, $latestMessageUser1->id); + $this->assertSame(4, $latestMessageUser2->id); + $this->assertSame(1, $oldestMessageUser1->id); + $this->assertSame(2, $oldestMessageUser2->id); + } +} diff --git a/tests/Models/Message.php b/tests/Models/Message.php index 30618dd..f915da8 100644 --- a/tests/Models/Message.php +++ b/tests/Models/Message.php @@ -8,6 +8,9 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Korridor\LaravelHasManyMerged\HasManyMergedRelation; +/** + * @property int $id + */ class Message extends Model { use HasManyMergedRelation; @@ -23,10 +26,11 @@ class Message extends Model 'content_integer', 'sender_user_id', 'receiver_user_id', + 'created_at', ]; /** - * @return BelongsTo + * @return BelongsTo */ public function sender(): BelongsTo { @@ -34,7 +38,7 @@ public function sender(): BelongsTo } /** - * @return BelongsTo + * @return BelongsTo */ public function receiver(): BelongsTo { diff --git a/tests/Models/User.php b/tests/Models/User.php index e486606..d1fbcbe 100644 --- a/tests/Models/User.php +++ b/tests/Models/User.php @@ -8,10 +8,21 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Korridor\LaravelHasManyMerged\HasManyMerged; use Korridor\LaravelHasManyMerged\HasManyMergedRelation; +use Korridor\LaravelHasManyMerged\HasOneMerged; +use Korridor\LaravelHasManyMerged\HasOneMergedRelation; +/** + * @property int $id + * @property int $other_unique_id + * @property string $name + * @property int $messages_sum_content_integer + * @property ?Message $latestMessage + * @property ?Message $oldestMessage + */ class User extends Model { use HasManyMergedRelation; + use HasOneMergedRelation; /** * The primary key for the model. @@ -39,6 +50,24 @@ public function messages(): HasManyMerged return $this->hasManyMerged(Message::class, ['sender_user_id', 'receiver_user_id'], 'other_unique_id'); } + /** + * @return HasOneMerged + */ + public function latestMessage(): HasOneMerged + { + return $this->hasOneMerged(Message::class, ['sender_user_id', 'receiver_user_id'], 'other_unique_id') + ->latest(); + } + + /** + * @return HasOneMerged + */ + public function oldestMessage(): HasOneMerged + { + return $this->hasOneMerged(Message::class, ['sender_user_id', 'receiver_user_id'], 'other_unique_id') + ->oldest(); + } + /** * @return HasMany */