diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php index 3a04cf4490..9fdd576af1 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php @@ -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 $filters */ - $filters = $grouped['filters'] ?? []; - /** @var array $orderAttributes */ - $orderAttributes = $grouped['orderAttributes'] ?? []; - /** @var array $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 $templates - * @param array $filters - * @return array - */ - 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 $templates - * @param array $orderAttributes - * @param array $orderTypes - * @return array - */ - 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; - } } diff --git a/src/Appwrite/Utopia/Database/InMemoryQuery.php b/src/Appwrite/Utopia/Database/InMemoryQuery.php new file mode 100644 index 0000000000..c9f930369a --- /dev/null +++ b/src/Appwrite/Utopia/Database/InMemoryQuery.php @@ -0,0 +1,159 @@ + $documents + * @param array $filters + * @return array + */ + 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 $documents + * @param array $orderAttributes + * @param array $orderTypes + * @return array + */ + 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 $documents + * @return array + */ + 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 $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; + } +} diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php b/src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php new file mode 100644 index 0000000000..c2df415abd --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php @@ -0,0 +1,41 @@ + $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), + ]); + } +} diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php b/src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php new file mode 100644 index 0000000000..ea2b7c4cad --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php @@ -0,0 +1,24 @@ + 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); + } +}