diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..de7a821 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,24 @@ +name: Lint +on: [ push, pull_request ] + +jobs: + lint: + name: PHP-${{ matrix.php_version }}-${{ matrix.perfer }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php_version: + - 7.4 + perfer: + - stable + container: + image: nauxliu/php-ci-image:${{ matrix.php_version }} + steps: + - uses: actions/checkout@master + - name: Install Dependencies + run: composer install --prefer-dist --no-interaction --no-suggest + - name: Check Style + run: composer check-style + - name: PHPStan analyse + run: composer phpstan \ No newline at end of file diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml new file mode 100644 index 0000000..8d1654a --- /dev/null +++ b/.github/workflows/php.yml @@ -0,0 +1,24 @@ +name: Test +on: [push, pull_request] + +jobs: + phpunit: + name: PHP-${{ matrix.php_version }}-${{ matrix.perfer }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php_version: + - 7.2 + - 7.3 + - 7.4 + perfer: + - stable + container: + image: nauxliu/php-ci-image:${{ matrix.php_version }} + steps: + - uses: actions/checkout@master + - name: Install Dependencies + run: composer install --prefer-dist --no-interaction --no-suggest + - name: Run PHPUnit + run: ./vendor/bin/phpunit diff --git a/.gitignore b/.gitignore index f7f520e..40086ad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ .idea /vendor/ composer.lock +.phpunit.result.cache +.php_cs.cache +.DS_Store diff --git a/.php_cs b/.php_cs new file mode 100644 index 0000000..dd00957 --- /dev/null +++ b/.php_cs @@ -0,0 +1,48 @@ +setRules([ + '@PSR2' => true, + 'binary_operator_spaces' => true, + 'blank_line_after_opening_tag' => true, + 'compact_nullable_typehint' => true, + 'declare_equal_normalize' => true, + 'lowercase_cast' => true, + 'lowercase_static_reference' => true, + 'new_with_braces' => true, + 'no_blank_lines_after_class_opening' => true, + 'no_leading_import_slash' => true, + 'no_whitespace_in_blank_line' => true, + 'ordered_class_elements' => [ + 'order' => [ + 'use_trait', + ], + ], + 'ordered_imports' => [ + 'imports_order' => [ + 'class', + 'function', + 'const', + ], + 'sort_algorithm' => 'none', + ], + 'return_type_declaration' => true, + 'short_scalar_cast' => true, + 'single_blank_line_before_namespace' => true, + 'single_trait_insert_per_statement' => true, + 'ternary_operator_spaces' => true, + 'unary_operator_spaces' => true, + 'visibility_required' => [ + 'elements' => [ + 'const', + 'method', + 'property', + ], + ], + ]) + ->setFinder( + PhpCsFixer\Finder::create() + ->exclude('vendor') + ->in([__DIR__.'/src/', __DIR__.'/tests/', __DIR__.'/config/', __DIR__.'/migrations/']) + ) +; \ No newline at end of file diff --git a/README.md b/README.md index 6f75b55..090a9fe 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,13 @@ -# Laravel 5 Vote System +# Laravel Vote System :tada: This package helps you to add user based vote system to your model. -> This project code is basically the same as [laravel-follow](https://github.com/overtrue/laravel-follow). - ## Installation You can install the package using Composer: ```sh -$ composer require jcc/laravel-vote -vvv +$ composer require "jcc/laravel-vote:~2.0" ``` Then add the service provider to `config/app.php`: @@ -27,24 +25,22 @@ $ php artisan vendor:publish --provider="Jcc\LaravelVote\VoteServiceProvider" -- Finally, use VoteTrait in User model: ```php -use Jcc\LaravelVote\Vote; +use Jcc\LaravelVote\Traits\Voter; class User extends Model { - use Vote; + use Voter; } ``` Or use CanBeVoted in Comment model: ```php -use Jcc\LaravelVote\CanBeVoted; +use Jcc\LaravelVote\Traits\Votable; class Comment extends Model { - use CanBeVoted; - - protected $vote = User::class; + use Votable; } ``` @@ -79,12 +75,12 @@ $user->cancelVote($comment); #### Get user has voted comment items ```php -$user->votedItems(Comment::class)->get(); +$user->getVotedItems(Comment::class)->get(); ``` #### Check if user has up or down vote -``` +```php $comment = Comment::find(1); $user->hasVoted($comment); @@ -92,7 +88,7 @@ $user->hasVoted($comment); #### Check if user has up vote -``` +```php $comment = Comment::find(1); $user->hasUpVoted($comment); @@ -100,7 +96,7 @@ $user->hasUpVoted($comment); #### Check if user has down vote -``` +```php $comment = Comment::find(1); $user->hasDownVoted($comment); @@ -111,37 +107,96 @@ $user->hasDownVoted($comment); #### Get comment voters ```php -$comment->voters(); +$comment->voters()->get(); ``` #### Count comment voters ```php -$comment->countVoters(); +$comment->voters()->count(); +``` + +#### Get comment up voters + +```php +$comment->upVoters()->get(); ``` #### Count comment up voters ```php -$comment->countUpVoters(); +$comment->upVoters()->count(); +``` + +#### Get comment down voters + +```php +$comment->downVoters()->get(); ``` #### Count comment down voters ```php -$comment->countDownVoters(); +$comment->downVoters()->count(); ``` #### Check if voted by ```php -$comment->isVotedBy(1); +$user = User::find(1); + +$comment->isVotedBy($user); +``` + +#### Check if up voted by + +```php +$user = User::find(1); + +$comment->isUpVotedBy($user); +``` + +#### Check if down voted by + +```php +$user = User::find(1); + +$comment->isDownVotedBy($user); +``` + +### N+1 issue + +To avoid the N+1 issue, you can use eager loading to reduce this operation to just 2 queries. When querying, you may specify which relationships should be eager loaded using the `with` method: + +```php +// Voter +$users = User::with('votes')->get(); + +foreach($users as $user) { + $user->hasVoted($comment); +} + +// Votable +$comments = Comment::with('voters')->get(); + +foreach($comments as $comment) { + $comment->isVotedBy($user); +} ``` +### Events + +| **Event** | **Description** | +| --- | --- | +| `Jcc\LaravelVote\Events\Voted` | Triggered when the relationship is created or updated. | +| `Jcc\LaravelVote\Events\CancelVoted` | Triggered when the relationship is deleted. | + + ## Reference -[laravel-follow](https://github.com/overtrue/laravel-follow) +- [laravel-follow](https://github.com/overtrue/laravel-follow) +- [laravel-like](https://github.com/overtrue/laravel-like) ## License -MIT +[MIT](LICENSE) diff --git a/composer.json b/composer.json index 09a206c..2c5e14b 100644 --- a/composer.json +++ b/composer.json @@ -8,10 +8,15 @@ } ], "require": { - "php": ">=5.5.9" + "php": ">=7.2", + "laravel/framework": "^5.5|~6.0|~7.0|~8.0", + "symfony/polyfill-php80": "^1.22" }, "require-dev": { - "phpunit/phpunit": "~5.0" + "mockery/mockery": "^1.3", + "orchestra/testbench": "^3.5|~4.0|~5.0|~6.0", + "friendsofphp/php-cs-fixer": "^2.18", + "phpstan/phpstan": "^0.12.81" }, "license": "MIT", "autoload": { @@ -31,6 +36,12 @@ ] } }, - "minimum-stability": "dev", - "prefer-stable": true + "minimum-stability": "stable", + "prefer-stable": true, + "scripts": { + "check-style": "vendor/bin/php-cs-fixer fix --using-cache=no --diff --config=.php_cs --dry-run --ansi", + "fix-style": "vendor/bin/php-cs-fixer fix --using-cache=no --config=.php_cs --ansi", + "test": "vendor/bin/phpunit --colors=always", + "phpstan": "vendor/bin/phpstan analyse src -l 5" + } } diff --git a/config/vote.php b/config/vote.php new file mode 100644 index 0000000..a37ca87 --- /dev/null +++ b/config/vote.php @@ -0,0 +1,10 @@ + 'votes', + + 'user_foreign_key' => 'user_id', + + 'vote_model' => \Jcc\LaravelVote\Vote::class, +]; diff --git a/database/migrations/create_votes_table.php b/database/migrations/create_votes_table.php deleted file mode 100644 index aa49138..0000000 --- a/database/migrations/create_votes_table.php +++ /dev/null @@ -1,34 +0,0 @@ -unsignedInteger('user_id'); - $table->unsignedInteger('votable_id'); - $table->string('votable_type')->index(); - $table->enum('type', ['up_vote', 'down_vote'])->default('up_vote'); - - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::drop('votes'); - } -} \ No newline at end of file diff --git a/migrations/2021_03_13_000000_create_votes_table.php b/migrations/2021_03_13_000000_create_votes_table.php new file mode 100644 index 0000000..174e8ce --- /dev/null +++ b/migrations/2021_03_13_000000_create_votes_table.php @@ -0,0 +1,30 @@ +increments('id'); + $table->unsignedBigInteger(config('vote.user_foreign_key'))->index(); + $table->morphs('votable'); + $table->string('vote_type', 16)->default('up_vote'); // 'up_vote'/'down_vote' + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down() + { + Schema::dropIfExists(config('vote.votes_table')); + } +} diff --git a/phpunit.xml b/phpunit.xml index 5fb5529..ce90235 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -7,8 +7,7 @@ convertNoticesToExceptions="true" convertWarningsToExceptions="true" processIsolation="false" - stopOnFailure="false" - syntaxCheck="false"> + stopOnFailure="false"> ./tests/ diff --git a/src/CanBeVoted.php b/src/CanBeVoted.php deleted file mode 100644 index 8292a3d..0000000 --- a/src/CanBeVoted.php +++ /dev/null @@ -1,87 +0,0 @@ - - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Jcc\LaravelVote; - -trait CanBeVoted -{ - /** - * Check if user is voted by given user. - * - * @param $user - * - * @return bool - */ - public function isVotedBy($user) - { - return $this->voters->contains($user); - } - - /** - * Return the total vote score - * - * @return int - */ - public function countTotalVotes() - { - $downVote = $this->countVoters('down_vote'); - $upVotes = $this->countVoters('up_vote'); - return $upVotes - $downVote; - } - - /** - * Count the number of up votes. - * - * @return int - */ - public function countUpVoters() - { - return $this->countVoters('up_vote'); - } - - /** - * Count the number of down votes. - * - * @return int - */ - public function countDownVoters() - { - return $this->countVoters('down_vote'); - } - - /** - * Count the number of voters. - * - * @param string $type - * - * @return int - */ - public function countVoters($type = null) - { - $voters = $this->voters(); - - if(!is_null($type)) $voters->wherePivot('type', $type); - - return $voters->count(); - } - - /** - * Return voters. - * - * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany - */ - public function voters() - { - $property = property_exists($this, 'vote') ? $this->vote : __CLASS__; - - return $this->morphToMany($property, 'votable', $this->vote_table ?: 'votes'); - } -} diff --git a/src/Events/CancelVoted.php b/src/Events/CancelVoted.php new file mode 100644 index 0000000..f23109e --- /dev/null +++ b/src/Events/CancelVoted.php @@ -0,0 +1,7 @@ +vote = $vote; + } +} diff --git a/src/Events/Voted.php b/src/Events/Voted.php new file mode 100644 index 0000000..8d82604 --- /dev/null +++ b/src/Events/Voted.php @@ -0,0 +1,7 @@ +relationLoaded('voters')) { + return $this->voters->contains($user); + } + + return $this->voters() + ->where(\config('vote.user_foreign_key'), $user->getKey()) + ->when(\is_string($type), function ($builder) use ($type) { + $builder->where('vote_type', (string)new VoteItems($type)); + }) + ->exists(); + } + + return false; + } + + /** + * Return voters. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + */ + public function voters(): \Illuminate\Database\Eloquent\Relations\BelongsToMany + { + return $this->belongsToMany( + \config('auth.providers.users.model'), + \config('vote.votes_table'), + 'votable_id', + \config('vote.user_foreign_key') + ) + ->where('votable_type', $this->getMorphClass()); + } + + /** + * @param \Illuminate\Database\Eloquent\Model $user + * + * @return bool + */ + public function isUpVotedBy(Model $user) + { + return $this->isVotedBy($user, VoteItems::UP); + } + + /** + * Return up voters. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + */ + public function upVoters(): \Illuminate\Database\Eloquent\Relations\BelongsToMany + { + return $this->voters()->where('vote_type', VoteItems::UP); + } + + /** + * @param \Illuminate\Database\Eloquent\Model $user + * + * @return bool + */ + public function isDownVotedBy(Model $user) + { + return $this->isVotedBy($user, VoteItems::DOWN); + } + + /** + * Return down voters. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + */ + public function downVoters(): \Illuminate\Database\Eloquent\Relations\BelongsToMany + { + return $this->voters()->where('vote_type', VoteItems::DOWN); + } +} diff --git a/src/Traits/Voter.php b/src/Traits/Voter.php new file mode 100644 index 0000000..aaec2b6 --- /dev/null +++ b/src/Traits/Voter.php @@ -0,0 +1,162 @@ + $object->getMorphClass(), + 'votable_id' => $object->getKey(), + \config('vote.user_foreign_key') => $this->getKey(), + ]; + + /* @var \Illuminate\Database\Eloquent\Model $vote */ + $vote = \app(\config('vote.vote_model')); + + $type = (string)new VoteItems($type); + + /* @var \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Builder $vote */ + return tap($vote->where($attributes)->firstOr( + function () use ($vote, $attributes, $type) { + $vote->unguard(); + + if ($this->relationLoaded('votes')) { + $this->unsetRelation('votes'); + } + + return $vote->create(\array_merge($attributes, [ + 'vote_type' => $type, + ])); + } + ), function (Model $model) use ($type) { + $model->update(['vote_type' => $type]); + }); + } + + /** + * @param \Illuminate\Database\Eloquent\Model $object + * + * @return bool + */ + public function hasVoted(Model $object, ?string $type = null): bool + { + return ($this->relationLoaded('votes') ? $this->votes : $this->votes()) + ->where('votable_id', $object->getKey()) + ->where('votable_type', $object->getMorphClass()) + ->when(\is_string($type), function ($builder) use ($type) { + $builder->where('vote_type', (string)new VoteItems($type)); + }) + ->count() > 0; + } + + /** + * @param Model $object + * @return bool + * @throws \Exception + */ + public function cancelVote(Model $object): bool + { + /* @var \Jcc\LaravelVote\Vote $relation */ + $relation = \app(\config('vote.vote_model')) + ->where('votable_id', $object->getKey()) + ->where('votable_type', $object->getMorphClass()) + ->where(\config('vote.user_foreign_key'), $this->getKey()) + ->first(); + + if ($relation) { + if ($this->relationLoaded('votes')) { + $this->unsetRelation('votes'); + } + + return $relation->delete(); + } + + return true; + } + + /** + * @return HasMany + */ + public function votes(): HasMany + { + return $this->hasMany(\config('vote.vote_model'), \config('vote.user_foreign_key'), $this->getKeyName()); + } + + /** + * Get Query Builder for votes + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function getVotedItems(string $model, ?string $type = null) + { + return \app($model)->whereHas( + 'voters', + function ($builder) use ($type) { + return $builder->where(\config('vote.user_foreign_key'), $this->getKey())->when( + \is_string($type), + function ($builder) use ($type) { + $builder->where('vote_type', (string)new VoteItems($type)); + } + ); + } + ); + } + + public function upVote(Model $object): Vote + { + return $this->vote($object, VoteItems::UP); + } + + public function downVote(Model $object): Vote + { + return $this->vote($object, VoteItems::DOWN); + } + + public function hasUpVoted(Model $object) + { + return $this->hasVoted($object, VoteItems::UP); + } + + public function hasDownVoted(Model $object) + { + return $this->hasVoted($object, VoteItems::DOWN); + } + + public function toggleUpVote(Model $object) + { + return $this->hasUpVoted($object) ? $this->cancelVote($object) : $this->upVote($object); + } + + public function toggleDownVote(Model $object) + { + return $this->hasDownVoted($object) ? $this->cancelVote($object) : $this->downVote($object); + } + + public function getUpVotedItems(string $model) + { + return $this->getVotedItems($model, VoteItems::UP); + } + + public function getDownVotedItems(string $model) + { + return $this->getVotedItems($model, VoteItems::DOWN); + } +} diff --git a/src/Vote.php b/src/Vote.php index a154a4e..0dc5375 100644 --- a/src/Vote.php +++ b/src/Vote.php @@ -1,159 +1,101 @@ - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - namespace Jcc\LaravelVote; -trait Vote +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\MorphTo; +use Illuminate\Support\Facades\Auth; +use Jcc\LaravelVote\Events\CancelVoted; +use Jcc\LaravelVote\Events\Voted; + +/** + * Class Vote + * + * @property string $vote_type + * @property string $votable_type + */ +class Vote extends Model { - protected $voteRelation = __CLASS__; + protected $guarded = []; - /** - * Up vote a item or items. - * - * @param int|array|\Illuminate\Database\Eloquent\Model $item - * - * @return boolean - */ - public function upVote($item) - { - $this->cancelVote($item); + protected $dispatchesEvents = [ + 'created' => Voted::class, + 'updated' => Voted::class, - return $this->vote($item); - } + 'deleted' => CancelVoted::class, + ]; /** - * Down vote a item or items. - * - * @param int|array|\Illuminate\Database\Eloquent\Model $item - * - * @return boolean + * @param array $attributes */ - public function downVote($item) + public function __construct(array $attributes = []) { - $this->cancelVote($item); + $this->table = \config('vote.votes_table'); - return $this->vote($item, 'down_vote'); + parent::__construct($attributes); } - /** - * Vote a item or items. - * - * @param int|array|\Illuminate\Database\Eloquent\Model $item - * @param string $type - * @return boolean - */ - public function vote($item, $type = 'up_vote') + protected static function boot() { - $items = array_fill_keys((array) $this->checkVoteItem($item), ['type' => $type]); + parent::boot(); - return $this->votedItems()->sync($items, false); + self::creating(function (Vote $vote) { + $userForeignKey = \config('vote.user_foreign_key'); + $vote->{$userForeignKey} = $vote->{$userForeignKey} ?: Auth::id(); + }); } - /** - * Cancel vote a item or items. - * - * @param int|array|\Illuminate\Database\Eloquent\Model $item - * - * @return int - */ - public function cancelVote($item) + public function votable(): MorphTo { - $item = $this->checkVoteItem($item); - - return $this->votedItems()->detach((array)$item); + return $this->morphTo(); } /** - * Determine whether the type of 'up_vote' item exist - * - * @param $item - * - * @return boolean + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ - public function hasUpVoted($item) + public function user() { - return $this->hasVoted($item, 'up_vote'); + return $this->belongsTo(\config('auth.providers.users.model'), \config('vote.user_foreign_key')); } /** - * Determine whether the type of 'down_vote' item exist - * - * @param $item - * - * @return boolean + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ - public function hasDownVoted($item) + public function voter() { - return $this->hasVoted($item, 'down_vote'); + return $this->user(); } /** - * Check if user has voted item. - * - * @param $item - * @param string $type + * @param \Illuminate\Database\Eloquent\Builder $query + * @param string $type * - * @return bool + * @return \Illuminate\Database\Eloquent\Builder */ - public function hasVoted($item, $type = null) + public function scopeWithVotableType(Builder $query, string $type) { - $item = $this->checkVoteItem($item); - - $votedItems = $this->votedItems(); - - if(!is_null($type)) $votedItems->wherePivot('type', $type); - - return $votedItems->get()->contains($item); + return $query->where('votable_type', \app($type)->getMorphClass()); } /** - * Return the user what has items. + * @param \Illuminate\Database\Eloquent\Builder $query + * @param string $type * - * @param string $class - * - * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + * @return \Illuminate\Database\Eloquent\Builder */ - public function votedItems($class = null) + public function scopeWithVoteType(Builder $query, string $type) { - if (!empty($class)) { - $this->setVoteRelation($class); - } - - return $this->morphedByMany($this->voteRelation, 'votable', $this->vote_table ?: 'votes')->withTimestamps(); + return $query->where('vote_type', (string)new VoteItems($type)); } - /** - * Determine whether $item is an instantiated object of \Illuminate\Database\Eloquent\Model - * - * @param $item - * - * @return int - */ - protected function checkVoteItem($item) + public function isUp(): bool { - if ($item instanceof \Illuminate\Database\Eloquent\Model) { - $this->setVoteRelation(get_class($item)); - return $item->id; - }; - - return $item; + return $this->vote_type === VoteItems::UP; } - /** - * Set the vote relation class. - * - * @param string $class - */ - protected function setVoteRelation($class) + public function isDown(): bool { - return $this->voteRelation = $class; + return $this->vote_type === VoteItems::DOWN; } -} \ No newline at end of file +} diff --git a/src/VoteItems.php b/src/VoteItems.php new file mode 100644 index 0000000..d498ad0 --- /dev/null +++ b/src/VoteItems.php @@ -0,0 +1,42 @@ +value = $value; + } + + /** + * @return string[] + */ + public static function getValues(): array + { + return [self::UP, self::DOWN]; + } + + /** + * @return string + */ + public function __toString() + { + return $this->value; + } +} diff --git a/src/VoteServiceProvider.php b/src/VoteServiceProvider.php index b4ab598..fc46b63 100644 --- a/src/VoteServiceProvider.php +++ b/src/VoteServiceProvider.php @@ -21,8 +21,16 @@ class VoteServiceProvider extends ServiceProvider public function boot() { $this->publishes([ - __DIR__.'/../database/migrations/create_votes_table.php' => database_path('migrations/'.date('Y_m_d_His').'_create_votes_table.php'), + \dirname(__DIR__) . '/config/vote.php' => config_path('vote.php'), + ], 'config'); + + $this->publishes([ + \dirname(__DIR__) . '/migrations/' => database_path('migrations'), ], 'migrations'); + + if ($this->app->runningInConsole()) { + $this->loadMigrationsFrom(\dirname(__DIR__) . '/migrations/'); + } } /** @@ -30,6 +38,9 @@ public function boot() */ public function register() { - // + $this->mergeConfigFrom( + \dirname(__DIR__) . '/config/vote.php', + 'vote' + ); } -} \ No newline at end of file +} diff --git a/tests/Book.php b/tests/Book.php new file mode 100644 index 0000000..e14d1d7 --- /dev/null +++ b/tests/Book.php @@ -0,0 +1,13 @@ + User::class]); + } + + public function test_voteItems() + { + self::assertEquals('up_vote', (string)(new VoteItems('up_vote'))); + self::assertEquals('down_vote', (string)(new VoteItems('down_vote'))); + + $invalidValue = 'foobar'; + $this->expectException(UnexpectValueException::class); + $this->expectDeprecationMessage("Unexpect Value: {$invalidValue}"); + new VoteItems($invalidValue); + } + + public function test_basic_features() + { + /** @var User $user */ + $user = User::create(['name' => 'jcc']); + /** @var Post $post */ + $post = Post::create(['title' => 'Hello world!']); + + $user->vote($post, VoteItems::UP); + + Event::assertDispatched(Voted::class, function ($event) use ($user, $post) { + $vote = $event->vote; + self::assertTrue($vote->isUp()); + self::assertFalse($vote->isDown()); + return $vote->votable instanceof Post + && $vote->user instanceof User + && $vote->user->id === $user->id + && $vote->votable->id === $post->id; + }); + + self::assertTrue($user->hasVoted($post)); + self::assertTrue($user->hasUpVoted($post)); + self::assertFalse($user->hasDownVoted($post)); + + self::assertTrue($post->isVotedBy($user)); + self::assertTrue($post->isUpVotedBy($user)); + self::assertFalse($post->isDownVotedBy($user)); + + self::assertTrue($user->cancelVote($post)); + + Event::fake(); + + $user->vote($post, VoteItems::DOWN); + Event::assertDispatched(Voted::class, function ($event) use ($user, $post) { + $vote = $event->vote; + self::assertFalse($vote->isUp()); + self::assertTrue($vote->isDown()); + return $vote->votable instanceof Post + && $vote->user instanceof User + && $vote->user->id === $user->id + && $vote->votable->id === $post->id; + }); + + self::assertTrue($user->hasVoted($post)); + self::assertFalse($user->hasUpVoted($post)); + self::assertTrue($user->hasDownVoted($post)); + + self::assertTrue($post->isVotedBy($user)); + self::assertFalse($post->isUpVotedBy($user)); + self::assertTrue($post->isDownVotedBy($user)); + + /** @var User $user */ + $user = User::create(['name' => 'jcc']); + /** @var Post $post */ + $post = Post::create(['title' => 'Hello world!']); + $user->vote($post, VoteItems::UP); + Event::fake(); + $user->vote($post, VoteItems::DOWN); + Event::assertDispatched(Voted::class, function ($event) use ($user, $post) { + $vote = $event->vote; + self::assertFalse($vote->isUp()); + self::assertTrue($vote->isDown()); + return $vote->votable instanceof Post + && $vote->user instanceof User + && $vote->user->id === $user->id + && $vote->votable->id === $post->id; + }); + } + + public function test_cancelVote_features() + { + $user1 = User::create(['name' => 'jcc']); + $user2 = User::create(['name' => 'allen']); + + $post = Post::create(['title' => 'Hello world!']); + + $user1->vote($post, VoteItems::DOWN); + $user2->vote($post, VoteItems::UP); + + $user1->cancelVote($post); + $user2->cancelVote($post); + + self::assertFalse($user1->hasVoted($post)); + self::assertFalse($user1->hasDownVoted($post)); + self::assertFalse($user1->hasUpVoted($post)); + + self::assertFalse($user1->hasVoted($post)); + self::assertFalse($user2->hasUpVoted($post)); + self::assertFalse($user2->hasDownVoted($post)); + } + + public function test_upVoted_to_downVoted_each_other_features() + { + $user1 = User::create(['name' => 'jcc']); + $user2 = User::create(['name' => 'allen']); + $post = Post::create(['title' => 'Hello world!']); + + $upModel = $user1->vote($post, VoteItems::UP); + self::assertTrue($user1->hasUpVoted($post)); + self::assertFalse($user1->hasDownVoted($post)); + + $downModel = $user1->vote($post, VoteItems::DOWN); + self::assertFalse($user1->hasUpVoted($post)); + self::assertTrue($user1->hasDownVoted($post)); + self::assertTrue($user1->hasDownVoted($post)); + self::assertEquals($upModel->id, $downModel->id); + + $downModel = $user2->vote($post, VoteItems::DOWN); + self::assertFalse($user2->hasUpVoted($post)); + self::assertTrue($user2->hasDownVoted($post)); + + $upModel = $user2->vote($post, VoteItems::UP); + self::assertTrue($user2->hasUpVoted($post)); + self::assertFalse($user2->hasDownVoted($post)); + self::assertEquals($upModel->id, $downModel->id); + } + + public function test_aggregations() + { + $user = User::create(['name' => 'jcc']); + + $post1 = Post::create(['title' => 'Hello world!']); + $post2 = Post::create(['title' => 'Hello everyone!']); + $post3 = Post::create(['title' => 'Hello players!']); + $book1 = Book::create(['title' => 'Learn laravel.']); + $book2 = Book::create(['title' => 'Learn symfony.']); + $book3 = Book::create(['title' => 'Learn yii2.']); + + $user->vote($post1, VoteItems::UP); + $user->vote($post2, VoteItems::UP); + $user->vote($post3, VoteItems::DOWN); + + $user->vote($book1, VoteItems::UP); + $user->vote($book2, VoteItems::UP); + $user->vote($book3, VoteItems::DOWN); + + self::assertSame(6, $user->votes()->count()); + self::assertSame(4, $user->votes()->withVoteType(VoteItems::UP)->count()); + self::assertSame(2, $user->votes()->withVoteType(VoteItems::DOWN)->count()); + + self::assertSame(3, $user->votes()->withVotableType(Book::class)->count()); + self::assertSame(1, $user->votes()->withVoteType(VoteItems::DOWN)->withVotableType(Book::class)->count()); + } + + public function test_vote_same_model() + { + $user1 = User::create(['name' => 'jcc']); + $user2 = User::create(['name' => 'allen']); + $user3 = User::create(['name' => 'taylor']); + + $user1->vote($user2, VoteItems::UP); + $user3->vote($user1, VoteItems::DOWN); + + self::assertTrue($user1->hasVoted($user2)); + self::assertTrue($user2->isVotedBy($user1)); + + self::assertTrue($user1->hasUpVoted($user2)); + self::assertTrue($user2->isUpVotedBy($user1)); + + self::assertTrue($user3->hasVoted($user1)); + self::assertTrue($user1->isVotedBy($user3)); + + self::assertTrue($user3->hasDownVoted($user1)); + self::assertTrue($user1->isDownVotedBy($user3)); + } + + public function test_object_voters() + { + $user1 = User::create(['name' => 'jcc']); + $user2 = User::create(['name' => 'allen']); + $user3 = User::create(['name' => 'taylor']); + + $post = Post::create(['title' => 'Hello world!']); + + $user1->vote($post, VoteItems::UP); + $user2->vote($post, VoteItems::DOWN); + + self::assertCount(2, $post->voters); + self::assertSame('jcc', $post->voters[0]['name']); + self::assertSame('allen', $post->voters[1]['name']); + + $sqls = $this->getQueryLog(function () use ($post, $user1, $user2, $user3) { + self::assertTrue($post->isVotedBy($user1)); + self::assertTrue($post->isVotedBy($user2)); + self::assertFalse($post->isVotedBy($user3)); + }); + + self::assertEmpty($sqls->all()); + } + + public function test_object_votes_with_custom_morph_class_name() + { + $user1 = User::create(['name' => 'jcc']); + $user2 = User::create(['name' => 'allen']); + $user3 = User::create(['name' => 'taylor']); + + $post = Post::create(['title' => 'Hello world!']); + + Relation::morphMap([ + 'posts' => Post::class, + ]); + + $user1->vote($post, VoteItems::UP); + $user2->vote($post, VoteItems::DOWN); + + self::assertCount(2, $post->voters); + self::assertSame('jcc', $post->voters[0]['name']); + self::assertSame('allen', $post->voters[1]['name']); + } + + public function test_eager_loading() + { + $user = User::create(['name' => 'jcc']); + + $post1 = Post::create(['title' => 'Hello world!']); + $post2 = Post::create(['title' => 'Hello everyone!']); + $book1 = Book::create(['title' => 'Learn laravel.']); + $book2 = Book::create(['title' => 'Learn symfony.']); + + $user->vote($post1, VoteItems::UP); + $user->vote($post2, VoteItems::DOWN); + $user->vote($book1, VoteItems::UP); + $user->vote($book2, VoteItems::DOWN); + + // start recording + $sqls = $this->getQueryLog(function () use ($user) { + $user->load('votes.votable'); + }); + + self::assertSame(3, $sqls->count()); + + // from loaded relations + $sqls = $this->getQueryLog(function () use ($user, $post1) { + $user->hasVoted($post1); + }); + + self::assertEmpty($sqls->all()); + } + + /** + * @param \Closure $callback + * + * @return \Illuminate\Support\Collection + */ + protected function getQueryLog(\Closure $callback): \Illuminate\Support\Collection + { + $sqls = \collect([]); + DB::listen(function ($query) use ($sqls) { + $sqls->push(['sql' => $query->sql, 'bindings' => $query->bindings]); + }); + + $callback(); + + return $sqls; + } +} diff --git a/tests/Post.php b/tests/Post.php new file mode 100644 index 0000000..e3222e6 --- /dev/null +++ b/tests/Post.php @@ -0,0 +1,13 @@ +set('database.default', 'testing'); + $app['config']->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + } + + /** + * run package database migrations. + */ + public function setUp(): void + { + parent::setUp(); + $this->loadMigrationsFrom(__DIR__ . '/migrations'); + $this->loadMigrationsFrom(dirname(__DIR__) . '/migrations'); + } } diff --git a/tests/User.php b/tests/User.php new file mode 100644 index 0000000..10ecc38 --- /dev/null +++ b/tests/User.php @@ -0,0 +1,15 @@ +increments('id'); + $table->string('title'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down() + { + Schema::dropIfExists('books'); + } +} diff --git a/tests/migrations/2021_03_13_000000_create_posts_table.php b/tests/migrations/2021_03_13_000000_create_posts_table.php new file mode 100644 index 0000000..56fa9b5 --- /dev/null +++ b/tests/migrations/2021_03_13_000000_create_posts_table.php @@ -0,0 +1,28 @@ +increments('id'); + $table->string('title'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down() + { + Schema::dropIfExists('posts'); + } +} diff --git a/tests/migrations/2021_03_13_000000_create_users_table.php b/tests/migrations/2021_03_13_000000_create_users_table.php new file mode 100644 index 0000000..b72e592 --- /dev/null +++ b/tests/migrations/2021_03_13_000000_create_users_table.php @@ -0,0 +1,28 @@ +increments('id'); + $table->string('name'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down() + { + Schema::dropIfExists('users'); + } +}