forMany([$file], $viewer, $urlFor)[$file->id] ?? ['previous' => null, 'next' => null]; } /** * The same answer for a whole page of rows, keyed by file id. * * Two queries regardless of row count, the same shape * VisibleCommentScope::countsFor() uses: the previous ids are already * on the loaded rows, one query finds every successor, and one more * asks the viewer's scope which of those candidates it may name. * * @param iterable $files * @param callable(File): ?string|null $urlFor * @return array */ public function forMany(iterable $files, ?User $viewer, ?callable $urlFor = null): array { /** @var array $rows */ $rows = []; foreach ($files as $file) { $rows[$file->id] = $file; } if ($rows === []) { return []; } // Free: previous_file_id is a column on the rows already loaded. $previousIds = []; foreach ($rows as $file) { if ($file->previous_file_id !== null) { $previousIds[$file->previous_file_id] = true; } } // One query for every successor on the page at once. `slug` is not // optional in this select: the public surface builds a counterpart's // URL from it, and omitting it hands route() a null parameter rather // than failing anywhere near here. $successors = File::query() ->whereIn('previous_file_id', array_keys($rows)) ->get(['id', 'name', 'slug', 'previous_file_id', 'public', 'expires_at', 'folder_id']); /** @var array $candidates */ $candidates = []; foreach ($successors as $successor) { $candidates[$successor->id] = $successor; } if ($previousIds !== []) { $previous = File::query() ->whereIn('id', array_keys($previousIds)) ->get(['id', 'name', 'slug', 'previous_file_id', 'public', 'expires_at', 'folder_id']); foreach ($previous as $file) { $candidates[$file->id] = $file; } } $nameable = $this->nameable($candidates, $viewer); $result = []; foreach ($rows as $id => $file) { $next = null; foreach ($successors as $successor) { if ($successor->previous_file_id === $id) { $next = $successor; break; } } $result[$id] = [ 'previous' => $this->describe( $file->previous_file_id === null ? null : ($candidates[$file->previous_file_id] ?? null), $nameable, $urlFor, ), 'next' => $this->describe($next, $nameable, $urlFor), ]; } return $result; } /** * Which of the candidate files this viewer may be told about. * * @param array $candidates * @return list */ private function nameable(array $candidates, ?User $viewer): array { if ($candidates === []) { return []; } // A guest "sees both files" exactly when both are effectively // public and unexpired — the same predicate // PublicGroupsController::showFile 404s on, so the badge can never // point at a page that would refuse to load. if ($viewer === null) { $ids = []; foreach ($candidates as $candidate) { if ($candidate->isEffectivelyPublic() && ! $candidate->isExpired()) { $ids[] = $candidate->id; } } return $ids; } // ViewableFileScope is FilePolicy::view() as a query, so this stays // in lockstep with what the download route would allow. One query. return array_values(array_map(intval(...), $this->viewable->for($viewer) ->whereIn('files.id', array_keys($candidates)) ->pluck('files.id') ->all())); } /** * @param list $nameable * @param callable(File): ?string|null $urlFor * @return array{id: int, name: string, url: string|null}|null */ private function describe(?File $file, array $nameable, ?callable $urlFor): ?array { if ($file === null || ! in_array($file->id, $nameable, true)) { return null; } return [ 'id' => $file->id, 'name' => $file->name, 'url' => $urlFor === null ? null : $urlFor($file), ]; } }