Improve code quality

This commit is contained in:
Matej Bačo
2026-04-17 17:42:46 +02:00
parent dc704fdb51
commit bb38bf4248
4 changed files with 230 additions and 122 deletions
@@ -6,17 +6,13 @@ use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\InMemoryQuery;
use Appwrite\Utopia\Database\Validator\Queries\ProjectTemplates;
use Appwrite\Utopia\Response;
use Utopia\Config\Config;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Query;
use Utopia\Database\Validator\Queries;
use Utopia\Database\Validator\Query\Filter;
use Utopia\Database\Validator\Query\Limit;
use Utopia\Database\Validator\Query\Offset;
use Utopia\Database\Validator\Query\Order;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Boolean;
@@ -25,17 +21,6 @@ class XList extends Action
{
use HTTP;
private const ALLOWED_ATTRIBUTES = [
'type' => Database::VAR_STRING,
'locale' => Database::VAR_STRING,
'subject' => Database::VAR_STRING,
'message' => Database::VAR_STRING,
'senderName' => Database::VAR_STRING,
'senderEmail' => Database::VAR_STRING,
'replyTo' => Database::VAR_STRING,
'custom' => Database::VAR_BOOLEAN,
];
public static function getName()
{
return 'listProjectEmailTemplates';
@@ -43,22 +28,6 @@ class XList extends Action
public function __construct()
{
$attributes = [];
foreach (self::ALLOWED_ATTRIBUTES as $key => $type) {
$attributes[] = new Document([
'key' => $key,
'type' => $type,
'array' => false,
]);
}
$queriesValidator = new Queries([
new Limit(),
new Offset(),
new Filter($attributes, Database::VAR_STRING, APP_DATABASE_QUERY_MAX_VALUES),
new Order($attributes),
]);
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/project/templates/email')
@@ -80,7 +49,7 @@ class XList extends Action
)
]
))
->param('queries', [], $queriesValidator, 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter and order on the following attributes: ' . implode(', ', array_keys(self::ALLOWED_ATTRIBUTES)), true)
->param('queries', [], new ProjectTemplates(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter and order on the following attributes: ' . implode(', ', array_keys(ProjectTemplates::ALLOWED_ATTRIBUTES)), true)
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
->inject('project')
->inject('response')
@@ -106,15 +75,6 @@ class XList extends Action
}
$grouped = Query::groupByType($queries);
$limit = $grouped['limit'] ?? APP_LIMIT_COUNT;
$offset = $grouped['offset'] ?? 0;
/** @var array<Query> $filters */
$filters = $grouped['filters'] ?? [];
/** @var array<string> $orderAttributes */
$orderAttributes = $grouped['orderAttributes'] ?? [];
/** @var array<string> $orderTypes */
$orderTypes = $grouped['orderTypes'] ?? [];
$types = Config::getParam('locale-templates')['email'] ?? [];
$projectTemplates = $project->getAttribute('templates', []);
@@ -138,91 +98,15 @@ class XList extends Action
}
}
$templates = $this->applyFilters($templates, $filters);
$templates = $this->applyOrder($templates, $orderAttributes, $orderTypes);
$templates = InMemoryQuery::filter($templates, $grouped['filters']);
$templates = InMemoryQuery::order($templates, $grouped['orderAttributes'], $grouped['orderTypes']);
$total = $includeTotal ? \count($templates) : 0;
$templates = \array_slice($templates, $offset, $limit);
$templates = InMemoryQuery::paginate($templates, $grouped['limit'] ?? APP_LIMIT_COUNT, $grouped['offset']);
$response->dynamic(new Document([
'templates' => $templates,
'total' => $total,
]), Response::MODEL_EMAIL_TEMPLATE_LIST);
}
/**
* @param array<Document> $templates
* @param array<Query> $filters
* @return array<Document>
*/
private function applyFilters(array $templates, array $filters): array
{
if (empty($filters)) {
return $templates;
}
return \array_values(\array_filter($templates, function (Document $template) use ($filters) {
foreach ($filters as $filter) {
if (!$this->matches($template, $filter)) {
return false;
}
}
return true;
}));
}
private function matches(Document $template, Query $filter): bool
{
$attribute = $filter->getAttribute();
$values = $filter->getValues();
$actual = $template->getAttribute($attribute);
$needle = (string) ($values[0] ?? '');
return match ($filter->getMethod()) {
Query::TYPE_EQUAL => \in_array($actual, $values, false),
Query::TYPE_NOT_EQUAL => !\in_array($actual, $values, false),
Query::TYPE_STARTS_WITH => \is_string($actual) && \str_starts_with($actual, $needle),
Query::TYPE_NOT_STARTS_WITH => \is_string($actual) && !\str_starts_with($actual, $needle),
Query::TYPE_ENDS_WITH => \is_string($actual) && \str_ends_with($actual, $needle),
Query::TYPE_NOT_ENDS_WITH => \is_string($actual) && !\str_ends_with($actual, $needle),
Query::TYPE_CONTAINS => \is_string($actual) && \str_contains($actual, $needle),
Query::TYPE_NOT_CONTAINS => \is_string($actual) && !\str_contains($actual, $needle),
Query::TYPE_SEARCH => \is_string($actual) && \stripos($actual, $needle) !== false,
Query::TYPE_NOT_SEARCH => \is_string($actual) && \stripos($actual, $needle) === false,
Query::TYPE_IS_NULL => $actual === null || $actual === '',
Query::TYPE_IS_NOT_NULL => $actual !== null && $actual !== '',
default => throw new Exception(Exception::GENERAL_QUERY_INVALID, 'Query method not supported for email templates: ' . $filter->getMethod()),
};
}
/**
* @param array<Document> $templates
* @param array<string> $orderAttributes
* @param array<string> $orderTypes
* @return array<Document>
*/
private function applyOrder(array $templates, array $orderAttributes, array $orderTypes): array
{
if (empty($orderAttributes)) {
return $templates;
}
\usort($templates, function (Document $a, Document $b) use ($orderAttributes, $orderTypes) {
foreach ($orderAttributes as $index => $attribute) {
$direction = \strtoupper($orderTypes[$index] ?? Database::ORDER_ASC);
$valueA = $a->getAttribute($attribute);
$valueB = $b->getAttribute($attribute);
$cmp = $valueA <=> $valueB;
if ($cmp === 0) {
continue;
}
return $direction === Database::ORDER_DESC ? -$cmp : $cmp;
}
return 0;
});
return $templates;
}
}
@@ -0,0 +1,159 @@
<?php
namespace Appwrite\Utopia\Database;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Query;
/**
* Applies filter, order, and pagination queries to an in-memory array of Documents.
*
* Intended for list endpoints whose full dataset is materialized in memory (e.g. built
* from config and project attributes rather than a database collection).
*/
class InMemoryQuery
{
/**
* Filter documents using AND-combined query filters.
*
* @param array<Document> $documents
* @param array<Query> $filters
* @return array<Document>
*/
public static function filter(array $documents, array $filters): array
{
if (empty($filters)) {
return \array_values($documents);
}
return \array_values(\array_filter($documents, function (Document $document) use ($filters) {
foreach ($filters as $filter) {
if (!self::matches($document, $filter)) {
return false;
}
}
return true;
}));
}
/**
* Evaluate a single filter query against a document.
*/
public static function matches(Document $document, Query $filter): bool
{
$attribute = $filter->getAttribute();
$values = $filter->getValues();
$actual = $document->getAttribute($attribute);
$needle = (string) ($values[0] ?? '');
return match ($filter->getMethod()) {
Query::TYPE_EQUAL => \in_array($actual, $values, false),
Query::TYPE_NOT_EQUAL => !\in_array($actual, $values, false),
Query::TYPE_LESSER => self::compareScalar($actual, $values[0] ?? null) < 0,
Query::TYPE_LESSER_EQUAL => self::compareScalar($actual, $values[0] ?? null) <= 0,
Query::TYPE_GREATER => self::compareScalar($actual, $values[0] ?? null) > 0,
Query::TYPE_GREATER_EQUAL => self::compareScalar($actual, $values[0] ?? null) >= 0,
Query::TYPE_BETWEEN => self::compareScalar($actual, $values[0] ?? null) >= 0 && self::compareScalar($actual, $values[1] ?? null) <= 0,
Query::TYPE_NOT_BETWEEN => self::compareScalar($actual, $values[0] ?? null) < 0 || self::compareScalar($actual, $values[1] ?? null) > 0,
Query::TYPE_STARTS_WITH => \is_string($actual) && \str_starts_with($actual, $needle),
Query::TYPE_NOT_STARTS_WITH => \is_string($actual) && !\str_starts_with($actual, $needle),
Query::TYPE_ENDS_WITH => \is_string($actual) && \str_ends_with($actual, $needle),
Query::TYPE_NOT_ENDS_WITH => \is_string($actual) && !\str_ends_with($actual, $needle),
Query::TYPE_CONTAINS => self::containsValue($actual, $values),
Query::TYPE_NOT_CONTAINS => !self::containsValue($actual, $values),
Query::TYPE_SEARCH => \is_string($actual) && $needle !== '' && \stripos($actual, $needle) !== false,
Query::TYPE_NOT_SEARCH => \is_string($actual) && ($needle === '' || \stripos($actual, $needle) === false),
Query::TYPE_IS_NULL => $actual === null,
Query::TYPE_IS_NOT_NULL => $actual !== null,
default => throw new \InvalidArgumentException('Unsupported query method: ' . $filter->getMethod()),
};
}
/**
* Sort documents by one or more attributes.
*
* @param array<Document> $documents
* @param array<string> $orderAttributes
* @param array<string> $orderTypes
* @return array<Document>
*/
public static function order(array $documents, array $orderAttributes, array $orderTypes): array
{
if (empty($orderAttributes)) {
return \array_values($documents);
}
$documents = \array_values($documents);
\usort($documents, function (Document $a, Document $b) use ($orderAttributes, $orderTypes) {
foreach ($orderAttributes as $index => $attribute) {
$direction = \strtoupper($orderTypes[$index] ?? Database::ORDER_ASC);
$cmp = self::compareScalar($a->getAttribute($attribute), $b->getAttribute($attribute));
if ($cmp !== 0) {
return $direction === Database::ORDER_DESC ? -$cmp : $cmp;
}
}
return 0;
});
return $documents;
}
/**
* Apply limit and offset.
*
* @param array<Document> $documents
* @return array<Document>
*/
public static function paginate(array $documents, ?int $limit, ?int $offset): array
{
return \array_slice(\array_values($documents), $offset ?? 0, $limit);
}
/**
* Compare two scalars in a way that handles null consistently (null sorts before any value).
*/
private static function compareScalar(mixed $a, mixed $b): int
{
if ($a === null && $b === null) {
return 0;
}
if ($a === null) {
return -1;
}
if ($b === null) {
return 1;
}
return $a <=> $b;
}
/**
* Check if an attribute contains any of the given values. Handles both array attributes
* (checks membership) and string attributes (checks substring).
*
* @param array<mixed> $values
*/
private static function containsValue(mixed $actual, array $values): bool
{
if (\is_array($actual)) {
foreach ($values as $value) {
if (\in_array($value, $actual, false)) {
return true;
}
}
return false;
}
if (\is_string($actual)) {
foreach ($values as $value) {
if (\str_contains($actual, (string) $value)) {
return true;
}
}
return false;
}
return false;
}
}
@@ -0,0 +1,41 @@
<?php
namespace Appwrite\Utopia\Database\Validator\Queries;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\Queries;
use Utopia\Database\Validator\Query\Filter;
use Utopia\Database\Validator\Query\Limit;
use Utopia\Database\Validator\Query\Offset;
use Utopia\Database\Validator\Query\Order;
/**
* Queries validator for list endpoints whose dataset is materialized in memory rather
* than backed by a database collection. Pairs with {@see \Appwrite\Utopia\Database\InMemoryQuery}
* to apply the validated queries.
*/
class BaseInMemory extends Queries
{
/**
* @param array<string, string> $allowedAttributes Map of attribute key to Database::VAR_* type
*/
public function __construct(array $allowedAttributes)
{
$attributes = [];
foreach ($allowedAttributes as $key => $type) {
$attributes[] = new Document([
'key' => $key,
'type' => $type,
'array' => false,
]);
}
parent::__construct([
new Limit(),
new Offset(),
new Filter($attributes, Database::VAR_STRING, APP_DATABASE_QUERY_MAX_VALUES),
new Order($attributes),
]);
}
}
@@ -0,0 +1,24 @@
<?php
namespace Appwrite\Utopia\Database\Validator\Queries;
use Utopia\Database\Database;
class ProjectTemplates extends BaseInMemory
{
public const ALLOWED_ATTRIBUTES = [
'type' => Database::VAR_STRING,
'locale' => Database::VAR_STRING,
'subject' => Database::VAR_STRING,
'message' => Database::VAR_STRING,
'senderName' => Database::VAR_STRING,
'senderEmail' => Database::VAR_STRING,
'replyTo' => Database::VAR_STRING,
'custom' => Database::VAR_BOOLEAN,
];
public function __construct()
{
parent::__construct(self::ALLOWED_ATTRIBUTES);
}
}