<?php
/**
* @file classes/submission/Collector.php
*
* Copyright (c) 2014-2021 Simon Fraser University
* Copyright (c) 2000-2021 John Willinsky
* Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
*
* @class Collector
*
* @brief A helper class to configure a Query Builder to get a collection of submissions
*/
namespace PKP\submission;
use APP\core\Application;
use APP\facades\Repo;
use APP\submission\Collector as AppCollector;
use APP\submission\Submission;
use Exception;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\LazyCollection;
use PKP\core\Core;
use PKP\core\interfaces\CollectorInterface;
use PKP\facades\Locale;
use PKP\identity\Identity;
use PKP\plugins\Hook;
use PKP\search\SubmissionSearch;
use PKP\security\Role;
use PKP\submission\reviewRound\ReviewRound;
/**
* @template T of Submission
*/
abstract class Collector implements CollectorInterface
{
public const ORDERBY_DATE_PUBLISHED = 'datePublished';
public const ORDERBY_DATE_SUBMITTED = 'dateSubmitted';
public const ORDERBY_LAST_ACTIVITY = 'lastActivity';
public const ORDERBY_LAST_MODIFIED = 'lastModified';
public const ORDERBY_SEQUENCE = 'sequence';
public const ORDERBY_TITLE = 'title';
public const ORDERBY_SEARCH_RANKING = 'ranking';
public const ORDER_DIR_ASC = 'ASC';
public const ORDER_DIR_DESC = 'DESC';
public const UNASSIGNED = -1;
public DAO $dao;
public ?array $categoryIds = null;
public ?array $contextIds = null;
public ?int $count = null;
public ?int $daysInactive = null;
public bool $isIncomplete = false;
public bool $isOverdue = false;
public ?int $offset = null;
public string $orderBy = self::ORDERBY_DATE_SUBMITTED;
public string $orderDirection = 'DESC';
public ?string $searchPhrase = null;
public ?int $maxSearchKeywords = null;
public ?array $statuses = null;
public ?array $stageIds = null;
public ?array $doiStatuses = null;
public ?bool $hasDois = null;
public ?array $excludeIds = null;
/** @var array Which DOI types should be considered when checking if a submission has DOIs set */
public array $enabledDoiTypes = [];
/** @var array|int */
public $assignedTo = null;
public function __construct(DAO $dao)
{
$this->dao = $dao;
}
public function getCount(): int
{
return $this->dao->getCount($this);
}
/**
* @return Collection<int,int>
*/
public function getIds(): Collection
{
return $this->dao->getIds($this);
}
/**
* @copydoc DAO::getMany()
* @return LazyCollection<int,T>
*/
public function getMany(): LazyCollection
{
return $this->dao->getMany($this);
}
/**
* Limit results to submissions in these contexts
*/
public function filterByContextIds(?array $contextIds): AppCollector
{
$this->contextIds = $contextIds;
return $this;
}
/**
* Limit results by submissions assigned to these categories
*/
public function filterByCategoryIds(?array $categoryIds): AppCollector
{
$this->categoryIds = $categoryIds;
return $this;
}
/**
* Limit results to submissions that contain any pub objects (e.g. publication and galley) with these statuses
*
* @param array|null $statuses One or more of DOI::STATUS_* constants
*
*/
public function filterByDoiStatuses(?array $statuses): AppCollector
{
$this->doiStatuses = $statuses;
return $this;
}
/**
* Limit results to submissions that do/don't have any DOIs assign to their sub objects
*
* @param array|null $enabledDoiTypes TYPE_* constants to consider when checking submission has DOIs
*/
public function filterByHasDois(?bool $hasDois, ?array $enabledDoiTypes = null): AppCollector
{
$this->hasDois = $hasDois;
$this->enabledDoiTypes = $enabledDoiTypes === null ? [Repo::doi()::TYPE_PUBLICATION] : $enabledDoiTypes;
return $this;
}
/**
* Limit results by submissions with these statuses
*
* @see \PKP\submissions\PKPSubmission::STATUS_
*/
public function filterByStatus(?array $statuses): AppCollector
{
$this->statuses = $statuses;
return $this;
}
/**
* Limit results by submissions in these workflow stage ids
*/
public function filterByStageIds(?array $stageIds): AppCollector
{
$this->stageIds = $stageIds;
return $this;
}
/**
* Limit results to incomplete submissions
*
* Submissions are incomplete when the author has begun to enter
* details about their submission but not yet submitted it.
*/
public function filterByIncomplete(bool $isIncomplete): AppCollector
{
$this->isIncomplete = $isIncomplete;
return $this;
}
/**
* Limit results to submissions with overdue tasks
*/
public function filterByOverdue(bool $isOverdue): AppCollector
{
$this->isOverdue = $isOverdue;
return $this;
}
/**
* Limit results to submission with no activity for X days
*/
public function filterByDaysInactive(?int $daysInactive): AppCollector
{
$this->daysInactive = $daysInactive;
return $this;
}
/**
* Limit results to submissions assigned to these users
*
* @param int|array $assignedTo An array of user IDs
* or self::UNASSIGNED to get unassigned submissions
*/
public function assignedTo($assignedTo): AppCollector
{
$this->assignedTo = $assignedTo;
return $this;
}
/**
* Limit results to submissions matching this search query
*/
public function searchPhrase(?string $phrase, ?int $maxSearchKeywords = null): AppCollector
{
$this->searchPhrase = $phrase;
$this->maxSearchKeywords = $maxSearchKeywords;
return $this;
}
/**
* Ensure the given submission IDs are not included
*/
public function excludeIds(?array $ids): AppCollector
{
$this->excludeIds = $ids;
return $this;
}
/**
* Limit the number of objects retrieved
*/
public function limit(?int $count): AppCollector
{
$this->count = $count;
return $this;
}
/**
* Offset the number of objects retrieved, for example to
* retrieve the second page of contents
*/
public function offset(?int $offset): AppCollector
{
$this->offset = $offset;
return $this;
}
/**
* Order the results
*
* The following column values are supported:
*
* - lastModified
* - dateLastActivity
* - title
* - seq (sequence)
* - DAO::ORDERBY_DATE_PUBLISHED
*
* Results are ordered by the date submitted by default.
*
* @param string $sorter One of the self::ORDERBY_ constants
* @param string $direction One of the self::ORDER_DIR_ constants
*/
public function orderBy(string $sorter, string $direction = self::ORDER_DIR_DESC): AppCollector
{
$this->orderBy = $sorter;
$this->orderDirection = $direction;
return $this;
}
/**
* Add APP-specific filtering methods for submission sub objects DOI statuses
*
*/
abstract protected function addDoiStatusFilterToQuery(Builder $q);
/**
* Add APP-specific filtering methods for checking if submission sub objects have DOIs assigned
*/
abstract protected function addHasDoisFilterToQuery(Builder $q);
/**
* @copydoc CollectorInterface::getQueryBuilder()
*/
public function getQueryBuilder(): Builder
{
$q = DB::table('submissions AS s')
->leftJoin('publications AS po', 's.current_publication_id', '=', 'po.publication_id')
->select(['s.*']);
// Never permit a query without a context_id unless the CONTEXT_ID_ALL wildcard
// has been set explicitly.
if (!isset($this->contextIds)) {
throw new Exception('Submissions can not be retrieved without a context id. Pass the CONTEXT_ID_ALL wildcard to get submissions from any context.');
} elseif (!in_array(Application::CONTEXT_ID_ALL, $this->contextIds)) {
$q->whereIn('s.context_id', $this->contextIds);
}
// Prepare keywords (allows short and numeric words)
$keywords = collect(Application::getSubmissionSearchIndex()->filterKeywords($this->searchPhrase, false, true, true))
->unique()
->take($this->maxSearchKeywords ?? PHP_INT_MAX);
// Setup the order by
switch ($this->orderBy) {
case self::ORDERBY_DATE_PUBLISHED:
$q->addSelect(['po.date_published']);
$q->orderBy('po.date_published', $this->orderDirection);
break;
case self::ORDERBY_LAST_ACTIVITY:
$q->orderBy('s.date_last_activity', $this->orderDirection);
break;
case self::ORDERBY_LAST_MODIFIED:
$q->orderBy('s.last_modified', $this->orderDirection);
break;
case self::ORDERBY_SEQUENCE:
$q->addSelect(['po.seq']);
$q->orderBy('po.seq', $this->orderDirection);
break;
case self::ORDERBY_TITLE:
$locale = Locale::getLocale();
$q->leftJoin('publications as publication_tlp', 's.current_publication_id', '=', 'publication_tlp.publication_id')
->leftJoin('publication_settings as publication_tlps', fn (JoinClause $join) =>
$join->on('publication_tlp.publication_id', '=', 'publication_tlps.publication_id')
->where('publication_tlps.setting_name', '=', 'title')
->where('publication_tlps.setting_value', '!=', '')
->where('publication_tlps.locale', '=', $locale)
);
$q->leftJoin('publications as publication_tlpl', 's.current_publication_id', '=', 'publication_tlpl.publication_id')
->leftJoin('publication_settings as publication_tlpsl', fn (JoinClause $join) =>
$join->on('publication_tlp.publication_id', '=', 'publication_tlpsl.publication_id')
->on('publication_tlpsl.locale', '=', 's.locale')
->where('publication_tlpsl.setting_name', '=', 'title')
);
$coalesceTitles = 'COALESCE(publication_tlps.setting_value, publication_tlpsl.setting_value)';
$q->addSelect([DB::raw($coalesceTitles)]);
$q->orderBy(DB::raw($coalesceTitles), $this->orderDirection);
break;
case self::ORDERBY_SEARCH_RANKING:
if (!$keywords->count()) {
$q->orderBy('s.date_submitted', $this->orderDirection);
break;
}
// Retrieves the number of matches for all keywords
$orderByMatchCount = DB::table('submission_search_objects', 'sso')
->join('submission_search_object_keywords AS ssok', 'ssok.object_id', '=', 'sso.object_id')
->join('submission_search_keyword_list AS sskl', 'sskl.keyword_id', '=', 'ssok.keyword_id')
->where(fn (Builder $q) =>
$keywords->map(fn (string $keyword) => $q
->orWhere('sskl.keyword_text', '=', DB::raw('LOWER(?)'))
->addBinding($keyword)
)
)
->whereColumn('s.submission_id', '=', 'sso.submission_id')
->selectRaw('COUNT(0)');
// Retrieves the number of distinct matched keywords
$orderByDistinctKeyword = (clone $orderByMatchCount)->select(DB::raw('COUNT(DISTINCT sskl.keyword_id)'));
$q->orderBy($orderByDistinctKeyword, $this->orderDirection)
->orderBy($orderByMatchCount, $this->orderDirection);
break;
case self::ORDERBY_DATE_SUBMITTED:
default:
$q->orderBy('s.date_submitted', $this->orderDirection);
break;
}
if (isset($this->statuses)) {
$q->whereIn('s.status', $this->statuses);
}
if (isset($this->stageIds)) {
$q->whereIn('s.stage_id', $this->stageIds);
}
if ($this->isIncomplete) {
$q->where('s.submission_progress', '<>', '');
}
if (isset($this->daysInactive)) {
$q->where('s.date_last_activity', '<', Core::getCurrentDate(strtotime('-' . $this->daysInactive . ' days')));
}
if ($this->isOverdue) {
$q->leftJoin('review_assignments as raod', 'raod.submission_id', '=', 's.submission_id')
->leftJoin('review_rounds as rr', fn (Builder $table) =>
$table->on('rr.submission_id', '=', 's.submission_id')
->on('raod.review_round_id', '=', 'rr.review_round_id')
);
// Only get overdue assignments on active review rounds
$q->whereNotIn('rr.status', [
ReviewRound::REVIEW_ROUND_STATUS_RESUBMIT_FOR_REVIEW,
ReviewRound::REVIEW_ROUND_STATUS_SENT_TO_EXTERNAL,
ReviewRound::REVIEW_ROUND_STATUS_ACCEPTED,
ReviewRound::REVIEW_ROUND_STATUS_DECLINED,
]);
$q->where(fn (Builder $q) =>
$q->where('raod.declined', '<>', 1)
->where('raod.cancelled', '<>', 1)
->where(fn (Builder $q) =>
$q->where('raod.date_due', '<', Core::getCurrentDate(strtotime('tomorrow')))
->whereNull('raod.date_completed')
)
->orWhere(fn (Builder $q) =>
$q->where('raod.date_response_due', '<', Core::getCurrentDate(strtotime('tomorrow')))
->whereNull('raod.date_confirmed')
)
);
}
if (is_array($this->assignedTo)) {
$q->whereIn('s.submission_id', fn (Builder $q) =>
$q->select('s.submission_id')
->from('submissions AS s')
->leftJoin('stage_assignments as sa', fn (Builder $q) =>
$q->on('s.submission_id', '=', 'sa.submission_id')
->whereIn('sa.user_id', $this->assignedTo)
)
->leftJoin('review_assignments as ra', fn (Builder $table) =>
$table->on('s.submission_id', '=', 'ra.submission_id')
->where('ra.declined', '=', (int) 0)
->where('ra.cancelled', '=', (int) 0)
->whereIn('ra.reviewer_id', $this->assignedTo)
)
->whereNotNull('sa.stage_assignment_id')
->orWhereNotNull('ra.review_id')
);
} elseif ($this->assignedTo === self::UNASSIGNED) {
$sub = DB::table('stage_assignments')
->select(DB::raw('count(stage_assignments.stage_assignment_id)'))
->leftJoin('user_groups', 'stage_assignments.user_group_id', '=', 'user_groups.user_group_id')
->where('stage_assignments.submission_id', '=', DB::raw('s.submission_id'))
->whereIn('user_groups.role_id', [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SUB_EDITOR]);
$q->whereNotNull('s.date_submitted')
->mergeBindings($sub)
->where(DB::raw('(' . $sub->toSql() . ')'), '=', '0');
}
// Search phrase
if ($keywords->count()) {
$likePattern = DB::raw("CONCAT('%', LOWER(?), '%')");
if(!empty($this->assignedTo)) {
// Holds a single random row to check whether we have any assignment
$q->leftJoinSub(fn (Builder $q) => $q
->from('review_assignments', 'ra')
->whereIn('ra.reviewer_id', $this->assignedTo == self::UNASSIGNED ? [] : (array) $this->assignedTo)
->select(DB::raw('1 AS value'))
->limit(1),
'any_assignment', 'any_assignment.value', '=', DB::raw('1')
);
}
// Builds the filters
$q->where(fn (Builder $q) => $keywords
->map(fn (string $keyword) => $q
// Look for matches on the indexed data
->orWhereExists(fn (Builder $query) => $query
->from('submission_search_objects', 'sso')
->join('submission_search_object_keywords AS ssok', 'sso.object_id', '=', 'ssok.object_id')
->join('submission_search_keyword_list AS sskl', 'sskl.keyword_id', '=', 'ssok.keyword_id')
->where('sskl.keyword_text', '=', DB::raw('LOWER(?)'))->addBinding($keyword)
->whereColumn('s.submission_id', '=', 'sso.submission_id')
// Don't permit reviewers to search on author names
->when(!empty($this->assignedTo), fn (Builder $q) => $q
->where(fn (Builder $q) => $q
->whereNull('any_assignment.value')
->orWhere('sso.type', '!=', SubmissionSearch::SUBMISSION_SEARCH_AUTHOR)
)
)
)
// Search on the publication title
->orWhereIn('s.submission_id', fn (Builder $query) => $query
->select('p.submission_id')->from('publications AS p')
->join('publication_settings AS ps', 'p.publication_id', '=', 'ps.publication_id')
->where('ps.setting_name', '=', 'title')
->where(DB::raw('LOWER(ps.setting_value)'), 'LIKE', $likePattern)
->addBinding($keyword)
)
// Search on the author name and ORCID
->orWhereIn('s.submission_id', fn (Builder $query) => $query
->select('p.submission_id')
->from('publications AS p')
->join('authors AS au', 'au.publication_id', '=', 'p.publication_id')
->join('author_settings AS aus', 'aus.author_id', '=', 'au.author_id')
->whereIn('aus.setting_name', [
Identity::IDENTITY_SETTING_GIVENNAME,
Identity::IDENTITY_SETTING_FAMILYNAME,
'orcid'
])
// Don't permit reviewers to search on author names
->when(!empty($this->assignedTo), fn (Builder $q) => $q
->where(fn (Builder $q) => $q
->whereNull('any_assignment.value')
->orWhereNotIn('aus.setting_name', [
Identity::IDENTITY_SETTING_GIVENNAME,
Identity::IDENTITY_SETTING_FAMILYNAME
])
)
)
->where(DB::raw('LOWER(aus.setting_value)'), 'LIKE', $likePattern)
->addBinding($keyword)
)
// Search for the exact submission ID
->when(
($numericWords = $keywords->filter(fn (string $keyword) => ctype_digit($keyword)))->count(),
fn (Builder $query) => $query->orWhereIn('s.submission_id', $numericWords)
)
)
);
} elseif (strlen($this->searchPhrase ?? '')) {
// If there's search text, but no keywords could be extracted from it, force the query to return nothing
$q->whereRaw('1 = 0');
}
if (isset($this->categoryIds)) {
$q->join('publication_categories as pc', 's.current_publication_id', '=', 'pc.publication_id')
->whereIn('pc.category_id', $this->categoryIds);
}
// Filter by any child pub object's DOI status
$q->when($this->doiStatuses !== null, fn (Builder $q) => $this->addDoiStatusFilterToQuery($q));
// Filter by whether any child pub objects have DOIs assigned
$q->when($this->hasDois !== null, fn (Builder $q) => $this->addHasDoisFilterToQuery($q));
// Filter out excluded submission IDs
$q->when($this->excludeIds !== null, fn (Builder $q) => $q->whereNotIn('s.submission_id', $this->excludeIds));
// Limit and offset results for pagination
if (isset($this->count)) {
$q->limit($this->count);
}
if (isset($this->offset)) {
$q->offset($this->offset);
}
// Add app-specific query statements
Hook::call('Submission::Collector', [&$q, $this]);
return $q;
}
}
|