<?php
/**
* @file classes/user/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 users
*/
namespace PKP\user;
use APP\core\Application;
use Carbon\Carbon;
use Illuminate\Database\MySqlConnection;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\LazyCollection;
use InvalidArgumentException;
use PKP\core\interfaces\CollectorInterface;
use PKP\core\PKPString;
use PKP\facades\Locale;
use PKP\identity\Identity;
use PKP\plugins\Hook;
/**
* @template T of User
*/
class Collector implements CollectorInterface
{
public const ORDERBY_ID = 'id';
public const ORDERBY_GIVENNAME = 'givenName';
public const ORDERBY_FAMILYNAME = 'familyName';
public const ORDER_DIR_ASC = 'ASC';
public const ORDER_DIR_DESC = 'DESC';
public const STATUS_ACTIVE = 'active';
public const STATUS_DISABLED = 'disabled';
public const STATUS_ALL = null;
public DAO $dao;
public string $orderBy = self::ORDERBY_ID;
public string $orderDirection = 'ASC';
public ?array $orderLocales = null;
public ?array $userGroupIds = null;
public ?array $roleIds = null;
public ?array $userIds = null;
public ?array $excludeUserIds = null;
public ?array $workflowStageIds = null;
public ?array $contextIds = null;
public ?string $registeredBefore = null;
public ?string $registeredAfter = null;
public ?string $status = self::STATUS_ACTIVE;
public bool $includeReviewerData = false;
public ?array $assignedSectionIds = null;
public ?array $assignedCategoryIds = null;
public ?array $settings = null;
public ?string $searchPhrase = null;
public ?array $excludeSubmissionStage = null;
public ?array $excludeRoles = null;
public ?array $assignedTo = null;
public ?int $reviewerRating = null;
public ?int $reviewsCompleted = null;
public ?array $daysSinceLastAssignment = null;
public ?int $averageCompletion = null;
public ?array $reviewsActive = null;
public ?int $count = null;
public ?int $offset = null;
/**
* Constructor
*/
public function __construct(DAO $dao)
{
$this->dao = $dao;
}
public function getCount(): int
{
return $this->dao->getCount($this);
}
/**
* @copydoc DAO::getMany()
*
* @return LazyCollection<int,T>
*/
public function getMany(): LazyCollection
{
return $this->dao->getMany($this);
}
/**
* @return Collection<int,int>
*/
public function getIds(): Collection
{
return $this->dao->getIds($this);
}
/**
* Limit results to users in these user groups
*/
public function filterByUserGroupIds(?array $userGroupIds): self
{
$this->userGroupIds = $userGroupIds;
return $this;
}
/**
* Filter by user IDs
*/
public function filterByUserIds(?array $userIds): self
{
$this->userIds = $userIds;
return $this;
}
/**
* Limit results to those who have a minimum reviewer rating
*/
public function filterByReviewerRating(?int $reviewerRating): self
{
$this->includeReviewerData(true);
$this->reviewerRating = $reviewerRating;
return $this;
}
/**
* Limit results to those who have completed at least this many reviews
*/
public function filterByReviewsCompleted(?int $reviewsCompleted): self
{
$this->includeReviewerData(true);
$this->reviewsCompleted = $reviewsCompleted;
return $this;
}
/**
* Limit results to those who's last review assignment was at least this many
* days ago.
*/
public function filterByDaysSinceLastAssignment(?int $minimumDaysSinceLastAssignment = null, ?int $maximumDaysSinceLastAssignment = null): self
{
$this->includeReviewerData(true);
$this->daysSinceLastAssignment = [$minimumDaysSinceLastAssignment, $maximumDaysSinceLastAssignment];
return $this;
}
/**
* Limit results to those who complete a review on average less than this many
* days after their assignment.
*/
public function filterByAverageCompletion(?int $averageCompletion): self
{
$this->includeReviewerData(true);
$this->averageCompletion = $averageCompletion;
return $this;
}
/**
* Limit results to those who have at least this many active review assignments
*/
public function filterByReviewsActive(?int $minimumReviewsActive = null, ?int $maximumReviewsActive = null): self
{
$this->includeReviewerData(true);
$this->reviewsActive = [$minimumReviewsActive, $maximumReviewsActive];
return $this;
}
/**
* Exclude results with the specified user IDs.
*/
public function excludeUserIds(?array $excludeUserIds): self
{
$this->excludeUserIds = $excludeUserIds;
return $this;
}
/**
* Limit results to users enrolled in these roles
*/
public function filterByRoleIds(?array $roleIds): self
{
$this->roleIds = $roleIds;
return $this;
}
/**
* Limit results to users enrolled in these roles
*/
public function filterByWorkflowStageIds(?array $workflowStageIds): self
{
$this->workflowStageIds = $workflowStageIds;
return $this;
}
/**
* Limit results to users with user groups in these context IDs
*/
public function filterByContextIds(?array $contextIds): self
{
$this->contextIds = $contextIds;
return $this;
}
/**
* Limit results to users registered before a specified date.
*/
public function filterRegisteredBefore(?string $registeredBefore): self
{
$this->registeredBefore = $registeredBefore;
return $this;
}
/**
* Limit results to users registered after a specified date.
*/
public function filterRegisteredAfter(?string $registeredAfter): self
{
$this->registeredAfter = $registeredAfter;
return $this;
}
/**
* Defines whether reviewer data should be included
*/
public function includeReviewerData(bool $includeReviewerData = true): self
{
$this->includeReviewerData = $includeReviewerData;
return $this;
}
/**
* Retrieve a set of users not assigned to a given submission stage as a user group.
* (Replaces UserStageAssignmentDAO::getUsersNotAssignedToStageInUserGroup)
*/
public function filterExcludeSubmissionStage(int $submissionId, int $stageId, int $userGroupId): self
{
$this->excludeSubmissionStage = [
'submission_id' => $submissionId,
'stage_id' => $stageId,
'user_group_id' => $userGroupId,
];
return $this;
}
/**
* Retrieve a set of users not assigned to the given roles
* (Replaces UserGroupDAO::getUsersNotInRole)
*/
public function filterExcludeRoles(?array $excludedRoles): self
{
$this->excludeRoles = $excludedRoles;
return $this;
}
/**
* Retrieve assigned users by submission and stage IDs.
* (Replaces UserStageAssignmentDAO::getUsersBySubmissionAndStageId)
*/
public function assignedTo(?int $submissionId = null, ?int $stageId = null, ?int $userGroupId = null): self
{
if ($submissionId === null) {
// Clear the condition.
$this->assignedTo = null;
if ($stageId !== null || $userGroupId !== null) {
throw new InvalidArgumentException('If a stage or user group ID is specified, a submission ID must be specified as well.');
}
} else {
$this->assignedTo = [
'submissionId' => $submissionId,
'stageId' => $stageId,
'userGroupId' => $userGroupId,
];
}
return $this;
}
/**
* Filter by active / disabled status.
*
* @param string|null $status self::STATUS_ACTIVE, self::STATUS_DISABLED or self::STATUS_ALL.
*/
public function filterByStatus(?string $status): self
{
if (!in_array($this->status, [self::STATUS_ACTIVE, self::STATUS_DISABLED, self::STATUS_ALL], true)) {
throw new InvalidArgumentException("Invalid status: \"{$this->status}\"");
}
$this->status = $status;
return $this;
}
/**
* Filter by assigned subeditor section IDs
*/
public function assignedToSectionIds(?array $sectionIds): self
{
$this->assignedSectionIds = $sectionIds;
return $this;
}
/**
* Filter by assigned subeditor section IDs
*/
public function assignedToCategoryIds(?array $categoryIds): self
{
$this->assignedCategoryIds = $categoryIds;
return $this;
}
/**
* Filter by exact match of user settings (the locale is ignored)
*
* @param array|null $settings The key must be a valid setting_name while the value will match the setting_value
*/
public function filterBySettings(?array $settings): self
{
$this->settings = $settings;
return $this;
}
/**
* Limit results to users matching this search query
*/
public function searchPhrase(?string $phrase): self
{
$this->searchPhrase = $phrase;
return $this;
}
/**
* Order the results
*
* @param string $sorter One of the self::ORDERBY_ constants
* @param string $direction One of the self::ORDER_DIR_ constants
* @param array|null locales Optional list of locale precedences when ordering by localized columns
*/
public function orderBy(string $sorter, string $direction = self::ORDER_DIR_DESC, ?array $locales = null): self
{
if (!in_array($sorter, [static::ORDERBY_FAMILYNAME, static::ORDERBY_GIVENNAME, static::ORDERBY_ID])) {
throw new InvalidArgumentException("Invalid order by: {$sorter}");
}
if (!in_array($direction, [static::ORDER_DIR_ASC, static::ORDER_DIR_DESC])) {
throw new InvalidArgumentException("Invalid order direction: {$direction}");
}
$this->orderBy = $sorter;
$this->orderDirection = $direction;
$this->orderLocales = $locales;
return $this;
}
/**
* Limit the number of objects retrieved
*/
public function limit(?int $count): self
{
$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): self
{
$this->offset = $offset;
return $this;
}
/**
* @copydoc CollectorInterface::getQueryBuilder()
*/
public function getQueryBuilder(): Builder
{
if ($this->offset !== null && $this->count === null) {
throw new InvalidArgumentException('The offset requires the count to be defined');
}
$query = DB::table('users', 'u')
->select('u.*')
// Filters by registration date
->when($this->registeredBefore !== null, fn (Builder $query) => $query->where('u.date_registered', '<', Carbon::rawParse($this->registeredBefore)->addDay()->toDateString()))
->when($this->registeredAfter !== null, fn (Builder $query) => $query->where('u.date_registered', '>=', $this->registeredAfter))
// Filters by user ID
->when($this->userIds !== null, fn (Builder $query) => $query->whereIn('u.user_id', $this->userIds))
->when($this->excludeUserIds !== null, fn (Builder $query) => $query->whereNotIn('u.user_id', $this->excludeUserIds))
// User enabled/disabled state
->when($this->status !== self::STATUS_ALL, fn (Builder $query) => $query->where('u.disabled', '=', $this->status === self::STATUS_DISABLED))
// Adds limit and offset for pagination
->when($this->count !== null, fn (Builder $query) => $query->limit($this->count))
->when($this->offset !== null, fn (Builder $query) => $query->offset($this->offset));
$this
->buildReviewerStatistics($query)
->buildUserGroupFilter($query)
->buildSearchFilter($query)
->buildSubEditorFilter($query)
->buildSettingsFilter($query)
->buildExcludedSubmissionStagesFilter($query)
->buildSubmissionAssignmentsFilter($query)
->buildOrderBy($query);
// Add app-specific query statements
Hook::call('User::Collector', [$query, $this]);
return $query;
}
/**
* Builds the filters related to submission assignments (submission, stage, user group)
*/
protected function buildSubmissionAssignmentsFilter(Builder $query): self
{
if ($this->assignedTo === null) {
return $this;
}
$query->whereExists(
fn (Builder $query) => $query->from('stage_assignments', 'sa')
->join('user_group_stage AS ugs', 'sa.user_group_id', '=', 'ugs.user_group_id')
->whereColumn('sa.user_id', '=', 'u.user_id')
->when(isset($this->assignedTo['submissionId']), fn ($query) => $query->where('sa.submission_id', '=', $this->assignedTo['submissionId']))
->when(isset($this->assignedTo['stageId']), fn ($query) => $query->where('ugs.stage_id', '=', $this->assignedTo['stageId']))
->when(isset($this->assignedTo['userGroupId']), fn ($query) => $query->where('sa.user_group_id', '=', $this->assignedTo['userGroupId']))
);
return $this;
}
/**
* Builds the filters related to the user group
*/
protected function buildUserGroupFilter(Builder $query): self
{
if ($this->userGroupIds === null && $this->roleIds === null && $this->contextIds === null && $this->workflowStageIds === null) {
return $this;
}
$subQuery = DB::table('user_user_groups as uug')
->join('user_groups AS ug', 'uug.user_group_id', '=', 'ug.user_group_id')
->whereColumn('uug.user_id', '=', 'u.user_id')
->when($this->userGroupIds !== null, fn (Builder $subQuery) => $subQuery->whereIn('uug.user_group_id', $this->userGroupIds))
->when(
$this->workflowStageIds !== null,
fn (Builder $subQuery) => $subQuery
->join('user_group_stage AS ugs', 'ug.user_group_id', '=', 'ugs.user_group_id')
->whereIn('ugs.stage_id', $this->workflowStageIds)
)
->when($this->roleIds !== null, fn ($subQuery) => $subQuery->whereIn('ug.role_id', $this->roleIds))
->when($this->contextIds !== null, fn ($subQuery) => $subQuery->whereIn('ug.context_id', $this->contextIds));
$joinSub = clone $subQuery;
$query
->addWhereExistsQuery(
$subQuery->when(
$this->excludeRoles != null,
// This aggregates a column role_count, which holds an information whether user holds specified role
fn (Builder $subQuery) => $subQuery->leftJoinSub(
$joinSub
->select('uug.user_id')
->whereIn('ug.role_id', $this->excludeRoles),
'agr',
'uug.user_id',
'agr.user_id'
)->whereNull('agr.user_id')
)
);
return $this;
}
/**
* Builds the settings filter
*/
protected function buildSettingsFilter(Builder $query): self
{
foreach ($this->settings ?? [] as $name => $value) {
$query->whereExists(
fn (Builder $query) => $query->from('user_settings', 'us')
->whereColumn('us.user_id', '=', 'u.user_id')
->where('us.setting_name', '=', $name)
->where('us.setting_value', '=', $value)
);
}
return $this;
}
/**
* Builds the excluded submission stages filter
*/
protected function buildExcludedSubmissionStagesFilter(Builder $query): self
{
if ($this->excludeSubmissionStage === null) {
return $this;
}
$query->whereExists(
fn (Builder $query) => $query->from('user_user_groups', 'uug')
->join('user_group_stage AS ugs', 'ugs.user_group_id', '=', 'uug.user_group_id')
->leftJoin(
'stage_assignments AS sa',
fn (JoinClause $join) => $join->on('sa.user_id', '=', 'uug.user_id')
->on('sa.user_group_id', '=', 'uug.user_group_id')
->where('sa.submission_id', '=', $this->excludeSubmissionStage['submission_id'])
)
->whereColumn('uug.user_id', '=', 'u.user_id')
->where('uug.user_group_id', '=', $this->excludeSubmissionStage['user_group_id'])
->where('ugs.stage_id', '=', $this->excludeSubmissionStage['stage_id'])
->whereNull('sa.user_group_id')
);
return $this;
}
/**
* Builds the sub-editor filter
*/
protected function buildSubEditorFilter(Builder $query): self
{
$subEditorFilters = [
Application::ASSOC_TYPE_SECTION => $this->assignedSectionIds,
Application::ASSOC_TYPE_CATEGORY => $this->assignedCategoryIds,
];
foreach (array_filter($subEditorFilters, fn (?array $assocIds) => !empty($assocIds)) as $assocType => $assocIds) {
$query->whereExists(
fn (Builder $query) => $query->from('subeditor_submission_group', 'ssg')
->whereColumn('ssg.user_id', '=', 'u.user_id')
->where('ssg.assoc_type', '=', $assocType)
->whereIn('ssg.assoc_id', $assocIds)
);
}
return $this;
}
/**
* Builds the reviewer statistics and related filters
*/
protected function buildReviewerStatistics(Builder $query): self
{
if (!$this->includeReviewerData) {
return $this;
}
$disableSharedReviewerStatistics = (bool) Application::get()->getRequest()->getSite()->getData('disableSharedReviewerStatistics');
$dateDiff = fn (string $dateA, string $dateB): string => DB::connection() instanceof MySqlConnection
? "DATEDIFF({$dateA}, {$dateB})"
: "DATE_PART('day', {$dateA} - {$dateB})";
$query->leftJoinSub(
fn (Builder $query) => $query->from('review_assignments', 'ra')
->groupBy('ra.reviewer_id')
->select('ra.reviewer_id')
->selectRaw('MAX(ra.date_assigned) AS last_assigned')
->selectRaw('COUNT(CASE WHEN ra.date_completed IS NULL AND ra.declined = 0 AND ra.cancelled = 0 THEN 1 END) AS incomplete_count')
->selectRaw('COUNT(CASE WHEN ra.date_completed IS NOT NULL AND ra.declined = 0 THEN 1 END) AS complete_count')
->selectRaw('SUM(ra.declined) AS declined_count')
->selectRaw('SUM(ra.cancelled) AS cancelled_count')
->selectRaw('AVG(' . $dateDiff('ra.date_completed', 'ra.date_notified') . ') AS average_time')
->selectRaw('AVG(ra.quality) AS reviewer_rating')
->when($disableSharedReviewerStatistics, fn (Builder $query) => $query->join('submissions AS s', 'ra.submission_id', '=', 's.submission_id')
->when($this->contextIds !== null, fn (Builder $query) => $query->whereIn('s.context_id', $this->contextIds))),
'ra_stats',
'u.user_id',
'=',
'ra_stats.reviewer_id'
)
// Select all statistics columns
->addSelect('ra_stats.*')
// Reviewer rating
->when($this->reviewerRating !== null, fn (Builder $query) => $query->where('ra_stats.reviewer_rating', '>=', $this->reviewerRating))
// Completed reviews
->when($this->reviewsCompleted !== null, fn (Builder $query) => $query->where('ra_stats.complete_count', '>=', $this->reviewsCompleted))
// Minimum active reviews
->when(($minReviews = $this->reviewsActive[0] ?? null) !== null, fn (Builder $query) => $query->where('ra_stats.incomplete_count', '>=', $minReviews))
// Maximum active reviews
->when(($maxReviews = $this->reviewsActive[1] ?? null) !== null, fn (Builder $query) => $query->where('ra_stats.incomplete_count', '<=', $maxReviews))
// Minimum days since last review assignment
->when(
($minDays = $this->daysSinceLastAssignment[0] ?? null) !== null,
fn (Builder $query) => $query
->where('ra_stats.last_assigned', '<=', Carbon::now()->subDays($minDays)->toDateString())
)
// Maximum days since last review assignment
->when(
($maxDays = $this->daysSinceLastAssignment[1] ?? null) !== null,
fn (Builder $query) => $query
->where('ra_stats.last_assigned', '>=', Carbon::now()->subDays($maxDays + 1)->toDateString()) // Add one to include upper bound
)
// Average days to complete review
->when($this->averageCompletion !== null, fn (Builder $query) => $query->where('ra_stats.average_time', '<=', $this->averageCompletion));
return $this;
}
/**
* Builds the user search
*/
protected function buildSearchFilter(Builder $query): self
{
if ($this->searchPhrase === null || !strlen($searchPhrase = trim($this->searchPhrase))) {
return $this;
}
// Settings where the search will be performed
$settings = [Identity::IDENTITY_SETTING_GIVENNAME, Identity::IDENTITY_SETTING_FAMILYNAME, 'preferredPublicName', 'affiliation', 'biography', 'orcid'];
// Break words by whitespace, trims and escapes "%" and "_"
$words = array_map(fn (string $word) => '%' . addcslashes($word, '%_') . '%', PKPString::regexp_split('/\s+/', $searchPhrase));
foreach ($words as $word) {
$query->where(
fn ($query) => $query->whereRaw('LOWER(u.username) LIKE LOWER(?)', [$word])
->orWhereRaw('LOWER(u.email) LIKE LOWER(?)', [$word])
->orWhereExists(
fn (Builder $query) => $query->from('user_settings', 'us')
->whereColumn('us.user_id', '=', 'u.user_id')
->whereIn('us.setting_name', $settings)
->whereRaw('LOWER(us.setting_value) LIKE LOWER(?)', [$word])
)
->orWhereExists(
fn (Builder $query) => $query->from('user_interests', 'ui')
->join('controlled_vocab_entry_settings AS cves', 'ui.controlled_vocab_entry_id', '=', 'cves.controlled_vocab_entry_id')
->whereColumn('ui.user_id', '=', 'u.user_id')
->whereRaw('LOWER(cves.setting_value) LIKE LOWER(?)', [$word])
)
);
}
return $this;
}
/**
* Handles the order by clause
*/
protected function buildOrderBy(Builder $query): self
{
$orderByFields = [self::ORDERBY_ID => 'u.user_id'];
if ($orderByField = $orderByFields[$this->orderBy] ?? null) {
$query->orderBy($orderByField, $this->orderDirection);
return $this;
}
$nameSettings = [self::ORDERBY_GIVENNAME => Identity::IDENTITY_SETTING_GIVENNAME, self::ORDERBY_FAMILYNAME => Identity::IDENTITY_SETTING_FAMILYNAME];
if ($nameSettings[$this->orderBy] ?? null) {
$locales = array_unique(
empty($this->orderLocales)
? [Locale::getLocale(), Application::get()->getRequest()->getSite()->getPrimaryLocale()]
: array_values($this->orderLocales)
);
$sortedSettings = array_values($this->orderBy === self::ORDERBY_GIVENNAME ? $nameSettings : array_reverse($nameSettings));
$query->orderBy(
function (Builder $query) use ($sortedSettings, $locales): void {
$query->fromSub(fn (Builder $query) => $query->from(null)->selectRaw(0), 'placeholder');
$aliasesBySetting = [];
foreach ($sortedSettings as $i => $setting) {
$aliases = [];
foreach ($locales as $j => $locale) {
$aliases[] = $alias = "us_{$i}_{$j}";
$query->leftJoin(
"user_settings AS {$alias}",
fn (JoinClause $join) => $join
->on("{$alias}.user_id", '=', 'u.user_id')
->where("{$alias}.setting_name", '=', $setting)
->where("{$alias}.locale", '=', $locale)
);
}
$aliasesBySetting[] = $aliases;
}
// Build a possibly long CONCAT(COALESCE(given_localeA, given_localeB, [...]), COALESCE(family_localeA, family_localeB, [...])
$coalescedSettings = array_map(
fn (array $aliases) => 'COALESCE(' . implode(', ', array_map(fn (string $alias) => "{$alias}.setting_value", $aliases)) . ", '')",
$aliasesBySetting
);
$query->selectRaw('CONCAT(' . implode(', ', $coalescedSettings) . ')');
},
$this->orderDirection
);
}
return $this;
}
}
|