diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index c9cbbdecc..b16ecdd23 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -900,6 +900,13 @@ abstract public function getSupportForCastIndexArray(): bool; */ abstract public function getSupportForUpserts(): bool; + /** + * Are sum queries supported? + * + * @return bool + */ + abstract public function getSupportForSum(): bool; + /** * Get current attribute count from collection document * @@ -988,6 +995,25 @@ protected function getAttributeSelections(array $queries): array return $selections; } + /** + * Get all sum attributes from queries + * + * @param Query[] $queries + * @return array> + */ + protected function getAttributeSums(array $queries): array + { + $selections = []; + + foreach ($queries as $query) { + if ($query->getMethod() === Query::TYPE_SUM) { + $selections[] = $query->getValues(); + } + } + + return $selections; + } + /** * Filter Keys * diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index bbb04e722..e665a968b 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -2139,15 +2139,22 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $sqlLimit .= \is_null($offset) ? '' : ' OFFSET :offset'; $selections = $this->getAttributeSelections($queries); + $sumSelections = $this->getAttributeSums($queries); $sql = " SELECT {$this->getAttributeProjection($selections, 'table_main')} FROM {$this->getSQLTable($name)} AS table_main {$sqlWhere} {$sqlOrder} - {$sqlLimit}; + {$sqlLimit} "; + if (!empty($sumSelections)) { + $sql = "SELECT {$this->getSumQueries($sumSelections)} FROM ({$sql}) table_sum;"; + } else { + $sql .= ';'; + } + $sql = $this->trigger(Database::EVENT_DOCUMENT_FIND, $sql); $stmt = $this->getPDO()->prepare($sql); @@ -2434,6 +2441,30 @@ protected function getAttributeProjection(array $selections, string $prefix = '' return \implode(', ', $selections); } + /** + * Get the SQL sum queries given the selected attributes + * + * @param array|string> $attributeGroups + * @return string + */ + protected function getSumQueries(array $attributeGroups): string + { + $sumExpressions = []; + + foreach ($attributeGroups as $attributeGroup) { + if (\is_string($attributeGroup)) { + $attributeGroup = [$attributeGroup]; + } + + $columnAlias = \implode('+', $attributeGroup); + $sumExpression = implode('+', array_map(fn ($attribute) => "`{$this->filter($attribute)}`", $attributeGroup)); + + $sumExpressions[] = "SUM({$sumExpression}) AS `{$columnAlias}`"; + } + + return \implode(', ', $sumExpressions); + } + /** * Get SQL Condition * @@ -2614,6 +2645,16 @@ public function getSupportForUpserts(): bool return true; } + /** + * Are sum queries supported? + * + * @return bool + */ + public function getSupportForSum(): bool + { + return true; + } + /** * Set max execution time * @param int $milliseconds diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index c5f0522af..7a1f97b8d 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -1820,6 +1820,11 @@ public function getSupportForUpserts(): bool return false; } + public function getSupportForSum(): bool + { + return false; + } + /** * Get current attribute count from collection document * diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index c2b886776..c71de02fc 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -1917,15 +1917,22 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $sqlLimit = \is_null($limit) ? '' : 'LIMIT :limit'; $sqlLimit .= \is_null($offset) ? '' : ' OFFSET :offset'; $selections = $this->getAttributeSelections($queries); + $sumSelections = $this->getAttributeSums($queries); $sql = " SELECT {$this->getAttributeProjection($selections, 'table_main')} FROM {$this->getSQLTable($name)} as table_main {$sqlWhere} {$sqlOrder} - {$sqlLimit}; + {$sqlLimit} "; + if (!empty($sumSelections)) { + $sql = "SELECT {$this->getSumQueries($sumSelections)} FROM ({$sql}) table_sum;"; + } else { + $sql .= ';'; + } + $sql = $this->trigger(Database::EVENT_DOCUMENT_FIND, $sql); $stmt = $this->getPDO()->prepare($sql); @@ -2201,6 +2208,30 @@ protected function getAttributeProjection(array $selections, string $prefix = '' return \implode(', ', $selections); } + /** + * Get the SQL sum queries given the selected attributes + * + * @param array> $attributeGroups + * @return string + */ + protected function getSumQueries(array $attributeGroups): string + { + $sumExpressions = []; + + foreach ($attributeGroups as $attributeGroup) { + if (\is_string($attributeGroup)) { + $attributeGroup = [$attributeGroup]; + } + + $columnAlias = \implode('+', $attributeGroup); + $sumExpression = implode('+', array_map(fn ($attribute) => "\"{$this->filter($attribute)}\"", $attributeGroup)); + + $sumExpressions[] = "SUM({$sumExpression}) AS \"{$columnAlias}\""; + } + + return \implode(', ', $sumExpressions); + } + /** * Get SQL Condition @@ -2449,6 +2480,16 @@ public function getSupportForUpserts(): bool return false; } + /** + * Are sum queries supported? + * + * @return bool + */ + public function getSupportForSum(): bool + { + return true; + } + /** * @return string */ diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index ae05e3d9b..97056abab 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -879,7 +879,7 @@ public function getSupportForRelationships(): bool */ protected function bindConditionValue(mixed $stmt, Query $query): void { - if ($query->getMethod() == Query::TYPE_SELECT) { + if ($query->getMethod() == Query::TYPE_SELECT || $query->getMethod() == Query::TYPE_SUM) { return; } @@ -1134,7 +1134,7 @@ public function getSQLConditions(array $queries = [], string $separator = 'AND') $conditions = []; foreach ($queries as $query) { - if ($query->getMethod() === Query::TYPE_SELECT) { + if ($query->getMethod() === Query::TYPE_SELECT || $query->getMethod() === Query::TYPE_SUM) { continue; } diff --git a/src/Database/Database.php b/src/Database/Database.php index 1ccea9ec8..24fc168d1 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -5533,6 +5533,7 @@ public function find(string $collection, array $queries = [], string $forPermiss $selections = $this->validateSelections($collection, $selects); $nestedSelections = []; + $skipDecode = false; foreach ($queries as $index => &$query) { switch ($query->getMethod()) { @@ -5567,6 +5568,12 @@ public function find(string $collection, array $queries = [], string $forPermiss } $query->setValues(\array_values($values)); break; + case Query::TYPE_SUM: + $skipDecode = true; + if (\str_contains($query->getAttribute(), '.')) { + unset($queries[$index]); + } + break; default: if (\str_contains($query->getAttribute(), '.')) { unset($queries[$index]); @@ -5596,7 +5603,9 @@ public function find(string $collection, array $queries = [], string $forPermiss $node = $this->silent(fn () => $this->populateDocumentRelationships($collection, $node, $nestedSelections)); } $node = $this->casting($collection, $node); - $node = $this->decode($collection, $node, $selections); + if (!$skipDecode) { + $node = $this->decode($collection, $node, $selections); + } if (!$node->isEmpty()) { $node->setAttribute('$collection', $collection->getId()); diff --git a/src/Database/Query.php b/src/Database/Query.php index 7388453b2..006586a16 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -23,6 +23,7 @@ class Query public const TYPE_ENDS_WITH = 'endsWith'; public const TYPE_SELECT = 'select'; + public const TYPE_SUM = 'sum'; // Order methods public const TYPE_ORDER_DESC = 'orderDesc'; @@ -53,6 +54,7 @@ class Query self::TYPE_STARTS_WITH, self::TYPE_ENDS_WITH, self::TYPE_SELECT, + self::TYPE_SUM, self::TYPE_ORDER_DESC, self::TYPE_ORDER_ASC, self::TYPE_LIMIT, @@ -214,6 +216,7 @@ public static function isMethod(string $value): bool self::TYPE_ENDS_WITH, self::TYPE_OR, self::TYPE_AND, + self::TYPE_SUM, self::TYPE_SELECT => true, default => false, }; @@ -459,6 +462,17 @@ public static function select(array $attributes): self return new self(self::TYPE_SELECT, values: $attributes); } + /** + * Helper method to create Query with sum method + * + * @param array $attributes + * @return Query + */ + public static function sum(array $attributes): self + { + return new self(self::TYPE_SUM, values: $attributes); + } + /** * Helper method to create Query with orderDesc method * diff --git a/src/Database/Validator/Queries.php b/src/Database/Validator/Queries.php index b1d67aad0..2373e7e9d 100644 --- a/src/Database/Validator/Queries.php +++ b/src/Database/Validator/Queries.php @@ -61,6 +61,18 @@ public function isValid($value): bool return false; } + $methodsUsed = array_map( + fn (Query|string $query) => $query instanceof Query ? $query->getMethod() : $query, + $value + ); + + $methodsUsed = array_unique($methodsUsed); + + if (in_array(Query::TYPE_SELECT, $methodsUsed) && in_array(Query::TYPE_SUM, $methodsUsed)) { + $this->message = 'Invalid query: Cannot mix select and sum queries'; + return false; + } + foreach ($value as $query) { if (!$query instanceof Query) { try { @@ -80,6 +92,7 @@ public function isValid($value): bool $method = $query->getMethod(); $methodType = match ($method) { Query::TYPE_SELECT => Base::METHOD_TYPE_SELECT, + Query::TYPE_SUM => Base::METHOD_TYPE_SUM, Query::TYPE_LIMIT => Base::METHOD_TYPE_LIMIT, Query::TYPE_OFFSET => Base::METHOD_TYPE_OFFSET, Query::TYPE_CURSOR_AFTER, diff --git a/src/Database/Validator/Queries/Document.php b/src/Database/Validator/Queries/Document.php index 41c9f3f9b..776d508cf 100644 --- a/src/Database/Validator/Queries/Document.php +++ b/src/Database/Validator/Queries/Document.php @@ -35,7 +35,7 @@ public function __construct(array $attributes) ]); $validators = [ - new Select($attributes), + new Select($attributes) ]; parent::__construct($validators); diff --git a/src/Database/Validator/Queries/Documents.php b/src/Database/Validator/Queries/Documents.php index abce8694f..e16ce8428 100644 --- a/src/Database/Validator/Queries/Documents.php +++ b/src/Database/Validator/Queries/Documents.php @@ -12,6 +12,7 @@ use Utopia\Database\Validator\Query\Offset; use Utopia\Database\Validator\Query\Order; use Utopia\Database\Validator\Query\Select; +use Utopia\Database\Validator\Query\Sum; class Documents extends IndexedQueries { @@ -66,6 +67,7 @@ public function __construct( ), new Order($attributes), new Select($attributes), + new Sum($attributes), ]; parent::__construct($attributes, $indexes, $validators); diff --git a/src/Database/Validator/Query/Base.php b/src/Database/Validator/Query/Base.php index a37fdd65a..ef419e4cc 100644 --- a/src/Database/Validator/Query/Base.php +++ b/src/Database/Validator/Query/Base.php @@ -12,6 +12,7 @@ abstract class Base extends Validator public const METHOD_TYPE_ORDER = 'order'; public const METHOD_TYPE_FILTER = 'filter'; public const METHOD_TYPE_SELECT = 'select'; + public const METHOD_TYPE_SUM = 'sum'; protected string $message = 'Invalid query'; diff --git a/src/Database/Validator/Query/Sum.php b/src/Database/Validator/Query/Sum.php new file mode 100644 index 000000000..18b50bf61 --- /dev/null +++ b/src/Database/Validator/Query/Sum.php @@ -0,0 +1,100 @@ + + */ + protected array $schema = []; + + /** + * List of internal attributes + * + * @var array + */ + protected const INTERNAL_ATTRIBUTES = [ + '$id', + '$internalId', + '$createdAt', + '$updatedAt', + '$permissions', + '$collection', + ]; + + /** + * @param array $attributes + */ + public function __construct(array $attributes = []) + { + foreach ($attributes as $attribute) { + $this->schema[$attribute->getAttribute('key', $attribute->getAttribute('$id'))] = $attribute->getArrayCopy(); + } + } + + /** + * Is valid. + * + * Returns true if method is TYPE_SUM selections are valid + * + * Otherwise, returns false + * + * @param Query $value + * @return bool + */ + public function isValid($value): bool + { + if (!$value instanceof Query) { + return false; + } + + if ($value->getMethod() !== Query::TYPE_SUM) { + return false; + } + + $internalKeys = \array_map( + fn ($attr) => $attr['$id'], + Database::INTERNAL_ATTRIBUTES + ); + + foreach ($value->getValues() as $attribute) { + if (\str_contains($attribute, '.')) { + //special symbols with `dots` + if (isset($this->schema[$attribute])) { + continue; + } + + // For relationships, just validate the top level. + // Will validate each nested level during the recursive calls. + $attribute = \explode('.', $attribute)[0]; + } + + // Skip internal attributes + if (\in_array($attribute, $internalKeys)) { + continue; + } + + if (!isset($this->schema[$attribute]) && $attribute !== '*') { + $this->message = 'Attribute not found in schema: ' . $attribute; + return false; + } + + // Check if attribute is an numeric value + if ($this->schema[$attribute]['type'] !== Database::VAR_INTEGER && $this->schema[$attribute]['type'] !== Database::VAR_FLOAT) { + $this->message = 'Attribute is not a numeric value: ' . $attribute; + return false; + } + } + return true; + } + + public function getMethodType(): string + { + return self::METHOD_TYPE_SUM; + } +} diff --git a/tests/e2e/Adapter/Base.php b/tests/e2e/Adapter/Base.php index 0191ea853..91da329ff 100644 --- a/tests/e2e/Adapter/Base.php +++ b/tests/e2e/Adapter/Base.php @@ -17523,6 +17523,162 @@ public function testUpdateDocumentsRelationships(): void } } + public function testSumQueries(): void + { + if (!static::getDatabase()->getAdapter()->getSupportForSum()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database = static::getDatabase(); + + $database->createCollection( + 'testSumQueries', + attributes: [ + new Document([ + '$id' => ID::custom('integer'), + 'type' => Database::VAR_INTEGER, + 'size' => 64, + 'required' => true, + ]), + new Document([ + '$id' => ID::custom('float'), + 'type' => Database::VAR_FLOAT, + 'size' => 64, + 'required' => true, + ]), + new Document([ + '$id' => ID::custom('string'), + 'type' => Database::VAR_STRING, + 'size' => 64, + 'required' => true, + ]), + ], + permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()) + ], + documentSecurity: false + ); + + $documents = $database->createDocuments('testSumQueries', [ + new Document([ + '$id' => 'doc1', + 'integer' => 10, + 'float' => 10.5, + 'string' => 'test1', + ]), + new Document([ + '$id' => 'doc2', + 'integer' => 20, + 'float' => 20.5, + 'string' => 'test2', + ]), + ]); + $document = $documents[0]; + + // Check sum of integer attribute + $documents = $database->find('testSumQueries', [ + Query::sum(['integer']), + ]); + + $this->assertCount(1, $documents); + $this->assertEquals(30, $documents[0]->getAttribute('integer')); + $this->assertEquals(['integer'], array_keys($documents[0]->getAttributes())); + + // Check sum of float attribute + $documents = $database->find('testSumQueries', [ + Query::sum(['float']), + ]); + + $this->assertEquals(31, $documents[0]->getAttribute('float')); + + // Check sum of multiple attributes + $documents = $database->find('testSumQueries', [ + Query::sum(['integer', 'float']), + ]); + + $this->assertEquals(61, $documents[0]->getAttribute('integer+float')); + + // Check sum of multiple attributes separately + $documents = $database->find('testSumQueries', [ + Query::sum(['integer']), + Query::sum(['float']), + ]); + + $this->assertEquals(30, $documents[0]->getAttribute('integer')); + $this->assertEquals(31, $documents[0]->getAttribute('float')); + + // Expect Fail: Sum of non-numeric attribute + try { + $database->find('testSumQueries', [ + Query::sum(['string']), + ]); + $this->fail('Failed to throw exception'); + } catch (QueryException $e) { + $this->assertEquals('Invalid query: Attribute is not a numeric value: string', $e->getMessage()); + } + + // Expect Fail: Sum of non-existent attribute + try { + $database->find('testSumQueries', [ + Query::sum(['nonExistent']), + ]); + $this->fail('Failed to throw exception'); + } catch (QueryException $e) { + $this->assertEquals('Invalid query: Attribute not found in schema: nonExistent', $e->getMessage()); + } + + // Expect Fail: Mix Select and Sum + try { + $database->find('testSumQueries', [ + Query::sum(['integer']), + Query::select(['string']), + ]); + $this->fail('Failed to throw exception'); + } catch (QueryException $e) { + $this->assertEquals('Invalid query: Cannot mix select and sum queries', $e->getMessage()); + } + + /** + * Test sum alongside other queries + */ + $documents = $database->find('testSumQueries', [ + Query::sum(['integer']), + Query::limit(1), + ]); + + $this->assertCount(1, $documents); + $this->assertEquals(10, $documents[0]->getAttribute('integer')); + + $documents = $database->find('testSumQueries', [ + Query::sum(['integer']), + Query::offset(1), + ]); + + $this->assertCount(1, $documents); + $this->assertEquals(20, $documents[0]->getAttribute('integer')); + + $documents = $database->find('testSumQueries', [ + Query::sum(['integer']), + Query::cursorAfter($document), + ]); + + $this->assertCount(1, $documents); + $this->assertEquals(20, $documents[0]->getAttribute('integer')); + + $documents = $database->find('testSumQueries', [ + Query::sum(['integer']), + Query::endsWith('string', 'st2'), + ]); + + $this->assertCount(1, $documents); + $this->assertEquals(20, $documents[0]->getAttribute('integer')); + + $database->deleteCollection('testSumQueries'); + } public function testNestedQueryValidation(): void { $this->getDatabase()->createCollection(__FUNCTION__, [ diff --git a/tests/unit/QueryTest.php b/tests/unit/QueryTest.php index d9ad6cd93..3875c24ff 100644 --- a/tests/unit/QueryTest.php +++ b/tests/unit/QueryTest.php @@ -198,6 +198,11 @@ public function testParse(): void $this->assertEquals(1, $query->getAttribute()); $this->assertEquals(['Hello /\ World'], $query->getValues()); + $query = Query::parse(Query::sum(['title', 'director'])->toString()); + $this->assertEquals('sum', $query->getMethod()); + $this->assertEquals(null, $query->getAttribute()); + $this->assertEquals(['title', 'director'], $query->getValues()); + $json = Query::or([ Query::equal('actors', ['Brad Pitt']), Query::equal('actors', ['Johnny Depp']) @@ -262,6 +267,7 @@ public function testIsMethod(): void $this->assertTrue(Query::isMethod('isNotNull')); $this->assertTrue(Query::isMethod('between')); $this->assertTrue(Query::isMethod('select')); + $this->assertTrue(Query::isMethod('sum')); $this->assertTrue(Query::isMethod('or')); $this->assertTrue(Query::isMethod('and')); @@ -283,6 +289,7 @@ public function testIsMethod(): void $this->assertTrue(Query::isMethod(QUERY::TYPE_IS_NOT_NULL)); $this->assertTrue(Query::isMethod(QUERY::TYPE_BETWEEN)); $this->assertTrue(Query::isMethod(QUERY::TYPE_SELECT)); + $this->assertTrue(Query::isMethod(QUERY::TYPE_SUM)); $this->assertTrue(Query::isMethod(QUERY::TYPE_OR)); $this->assertTrue(Query::isMethod(QUERY::TYPE_AND)); diff --git a/tests/unit/Validator/Query/SumTest.php b/tests/unit/Validator/Query/SumTest.php new file mode 100644 index 000000000..06e4c6d2b --- /dev/null +++ b/tests/unit/Validator/Query/SumTest.php @@ -0,0 +1,58 @@ +validator = new Sum( + attributes: [ + new Document([ + '$id' => 'value', + 'key' => 'value', + 'type' => Database::VAR_INTEGER, + 'array' => false, + ]), + new Document([ + '$id' => 'valueFloat', + 'key' => 'valueFloat', + 'type' => Database::VAR_FLOAT, + 'array' => false, + ]), + new Document([ + '$id' => 'valueStr', + 'key' => 'valueStr', + 'type' => Database::VAR_STRING, + 'array' => false, + ]), + ], + ); + } + + public function testValueSuccess(): void + { + $this->assertTrue($this->validator->isValid(Query::sum(['value']))); + $this->assertTrue($this->validator->isValid(Query::sum(['valueFloat']))); + } + + public function testValueFailure(): void + { + $this->assertFalse($this->validator->isValid(Query::limit(1))); + $this->assertEquals('Invalid query', $this->validator->getDescription()); + $this->assertFalse($this->validator->isValid(Query::sum(['valueStr']))); + } +}