generated from Nodarx/template
b1e92e5b07
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
234 lines
8.4 KiB
PHP
234 lines
8.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
*/
|
|
|
|
namespace KTXM\ProviderImap\Service\Cache;
|
|
|
|
use DateTimeImmutable;
|
|
use DateTimeZone;
|
|
use KTXF\Resource\Filter\FilterComparisonOperator;
|
|
use KTXF\Resource\Filter\FilterConjunctionOperator;
|
|
use KTXF\Resource\Filter\IFilter;
|
|
use KTXF\Resource\Sort\ISort;
|
|
use MongoDB\BSON\Regex;
|
|
|
|
/**
|
|
* Translates entity list filters and sorts into meta store queries.
|
|
*
|
|
* Mirrors LiveMailService's IMAP SEARCH / SORT translation so a cached list matches a
|
|
* live one: conditions are combined left to right with their conjunction, strings
|
|
* match as case-insensitive substrings, dates compare whole (UTC) days on `received`,
|
|
* sizes compare `size`. Conditions the cache cannot answer (body text, full text)
|
|
* make the whole filter a server filter.
|
|
*/
|
|
class MessageQueryBuilder
|
|
{
|
|
/** Attributes that need the message body, which the meta store does not hold */
|
|
private const SERVER_ATTRIBUTES = ['body', '*', 'all'];
|
|
|
|
/** Address fields: matched on address and display label */
|
|
private const ADDRESS_ATTRIBUTES = ['from', 'to', 'cc', 'bcc'];
|
|
|
|
/** Sorts on text fields use a case-insensitive collation */
|
|
private const TEXT_SORTS = ['subject', 'from', 'to'];
|
|
|
|
/**
|
|
* Whether the filter has to be evaluated by the server (IMAP SEARCH).
|
|
*/
|
|
public function needsServer(?IFilter $filter): bool
|
|
{
|
|
foreach ($filter?->conditions() ?? [] as $condition) {
|
|
if (in_array($condition['attribute'] ?? '', self::SERVER_ATTRIBUTES, true)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Meta store filter for a list filter; [] matches everything.
|
|
*
|
|
* Only valid when needsServer() is false.
|
|
*/
|
|
public function filter(?IFilter $filter): array
|
|
{
|
|
$expression = null;
|
|
|
|
foreach ($filter?->conditions() ?? [] as $condition) {
|
|
$operand = $this->operand($condition);
|
|
if ($operand === null) {
|
|
continue;
|
|
}
|
|
|
|
if ($expression === null) {
|
|
$expression = $operand;
|
|
continue;
|
|
}
|
|
|
|
$expression = (($condition['conjunction'] ?? FilterConjunctionOperator::AND) === FilterConjunctionOperator::OR)
|
|
? ['$or' => [$expression, $operand]]
|
|
: ['$and' => [$expression, $operand]];
|
|
}
|
|
|
|
return $expression ?? [];
|
|
}
|
|
|
|
/**
|
|
* Meta store sort for a list sort; always ends with uid so pages are stable.
|
|
*
|
|
* @return array{sort: array<string, int>, collation: ?array} collation is set when a text field is sorted
|
|
*/
|
|
public function sort(?ISort $sort): array
|
|
{
|
|
$order = [];
|
|
$text = false;
|
|
|
|
foreach ($sort?->conditions() ?? [] as $condition) {
|
|
$attribute = $condition['attribute'] ?? '';
|
|
$field = match ($attribute) {
|
|
'from' => 'from.address',
|
|
'to' => 'to.0.address',
|
|
'subject' => 'subject',
|
|
'received' => 'received',
|
|
'sent' => 'sent',
|
|
'size' => 'size',
|
|
default => null,
|
|
};
|
|
if ($field === null) {
|
|
continue;
|
|
}
|
|
|
|
$order[$field] = ($condition['direction'] ?? true) ? 1 : -1;
|
|
$text = $text || in_array($attribute, self::TEXT_SORTS, true);
|
|
}
|
|
|
|
$order['uid'] ??= 1;
|
|
|
|
return ['sort' => $order, 'collation' => $text ? ['locale' => 'en', 'strength' => 2] : null];
|
|
}
|
|
|
|
/**
|
|
* @param array{attribute:string, value:mixed, comparator?:FilterComparisonOperator, conjunction?:FilterConjunctionOperator|null} $condition
|
|
*/
|
|
private function operand(array $condition): ?array
|
|
{
|
|
$attribute = $condition['attribute'] ?? '';
|
|
$value = $condition['value'] ?? null;
|
|
$comparator = $condition['comparator'] ?? FilterComparisonOperator::EQ;
|
|
|
|
return match (true) {
|
|
$attribute === 'subject', in_array($attribute, self::ADDRESS_ATTRIBUTES, true) => $this->stringOperand($attribute, $value, $comparator),
|
|
$attribute === 'before', $attribute === 'after' => $this->dateOperand($attribute, $value, $comparator),
|
|
$attribute === 'min', $attribute === 'max' => $this->sizeOperand($attribute, $value, $comparator),
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
private function stringOperand(string $attribute, mixed $value, FilterComparisonOperator $comparator): ?array
|
|
{
|
|
$values = is_array($value) ? array_values($value) : [$value];
|
|
$values = array_values(array_filter(array_map(
|
|
static fn (mixed $item): string => trim((string) $item),
|
|
$values,
|
|
), static fn (string $item): bool => $item !== ''));
|
|
|
|
if ($values === []) {
|
|
return null;
|
|
}
|
|
|
|
$matches = array_map(fn (string $item): array => $this->stringMatch($attribute, $item), $values);
|
|
|
|
return match ($comparator) {
|
|
FilterComparisonOperator::EQ, FilterComparisonOperator::LIKE, FilterComparisonOperator::IN
|
|
=> count($matches) === 1 ? $matches[0] : ['$or' => $matches],
|
|
FilterComparisonOperator::NEQ, FilterComparisonOperator::NLIKE, FilterComparisonOperator::NIN
|
|
=> ['$nor' => $matches],
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
private function stringMatch(string $attribute, string $value): array
|
|
{
|
|
$regex = new Regex(preg_quote($value, '/'), 'i');
|
|
|
|
if ($attribute === 'subject') {
|
|
return ['subject' => $regex];
|
|
}
|
|
|
|
return ['$or' => [[$attribute . '.address' => $regex], [$attribute . '.label' => $regex]]];
|
|
}
|
|
|
|
private function dateOperand(string $attribute, mixed $value, FilterComparisonOperator $comparator): ?array
|
|
{
|
|
$day = $this->day($value);
|
|
if ($day === null) {
|
|
return null;
|
|
}
|
|
|
|
$start = $day->format('Y-m-d\TH:i:s\Z');
|
|
$end = $day->modify('+1 day')->format('Y-m-d\TH:i:s\Z');
|
|
$on = ['received' => ['$gte' => $start, '$lt' => $end]];
|
|
|
|
return match ($comparator) {
|
|
FilterComparisonOperator::EQ => $on,
|
|
FilterComparisonOperator::NEQ => ['$nor' => [$on]],
|
|
FilterComparisonOperator::LT, FilterComparisonOperator::LTE => $attribute === 'before' ? ['received' => ['$lt' => $start]] : null,
|
|
FilterComparisonOperator::GT, FilterComparisonOperator::GTE => $attribute === 'after' ? ['received' => ['$gte' => $start]] : null,
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
private function sizeOperand(string $attribute, mixed $value, FilterComparisonOperator $comparator): ?array
|
|
{
|
|
if (!is_int($value) && !is_numeric($value)) {
|
|
return null;
|
|
}
|
|
|
|
$size = max(0, (int) $value);
|
|
|
|
return match ($attribute) {
|
|
'min' => match ($comparator) {
|
|
FilterComparisonOperator::EQ, FilterComparisonOperator::GTE => ['size' => ['$gte' => $size]],
|
|
FilterComparisonOperator::GT => ['size' => ['$gt' => $size]],
|
|
FilterComparisonOperator::LT => ['size' => ['$lt' => $size]],
|
|
FilterComparisonOperator::LTE => ['size' => ['$lte' => $size]],
|
|
FilterComparisonOperator::NEQ => ['size' => ['$ne' => $size]],
|
|
default => null,
|
|
},
|
|
'max' => match ($comparator) {
|
|
FilterComparisonOperator::EQ, FilterComparisonOperator::LTE => ['size' => ['$lte' => $size]],
|
|
FilterComparisonOperator::LT => ['size' => ['$lt' => $size]],
|
|
FilterComparisonOperator::GT => ['size' => ['$gt' => $size]],
|
|
FilterComparisonOperator::GTE => ['size' => ['$gte' => $size]],
|
|
FilterComparisonOperator::NEQ => ['size' => ['$ne' => $size]],
|
|
default => null,
|
|
},
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Start of the (UTC) day a filter value falls on.
|
|
*/
|
|
private function day(mixed $value): ?DateTimeImmutable
|
|
{
|
|
if ($value === null || $value === '') {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$date = $value instanceof DateTimeImmutable ? $value : new DateTimeImmutable(trim((string) $value));
|
|
} catch (\Exception) {
|
|
return null;
|
|
}
|
|
|
|
return new DateTimeImmutable($date->format('Y-m-d'), new DateTimeZone('UTC'));
|
|
}
|
|
}
|