diff --git a/app/Libraries/Search/ScoreSearch.php b/app/Libraries/Search/ScoreSearch.php index 198a65484d2..c0661cbd681 100644 --- a/app/Libraries/Search/ScoreSearch.php +++ b/app/Libraries/Search/ScoreSearch.php @@ -36,6 +36,9 @@ public function getQuery(): BoolQuery { $query = new BoolQuery(); + if ($this->params->excludeConverts) { + $query->filter(['term' => ['convert' => false]]); + } if (config('osu.scores.es_enable_legacy_filter') && $this->params->isLegacy !== null) { $query->filter(['term' => ['is_legacy' => $this->params->isLegacy]]); } diff --git a/app/Libraries/Search/ScoreSearchParams.php b/app/Libraries/Search/ScoreSearchParams.php index 9ad2d4bc5ea..69053786f99 100644 --- a/app/Libraries/Search/ScoreSearchParams.php +++ b/app/Libraries/Search/ScoreSearchParams.php @@ -21,6 +21,7 @@ public static function fromArray(array $rawParams): static { $params = new static(); $params->beatmapIds = $rawParams['beatmap_ids'] ?? null; + $params->excludeConverts = $rawParams['exclude_converts'] ?? $params->excludeConverts; $params->excludeMods = $rawParams['exclude_mods'] ?? null; $params->isLegacy = $rawParams['is_legacy'] ?? null; $params->mods = $rawParams['mods'] ?? null; @@ -42,6 +43,7 @@ public static function fromArray(array $rawParams): static public ?array $beatmapIds = null; public ?Score $beforeScore = null; public ?int $beforeTotalScore = null; + public bool $excludeConverts = false; public ?array $excludeMods = null; public ?bool $isLegacy = null; public ?array $mods = null; diff --git a/app/Models/BeatmapPack.php b/app/Models/BeatmapPack.php index b57b9849ebe..45c1c09e1c1 100644 --- a/app/Models/BeatmapPack.php +++ b/app/Models/BeatmapPack.php @@ -5,7 +5,9 @@ namespace App\Models; -use Exception; +use App\Libraries\Search\ScoreSearch; +use App\Libraries\Search\ScoreSearchParams; +use Ds\Set; /** * @property string $author @@ -86,65 +88,55 @@ public function userCompletionData($user) { if ($user !== null) { $userId = $user->getKey(); - $beatmapsetIds = $this->items()->pluck('beatmapset_id')->all(); - $query = Beatmap::select('beatmapset_id')->distinct()->whereIn('beatmapset_id', $beatmapsetIds); - - if ($this->playmode === null) { - static $scoreRelations; - - // generate list of beatmap->score relation names for each modes - // store int mode as well as it'll be used for filtering the scores - if (!isset($scoreRelations)) { - $scoreRelations = []; - foreach (Beatmap::MODES as $modeStr => $modeInt) { - $scoreRelations[] = [ - 'playmode' => $modeInt, - 'relation' => camel_case("scores_best_{$modeStr}"), - ]; - } - } - - // outer where function - // The idea is SELECT ... WHERE ... AND ( OR OR ...). - $query->where(function ($q) use ($scoreRelations, $userId) { - foreach ($scoreRelations as $scoreRelation) { - // The scores> mentioned above is generated here. - // As it's "playmode = AND EXISTS (< score for user>)", - // wrap them so it's not flat "playmode = AND EXISTS ... OR playmode = AND EXISTS ...". - $q->orWhere(function ($qq) use ($scoreRelation, $userId) { - $qq - // this playmode filter ensures the scores are limited to non-convert maps - ->where('playmode', '=', $scoreRelation['playmode']) - ->whereHas($scoreRelation['relation'], function ($scoreQuery) use ($userId) { - $scoreQuery->where('user_id', '=', $userId); - - if ($this->no_diff_reduction) { - $scoreQuery->withoutMods(app('mods')->difficultyReductionIds->toArray()); - } - }); - }); - } - }); - } else { - $modeStr = Beatmap::modeStr($this->playmode); - - if ($modeStr === null) { - throw new Exception("beatmapset pack {$this->getKey()} has invalid playmode: {$this->playmode}"); - } - - $scoreRelation = camel_case("scores_best_{$modeStr}"); - - $query->whereHas($scoreRelation, function ($query) use ($userId) { - $query->where('user_id', '=', $userId); - - if ($this->no_diff_reduction) { - $query->withoutMods(app('mods')->difficultyReductionIds->toArray()); - } - }); + + $beatmaps = Beatmap + ::whereIn('beatmapset_id', $this->items()->select('beatmapset_id')) + ->select(['beatmap_id', 'beatmapset_id', 'playmode']) + ->get(); + $beatmapsetIdsByBeatmapId = []; + foreach ($beatmaps as $beatmap) { + $beatmapsetIdsByBeatmapId[$beatmap->beatmap_id] = $beatmap->beatmapset_id; + } + $params = [ + 'beatmap_ids' => array_keys($beatmapsetIdsByBeatmapId), + 'exclude_converts' => $this->playmode === null, + 'is_legacy' => true, + 'limit' => 0, + 'ruleset_id' => $this->playmode, + 'user_id' => $userId, + ]; + if ($this->no_diff_reduction) { + $params['exclude_mods'] = app('mods')->difficultyReductionIds->toArray(); } - $completedBeatmapsetIds = $query->pluck('beatmapset_id')->all(); - $completed = count($completedBeatmapsetIds) === count($beatmapsetIds); + static $aggName = 'by_beatmap'; + + $search = new ScoreSearch(ScoreSearchParams::fromArray($params)); + $search->size(0); + $search->setAggregations([$aggName => [ + 'terms' => [ + 'field' => 'beatmap_id', + 'size' => max(1, count($params['beatmap_ids'])), + ], + 'aggs' => [ + 'scores' => [ + 'top_hits' => [ + 'size' => 1, + ], + ], + ], + ]]); + $response = $search->response(); + $search->assertNoError(); + $completedBeatmapIds = array_map( + fn (array $hit): int => (int) $hit['key'], + $response->aggregations($aggName)['buckets'], + ); + $completedBeatmapsetIds = (new Set(array_map( + fn (int $beatmapId): int => $beatmapsetIdsByBeatmapId[$beatmapId], + $completedBeatmapIds, + )))->toArray(); + $completed = count($completedBeatmapsetIds) === count(array_unique($beatmapsetIdsByBeatmapId)); } return [ diff --git a/tests/Models/BeatmapPackUserCompletionTest.php b/tests/Models/BeatmapPackUserCompletionTest.php index 12122c1225a..db97b27a888 100644 --- a/tests/Models/BeatmapPackUserCompletionTest.php +++ b/tests/Models/BeatmapPackUserCompletionTest.php @@ -7,53 +7,96 @@ namespace Tests\Models; +use App\Libraries\Search\ScoreSearch; use App\Models\Beatmap; use App\Models\BeatmapPack; -use App\Models\Score\Best as ScoreBest; +use App\Models\BeatmapPackItem; +use App\Models\Beatmapset; +use App\Models\Country; +use App\Models\Genre; +use App\Models\Group; +use App\Models\Language; +use App\Models\Solo\Score; use App\Models\User; +use App\Models\UserGroup; +use App\Models\UserGroupEvent; use Tests\TestCase; +/** + * @group EsSoloScores + */ class BeatmapPackUserCompletionTest extends TestCase { - /** - * @dataProvider dataProviderForTestBasic - */ - public function testBasic(string $userType, ?string $packRuleset, bool $completed): void + private static array $users; + private static BeatmapPack $pack; + + public static function setUpBeforeClass(): void { + parent::setUpBeforeClass(); + + (new static())->refreshApplication(); $beatmap = Beatmap::factory()->ranked()->state([ 'playmode' => Beatmap::MODES['taiko'], ])->create(); - $pack = BeatmapPack::factory()->create(); - $pack->items()->create(['beatmapset_id' => $beatmap->beatmapset_id]); - - $scoreUser = User::factory()->create(); - $scoreClass = ScoreBest\Taiko::class; - switch ($userType) { - case 'convertOsu': - $checkUser = $scoreUser; - $scoreClass = ScoreBest\Osu::class; - break; - case 'default': - $checkUser = $scoreUser; - break; - case 'null': - $checkUser = null; - break; - case 'unrelated': - $checkUser = User::factory()->create(); - break; - } - - $scoreClass::factory()->create([ + static::$pack = BeatmapPack::factory()->create(); + static::$pack->items()->create(['beatmapset_id' => $beatmap->beatmapset_id]); + + static::$users = [ + 'convertOsu' => User::factory()->create(), + 'default' => User::factory()->create(), + 'null' => null, + 'unrelated' => User::factory()->create(), + ]; + + Score::factory()->create([ 'beatmap_id' => $beatmap, - 'user_id' => $scoreUser->getKey(), + 'ruleset_id' => Beatmap::MODES['osu'], + 'preserve' => true, + 'user_id' => static::$users['convertOsu'], ]); + Score::factory()->create([ + 'beatmap_id' => $beatmap, + 'preserve' => true, + 'user_id' => static::$users['default'], + ]); + + static::reindexScores(); + } + + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + + (new static())->refreshApplication(); + Beatmap::truncate(); + BeatmapPack::truncate(); + BeatmapPackItem::truncate(); + Beatmapset::truncate(); + Country::truncate(); + Genre::truncate(); + Group::truncate(); + Language::truncate(); + Score::truncate(); + User::truncate(); + UserGroup::truncate(); + UserGroupEvent::truncate(); + (new ScoreSearch())->deleteAll(); + } + + protected $connectionsToTransact = []; + + /** + * @dataProvider dataProviderForTestBasic + */ + public function testBasic(string $userType, ?string $packRuleset, bool $completed): void + { + $user = static::$users[$userType]; $rulesetId = $packRuleset === null ? null : Beatmap::MODES[$packRuleset]; - $pack->update(['playmode' => $rulesetId]); - $pack->refresh(); + static::$pack->update(['playmode' => $rulesetId]); + static::$pack->refresh(); - $data = $pack->userCompletionData($checkUser); + $data = static::$pack->userCompletionData($user); $this->assertSame($completed ? 1 : 0, count($data['beatmapset_ids'])); $this->assertSame($completed, $data['completed']); }