From d1b9f2f9c440ddad3344bd88a75d16c6da7915ae Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 16 Oct 2026 00:11:22 +1300 Subject: [PATCH] feat(aggregate): type, name and decode rows as a read returns them aggregate() handed back each row as the driver fetched it: numbers as text under MariaDB, MySQL and PostgreSQL, a sum of integers as a DECIMAL string such as "7.0000", booleans as 0/1, datetimes in the engine's format, arrays as JSON, filtered attributes still encoded, internal groups under their storage column (`_uid`) and joined groups bare, so a consumer had to rebuild each row against the schema. Appwrite does that in Appwrite\Utopia\Database\Aggregation; this moves it into the library. Database\Aggregation shapes each row from the queries and the read collections: counts and bitwise aggregates are integers, averages, deviations and variances floats, a sum an integer over an integer attribute and a float otherwise, and min()/max() take their attribute's type. Each group is named as a read names its attribute (`$id`, and a joined one qualified by its join alias, also when the queries name it bare), cast as a read casts it and passed through its attribute's filters. The tenant group is returned as read, as find() returns it. fetch() hands back the joined collections it resolved, so no join is resolved twice, and the default alias is built without a regex. Mongo, Memory and Redis still refuse aggregates. Tests that asserted the old storage-column and bare joined names now assert the read names. Co-Authored-By: Claude Opus 5.5 --- src/Database/Aggregation.php | 412 ++++++++++++++++++ src/Database/Trait/Documents.php | 22 +- tests/e2e/Adapter/Scopes/AggregationTests.php | 87 +++- tests/e2e/Adapter/Scopes/DocumentTests.php | 6 +- tests/e2e/Adapter/Scopes/JoinTests.php | 11 +- tests/unit/AggregationTest.php | 360 +++++++++++++++ tests/unit/Documents/AggregateOrderTest.php | 4 +- tests/unit/Documents/AggregateSelectTest.php | 17 +- tests/unit/Joins/AggregateMinorsTest.php | 6 +- tests/unit/Joins/AggregateResultNamesTest.php | 2 +- tests/unit/Joins/JoinInternalColumnsTest.php | 9 +- .../Joins/JoinedAttributeResolutionTest.php | 4 +- tests/unit/Joins/JoinedShapesTest.php | 6 +- .../FullOuterJoinAggregateVisibilityTest.php | 10 +- 14 files changed, 910 insertions(+), 46 deletions(-) create mode 100644 src/Database/Aggregation.php create mode 100644 tests/unit/AggregationTest.php diff --git a/src/Database/Aggregation.php b/src/Database/Aggregation.php new file mode 100644 index 000000000..28b9269f8 --- /dev/null +++ b/src/Database/Aggregation.php @@ -0,0 +1,412 @@ + $aggregates The method of each aggregate, keyed by its alias + * @param array $operands The attribute each aggregate reads, keyed by its alias + * @param array $groups The name a read returns each group under, keyed as the queries name it + * @param array $attributes The attribute of each group but the tenant, which a read returns as the adapter reads it, keyed by the name a read returns it under + * @param array $sources The join alias each group is read from, '' for the collection read, keyed by the name a read returns it under + * @param array $internal The keys of the attributes every document carries + */ + private function __construct( + private Database $database, + private array $aggregates, + private array $operands, + private array $groups, + private array $attributes, + private array $sources, + private array $internal, + ) { + } + + /** + * @param Database $database The database whose filters decode the groups + * @param Document $collection The collection read, with its attribute documents + * @param array $joined The collection each join reads, keyed by join alias + * @param array $queries The queries the rows were aggregated by, each aggregate aliased + */ + public static function from(Database $database, Document $collection, array $joined, array $queries): self + { + $internal = $database->internalAttributes(); + + $schema = []; + $origins = []; + foreach ([...$internal, ...self::attributes($collection)] as $attribute) { + $schema[$attribute->key] = $attribute; + $origins[$attribute->key] = ''; + } + foreach ($joined as $alias => $definition) { + foreach ([...$internal, ...self::attributes($definition)] as $attribute) { + $schema[$alias.'.'.$attribute->key] = $attribute; + $origins[$alias.'.'.$attribute->key] = $alias; + } + } + + $aggregates = []; + $operands = []; + $groups = []; + $attributes = []; + $sources = []; + foreach ($queries as $query) { + $method = $query->getMethod(); + if ($method->isAggregate()) { + $alias = $query->getAlias(); + $aggregates[$alias] = $method; + $operand = $schema[self::name($schema, $query->getAttribute())] ?? null; + if ($operand !== null) { + $operands[$alias] = $operand; + } + + continue; + } + + if ($method !== Method::GroupBy) { + continue; + } + + foreach ($query->getValues() as $group) { + if (! \is_string($group) || $group === '') { + continue; + } + + $name = self::name($schema, $group); + $groups[$group] = $name; + if (isset($schema[$name]) && $schema[$name]->key !== Document::TENANT) { + $attributes[$name] = $schema[$name]; + $sources[$name] = $origins[$name]; + } + } + } + + $keys = []; + foreach ($internal as $attribute) { + $keys[$attribute->key] = true; + } + + return new self($database, $aggregates, $operands, $groups, $attributes, $sources, $keys); + } + + /** + * @param array $row A row as the adapter reads it + * @return array + * + * @throws DatabaseException When a group's filter cannot decode it + */ + public function decode(array $row): array + { + $names = $this->names(\array_keys($row)); + + $shaped = []; + $filtered = []; + foreach ($row as $column => $value) { + $method = $this->aggregates[$column] ?? null; + if ($method !== null) { + $shaped[$column] = $this->aggregate($method, $this->operands[$column] ?? null, $value); + + continue; + } + + $name = $names[$column] ?? $column; + $attribute = $this->attributes[$name] ?? null; + if ($attribute === null) { + $shaped[$name] = $value; + + continue; + } + + $value = self::cast($attribute, $value); + $shaped[$name] = $value; + if ($value !== null && $attribute->filters !== []) { + $filtered[$this->sources[$name]][$name] = $value; + } + } + + foreach ($filtered as $alias => $values) { + foreach ($this->filter($alias, $values) as $name => $value) { + $shaped[$name] = $value; + } + } + + return $shaped; + } + + /** + * The name aggregate() gives an aggregate the queries leave unaliased: `_`, or `` for + * count('*'), with each character other than a letter, digit or underscore replaced by an underscore. + */ + public static function alias(Query $query): string + { + $method = $query->getMethod()->value; + $attribute = $query->getAttribute(); + $alias = $attribute === '*' || $attribute === '' ? $method : $method.'_'.$attribute; + + $sanitized = ''; + foreach (\str_split($alias) as $character) { + $sanitized .= \ctype_alnum($character) || $character === '_' ? $character : '_'; + } + + return \substr($sanitized, 0, Validator\Query\Aggregate::MAX_ALIAS_LENGTH); + } + + /** + * Passes groups read from one collection through the filters their attributes declare, as a read of that + * collection decodes them. + * + * @param string $alias The join alias the groups are read from, '' for the collection read + * @param array $values The cast value of each group, keyed by the name a read returns it under + * @return array + * + * @throws DatabaseException + */ + private function filter(string $alias, array $values): array + { + $prefix = $alias === '' ? '' : $alias.'.'; + + $keys = []; + $definitions = []; + $stored = new Document(); + foreach ($values as $name => $value) { + $key = \substr($name, \strlen($prefix)); + $keys[$name] = $key; + $stored->setAttribute($key, $value); + if (! isset($this->internal[$key])) { + $definitions[] = $this->attributes[$name]->toDocument(); + } + } + + $decoded = $this->database->decode( + new Document([Document::ID => $alias, 'attributes' => $definitions]), + $stored, + \array_values($keys), + ); + + $filtered = []; + foreach ($keys as $name => $key) { + $filtered[$name] = $decoded->getAttribute($key); + } + + return $filtered; + } + + /** + * The name a read returns each group column of a row under. A column is named as the queries name its group, or + * failing that by the group's storage column. + * + * @param list $columns + * @return array + */ + private function names(array $columns): array + { + $unclaimed = []; + foreach ($columns as $column) { + if (! isset($this->aggregates[$column])) { + $unclaimed[$column] = true; + } + } + + $names = []; + $pending = []; + foreach ($this->groups as $group => $name) { + if (isset($unclaimed[$group])) { + $names[$group] = $name; + unset($unclaimed[$group]); + } else { + $pending[$group] = $name; + } + } + + foreach ($pending as $group => $name) { + $dot = \strpos($group, '.'); + $key = $dot === false ? $group : \substr($group, $dot + 1); + foreach ([Storage::column($key), $key] as $column) { + if (isset($unclaimed[$column])) { + $names[$column] = $name; + unset($unclaimed[$column]); + break; + } + } + } + + return $names; + } + + private function aggregate(Method $method, ?Attribute $operand, mixed $value): mixed + { + if ($value === null) { + return null; + } + + return match (true) { + \in_array($method, self::COUNTS, true) => self::integer($value), + \in_array($method, self::STATISTICS, true) => self::float($value), + $method === Method::Sum => $operand !== null && \in_array($operand->type, self::INTEGERS, true) + ? self::integer($value) + : self::float($value), + default => $operand === null ? $value : self::cast($operand, $value), + }; + } + + /** + * The name a read returns an attribute under: a joined one qualified by its join alias, though the queries may + * name it bare when no other collection of the read declares it. + * + * @param array $schema + */ + private static function name(array $schema, string $attribute): string + { + if (\str_contains($attribute, '.') || isset($schema[$attribute])) { + return $attribute; + } + + $suffix = '.'.$attribute; + $matches = []; + foreach (\array_keys($schema) as $name) { + if (\str_ends_with($name, $suffix)) { + $matches[] = $name; + } + } + + return \count($matches) === 1 ? $matches[0] : $attribute; + } + + private static function cast(Attribute $attribute, mixed $value): mixed + { + if ($value === null) { + return null; + } + + $encoded = $attribute->array + || \in_array($attribute->type, self::OBJECTS, true) + || \in_array(Filter::Json->value, $attribute->filters, true); + + if ($encoded && \is_string($value)) { + $value = \json_decode($value, true); + } + + if (! $attribute->array || ! \is_array($value)) { + return self::scalar($attribute->type, $value); + } + + return \array_map(static fn (mixed $node): mixed => self::scalar($attribute->type, $node), $value); + } + + private static function scalar(ColumnType $type, mixed $value): mixed + { + if ($value === null || \is_array($value)) { + return $value; + } + + return match (true) { + $type === ColumnType::Boolean => \is_bool($value) ? $value : \filter_var($value, FILTER_VALIDATE_BOOLEAN), + $type === ColumnType::Id => \is_scalar($value) ? (string) $value : $value, + \in_array($type, self::INTEGERS, true) => self::integer($value), + \in_array($type, self::FLOATS, true) => self::float($value), + \in_array($type, self::DATETIMES, true) => \is_string($value) ? DateTime::formatTz($value) : $value, + default => $value, + }; + } + + /** + * MariaDB sums integers into DECIMAL, so a whole value can arrive as "7.0000"; one past the platform's integers + * stays a float rather than wrap. + */ + private static function integer(mixed $value): int|float + { + if (\is_int($value)) { + return $value; + } + + $integer = \filter_var($value, FILTER_VALIDATE_INT); + if ($integer !== false) { + return $integer; + } + + $float = self::float($value); + + return \floor($float) === $float && \abs($float) < PHP_INT_MAX ? (int) $float : $float; + } + + private static function float(mixed $value): float + { + return \is_numeric($value) ? (float) $value : 0.0; + } + + /** + * @return list + */ + private static function attributes(Document $collection): array + { + $attributes = []; + $definitions = $collection->getAttribute('attributes', []); + foreach (\is_array($definitions) ? $definitions : [] as $attribute) { + if (! $attribute instanceof Document) { + continue; + } + + try { + $attributes[] = Attribute::fromDocument($attribute); + } catch (Schema) { + continue; + } + } + + return $attributes; + } +} diff --git a/src/Database/Trait/Documents.php b/src/Database/Trait/Documents.php index 724cb2a00..4cd80ed09 100644 --- a/src/Database/Trait/Documents.php +++ b/src/Database/Trait/Documents.php @@ -12,6 +12,7 @@ use Utopia\Database\Adapter; use Utopia\Database\Adapter\Feature; use Utopia\Database\Adapter\ReadWritePool; +use Utopia\Database\Aggregation; use Utopia\Database\Attribute; use Utopia\Database\Cache\Epoch; use Utopia\Database\Cache\Owners; @@ -4209,6 +4210,8 @@ public function find(string $collection, array $queries = [], PermissionType $fo * @param array|null $adapterQueries Receives the queries as the adapter's find() was handed them, or * null when they can match no document and the adapter is not read * @param-out array|null $adapterQueries + * @param array $joinedByAlias Receives the collection each join reads, keyed by join alias + * @param-out array $joinedByAlias * @return array * * @throws DatabaseException @@ -4216,9 +4219,10 @@ public function find(string $collection, array $queries = [], PermissionType $fo * @throws TimeoutException * @throws Exception */ - private function fetch(Collection $collection, array $queries, PermissionType $forPermission, bool $aggregate, ?array &$adapterQueries = null): array + private function fetch(Collection $collection, array $queries, PermissionType $forPermission, bool $aggregate, ?array &$adapterQueries = null, array &$joinedByAlias = []): array { $adapterQueries = null; + $joinedByAlias = []; $queryCacheQueries = $queries; @@ -5084,6 +5088,10 @@ private function pages( * Run aggregation queries (count, sum, avg, min, max, groupBy) and return one row per group. An aggregate without * an alias comes back under `_`, or `` for count('*'). * + * Counts and bitwise aggregates are integers, averages, deviations and variances floats, and a sum an integer over + * an integer attribute and a float otherwise; min() and max() are typed as their attribute. Each group is named as + * a read names its attribute, a joined one qualified by its join alias, and typed and decoded as a read returns it. + * * @param array $queries At least one aggregate or groupBy query * @return list> * @@ -5097,13 +5105,18 @@ public function aggregate(string $collection, array $queries): array $queries = self::aliasAggregates($this->rewriteQueries(Event::DocumentAggregate, $definition, $queries)); $floating = self::floatingAggregates($queries); + $adapterQueries = null; + $joinedByAlias = []; + $results = $this->fetch($definition, $queries, PermissionType::Read, true, $adapterQueries, $joinedByAlias); + $aggregation = Aggregation::from($this, $definition, $joinedByAlias, $queries); + $rows = []; - foreach ($this->fetch($definition, $queries, PermissionType::Read, true) as $row) { + foreach ($results as $row) { $row = $row->getArrayCopy(); foreach ($floating as $alias) { self::assertFinite($row[$alias] ?? null); } - $rows[] = $row; + $rows[] = $aggregation->decode($row); } $listeners = $this->listens(Event::DocumentAggregate); @@ -5178,8 +5191,7 @@ private static function aliasAggregates(array $queries): array } $attribute = $query->getAttribute(); - $alias = $attribute === '*' || $attribute === '' ? $method->value : $method->value.'_'.$attribute; - $alias = \substr((string) \preg_replace('/[^A-Za-z0-9_]/', '_', $alias), 0, Aggregate::MAX_ALIAS_LENGTH); + $alias = Aggregation::alias($query); if (isset($taken[$alias])) { throw new QueryException("The default aggregate alias '{$alias}' is used by more than one aggregate; give each of them an explicit alias"); diff --git a/tests/e2e/Adapter/Scopes/AggregationTests.php b/tests/e2e/Adapter/Scopes/AggregationTests.php index b7ea0bd57..404a26be0 100644 --- a/tests/e2e/Adapter/Scopes/AggregationTests.php +++ b/tests/e2e/Adapter/Scopes/AggregationTests.php @@ -2411,9 +2411,9 @@ public function testGroupedSelectReturnsEachGroupOnceWithItsAggregate(): void 'full outer join' => [Query::fullOuterJoin($collection, 'product', [Query::on('product_uid', '$id')]), [3, 3, 5]], ] as $type => [$product, $totals]) { $groups = $database->aggregate($orders, [$product, Query::count('*', 'total'), Query::groupBy(['product.category']), Query::select(['product.category']), Query::orderAsc('product.category')]); - $this->assertSame(['books', 'clothing', 'electronics'], \array_map(fn (array $group): mixed => $group['category'], $groups), $type); + $this->assertSame(['books', 'clothing', 'electronics'], \array_map(fn (array $group): mixed => $group['product.category'], $groups), $type); $this->assertSame($totals, \array_map(fn (array $group): int => $this->intAttribute($group, 'total'), $groups), $type); - $this->assertSame(['category', 'total'], $this->sortedAttributeNames($groups[0]), $type); + $this->assertSame(['product.category', 'total'], $this->sortedAttributeNames($groups[0]), $type); } $database->deleteCollection($orders); @@ -2827,6 +2827,89 @@ private function pageOneByOne(Database $database, string $collection, Query $sea return $documents; } + public function testAggregateRowsAreTypedNamedAndDecodedAsAReadReturnsThem(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'agg_typed_rows'.$this->getAggSuffix(); + $database->createCollection(Collection::create( + id: $collection, + attributes: [ + Attribute::string(key: 'label', size: 16, required: true), + Attribute::integer(key: 'quantity', required: true), + Attribute::double(key: 'price', required: true), + Attribute::boolean(key: 'active', required: true), + Attribute::datetime(key: 'shippedAt', required: true), + ], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + foreach ([ + ['first', 'a', 1, 1.5, true, '2023-06-01T00:00:00.000+00:00'], + ['second', 'a', 2, 2.5, false, '2023-06-02T00:00:00.000+00:00'], + ['third', 'b', 4, 3.5, true, '2023-06-01T00:00:00.000+00:00'], + ] as [$id, $label, $quantity, $price, $active, $shippedAt]) { + $database->createDocument($collection, new Document([ + '$id' => $id, + 'label' => $label, + 'quantity' => $quantity, + 'price' => $price, + 'active' => $active, + 'shippedAt' => $shippedAt, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $totals = $database->aggregate($collection, [ + Query::count('*', 'rows'), + Query::countDistinct('label', 'labels'), + Query::sum('quantity', 'units'), + Query::sum('price', 'revenue'), + Query::avg('quantity', 'mean'), + Query::min('quantity', 'fewest'), + Query::max('price', 'dearest'), + Query::min('shippedAt', 'firstShipped'), + ]); + $this->assertCount(1, $totals); + $this->assertIsFloat($totals[0]['mean']); + $this->assertEqualsWithDelta(7 / 3, $totals[0]['mean'], 0.0001); + $this->assertSame([ + 'rows' => 3, + 'labels' => 2, + 'units' => 7, + 'revenue' => 7.5, + 'mean' => $totals[0]['mean'], + 'fewest' => 1, + 'dearest' => 3.5, + 'firstShipped' => '2023-06-01T00:00:00.000+00:00', + ], $totals[0]); + + $this->assertSame( + [ + ['rows' => 1, 'active' => false, 'quantity' => 2, '$id' => 'second'], + ['rows' => 1, 'active' => true, 'quantity' => 1, '$id' => 'first'], + ['rows' => 1, 'active' => true, 'quantity' => 4, '$id' => 'third'], + ], + $database->aggregate($collection, [ + Query::count('*', 'rows'), + Query::groupBy(['active', 'quantity', '$id']), + Query::orderAsc('active'), + Query::orderAsc('quantity'), + ]), + ); + + $this->assertSame( + [['count' => 2, 'sum_quantity' => 3, 'label' => 'a'], ['count' => 1, 'sum_quantity' => 4, 'label' => 'b']], + $database->aggregate($collection, [Query::count('*'), Query::sum('quantity'), Query::groupBy(['label']), Query::orderAsc('label')]), + ); + + $database->deleteCollection($collection); + } + public function testCountMatchesTheRowsOfGroupedDistinctAndAggregateReads(): void { $database = static::getDatabase(); diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index f603528b1..54ca7b6ad 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -10981,9 +10981,9 @@ public function testJoinedGroupSharingAMainGroupNameKeepsItsQualifiedName(): voi $case.': a having on the qualified group', ); $this->assertEquals( - [['orders' => 2, 'name' => 'apple'], ['orders' => 1, 'name' => 'banana']], + [['orders' => 2, 'it.name' => 'apple'], ['orders' => 1, 'it.name' => 'banana']], $database->aggregate($orders, [$join, Query::count('*', 'orders'), Query::groupBy(['it.name']), Query::orderAsc('it.name')]), - $case.': a joined group alone keeps its bare name', + $case.': a joined group alone keeps its qualified name', ); } } finally { @@ -11052,7 +11052,7 @@ public function testJoinWildcardSelectAndBareJoinedOrder(): void $this->assertSame(['o2', 'o1', 'o3'], $ids($database->find($orders, [Query::fullOuterJoin($items, 'it', [Query::on('item', 'code')]), Query::orderDesc('price'), Query::orderAsc('$id')]))); $this->assertSame(['o2', 'o1', 'o3'], $ids($database->find($orders, [$item, Query::orderDesc('name'), Query::orderAsc('$id')])), 'a name the main collection declares reads the main table'); $this->assertEquals( - [['orders' => 1, 'code' => 'b'], ['orders' => 2, 'code' => 'a']], + [['orders' => 1, 'it.code' => 'b'], ['orders' => 2, 'it.code' => 'a']], $database->aggregate($orders, [$item, Query::count('*', 'orders'), Query::groupBy(['code']), Query::orderDesc('code')]), ); diff --git a/tests/e2e/Adapter/Scopes/JoinTests.php b/tests/e2e/Adapter/Scopes/JoinTests.php index 4e9233a58..8097b08be 100644 --- a/tests/e2e/Adapter/Scopes/JoinTests.php +++ b/tests/e2e/Adapter/Scopes/JoinTests.php @@ -18,7 +18,6 @@ use Utopia\Database\Permission; use Utopia\Database\Query; use Utopia\Database\Role; -use Utopia\Database\Storage; use Utopia\Query\Method; trait JoinTests @@ -6293,7 +6292,7 @@ private function fullOuterJoinGroups(array $rows): array { return \array_map( fn (array $row): array => [ - $row['category'] ?? null, + $row['b.category'] ?? null, $this->intAttribute($row, 'rows'), $this->intAttribute($row, 'total'), $this->intAttribute($row, 'mainTotal'), @@ -6483,7 +6482,7 @@ public function testJoinBareAggregateAttributeResolvesToTheDeclaringJoin(): void ]); $totals = []; foreach ($results as $result) { - $status = $result['status']; + $status = $result['j1.status']; $this->assertIsString($status); $totals[$status] = $this->intAttribute($result, 'total'); } @@ -7849,7 +7848,7 @@ public function testJoinedInternalAttributesGroupTheJoinedRows(): void foreach ([Document::ID, Document::SEQUENCE, Document::CREATED_AT, Document::UPDATED_AT, Document::PERMISSIONS] as $attribute) { $total = 0; foreach ($database->aggregate($customers, [$join, Query::count('*', 'rows'), Query::groupBy(['purchase.'.$attribute])]) as $group) { - $this->assertArrayHasKey(Storage::column($attribute), $group, $type.' grouped by purchase.'.$attribute); + $this->assertArrayHasKey('purchase.'.$attribute, $group, $type.' grouped by purchase.'.$attribute); $total += $this->intAttribute($group, 'rows'); } $this->assertSame(3, $total, $type.' grouped by purchase.'.$attribute); @@ -7862,7 +7861,7 @@ public function testJoinedInternalAttributesGroupTheJoinedRows(): void Query::groupBy(['purchase.$id']), Query::orderAsc('purchase.$id'), ]); - $this->assertSame(['open', 'other', 'paid'], \array_map(static fn (array $group): mixed => $group[Storage::UID], $groups)); + $this->assertSame(['open', 'other', 'paid'], \array_map(static fn (array $group): mixed => $group['purchase.$id'], $groups)); $this->assertSame([50, 7, 100], \array_map(fn (array $group): int => $this->intAttribute($group, 'total'), $groups)); $this->cleanupAggCollections($database, $collections); @@ -8024,7 +8023,7 @@ public function testTenantIsReadOnlyWhereTheTablesHoldIt(): void $grouped = $reads[$attribute]['groupBy'](); $this->assertCount(1, $grouped, $attribute); $this->assertSame($rows, $this->intAttribute($grouped[0], 'total'), $attribute); - $value = $grouped[0][Storage::TENANT]; + $value = $grouped[0][$attribute]; $this->assertIsScalar($value, $attribute); $this->assertSame($tenant, (string) $value, $attribute); } diff --git a/tests/unit/AggregationTest.php b/tests/unit/AggregationTest.php new file mode 100644 index 000000000..5f3bca39d --- /dev/null +++ b/tests/unit/AggregationTest.php @@ -0,0 +1,360 @@ + + */ + public static function fetches(): Iterator + { + yield 'text' => [true]; + yield 'native' => [false]; + } + + #[DataProvider('fetches')] + public function testAggregatesAreTypedByMethodAndAttribute(bool $stringify): void + { + $rows = $this->aggregate($this->database($stringify), [ + Query::count('*', 'rows'), + Query::countDistinct('label', 'labels'), + Query::sum('quantity', 'units'), + Query::sum('price', 'revenue'), + Query::avg('quantity', 'mean'), + Query::min('quantity', 'fewest'), + Query::max('price', 'dearest'), + Query::min('shippedAt', 'firstShipped'), + Query::max('label', 'lastLabel'), + ]); + + $this->assertCount(1, $rows); + $this->assertIsFloat($rows[0]['mean']); + $this->assertEqualsWithDelta(7 / 3, $rows[0]['mean'], 0.0001); + $this->assertSame([ + 'rows' => 3, + 'labels' => 2, + 'units' => 7, + 'revenue' => 7.5, + 'mean' => $rows[0]['mean'], + 'fewest' => 1, + 'dearest' => 3.5, + 'firstShipped' => '2023-06-01T00:00:00.000+00:00', + 'lastLabel' => 'b', + ], $rows[0]); + } + + public function testStatisticsAreFloats(): void + { + $aggregation = Aggregation::from($this->database(true), $this->database(true)->getCollection(self::ITEMS), [], [ + Query::variance('quantity', 'spread'), + Query::stddev('quantity', 'deviation'), + Query::avg('quantity', 'mean'), + ]); + + $row = $aggregation->decode(['spread' => '6.405119350733394e17', 'deviation' => '2', 'mean' => null]); + + $this->assertSame(['spread' => 6.405119350733394e17, 'deviation' => 2.0, 'mean' => null], $row); + } + + public function testWholeDecimalSumOfIntegersIsAnInteger(): void + { + $aggregation = Aggregation::from($this->database(true), $this->database(true)->getCollection(self::ITEMS), [], [ + Query::sum('quantity', 'units'), + Query::count('*', 'rows'), + ]); + + $this->assertSame(['units' => 7, 'rows' => 3], $aggregation->decode(['units' => '7.0000', 'rows' => '3'])); + } + + #[DataProvider('fetches')] + public function testAggregateOverNoRowsKeepsNull(bool $stringify): void + { + $rows = $this->aggregate($this->database($stringify), [ + Query::equal('label', ['none']), + Query::count('*', 'rows'), + Query::avg('price', 'mean'), + ]); + + $this->assertSame([['rows' => 0, 'mean' => null]], $rows); + } + + #[DataProvider('fetches')] + public function testGroupsAreTypedAsARowTypesThem(bool $stringify): void + { + $rows = $this->aggregate($this->database($stringify), [ + Query::count('*', 'rows'), + Query::groupBy(['active', 'tags', 'shippedAt', 'quantity', 'price']), + Query::orderAsc('quantity'), + ]); + + $this->assertSame([ + ['rows' => 1, 'active' => true, 'tags' => ['p', 'q'], 'shippedAt' => '2023-06-01T00:00:00.000+00:00', 'quantity' => 1, 'price' => 1.5], + ['rows' => 1, 'active' => false, 'tags' => ['p'], 'shippedAt' => '2023-06-02T00:00:00.000+00:00', 'quantity' => 2, 'price' => 2.5], + ['rows' => 1, 'active' => true, 'tags' => [], 'shippedAt' => '2023-06-01T00:00:00.000+00:00', 'quantity' => 4, 'price' => 3.5], + ], $rows); + } + + #[DataProvider('fetches')] + public function testSystemGroupsUseTheirPublicNames(bool $stringify): void + { + $database = $this->database($stringify); + $rows = $this->aggregate($database, [ + Query::count('*', 'rows'), + Query::groupBy(['$id', '$sequence', '$createdAt', '$updatedAt', '$permissions']), + Query::orderAsc('$id'), + ]); + + $item = $database->getDocument(self::ITEMS, 'first'); + $this->assertSame([ + 'rows' => 1, + '$id' => 'first', + '$sequence' => (string) $item->getSequence(), + '$createdAt' => $item->getCreatedAt(), + '$updatedAt' => $item->getUpdatedAt(), + '$permissions' => self::PERMISSIONS, + ], $rows[0]); + $this->assertSame(['first', 'second', 'third'], \array_column($rows, '$id')); + } + + /** + * @param list $groups + * @param list $keys + */ + #[DataProvider('joinedGroups')] + public function testJoinedGroupsAreQualifiedByTheirAlias(array $groups, array $keys): void + { + $rows = $this->aggregate($this->database(true), [ + Query::count('*', 'rows'), + Query::join(self::OWNERS, 'o', [Query::on('ownerId', '$id')]), + Query::groupBy($groups), + ]); + + $this->assertNotEmpty($rows); + foreach ($rows as $row) { + $this->assertSame(['rows', ...$keys], \array_keys($row)); + $this->assertIsInt($row['rows']); + } + } + + /** + * @return Iterator, list}> + */ + public static function joinedGroups(): Iterator + { + yield 'joined alone' => [['o.label'], ['o.label']]; + yield 'joined beside the main attribute of its name' => [['label', 'o.label'], ['label', 'o.label']]; + yield 'joined system attribute alone' => [['o.$id'], ['o.$id']]; + yield 'joined and main system attributes' => [['$id', 'o.$id'], ['$id', 'o.$id']]; + yield 'joined attribute named bare' => [['rank'], ['o.rank']]; + } + + public function testJoinedAggregatesAreTypedByTheJoinedAttribute(): void + { + $rows = $this->aggregate($this->database(true), [ + Query::sum('o.rank', 'ranks'), + Query::max('rank', 'top'), + Query::max('o.$createdAt', 'latest'), + Query::join(self::OWNERS, 'o', [Query::on('ownerId', '$id')]), + Query::groupBy(['o.$id']), + ]); + + $this->assertCount(2, $rows); + foreach ($rows as $row) { + $this->assertSame(['ranks', 'top', 'latest', 'o.$id'], \array_keys($row)); + $this->assertIsInt($row['ranks']); + $this->assertIsInt($row['top']); + $this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}\+00:00$/', self::text($row['latest'])); + } + } + + public function testUnaliasedAggregatesKeepTheNameTheDatabaseGivesThem(): void + { + $rows = $this->aggregate($this->database(true), [ + Query::count('*'), + Query::sum('quantity'), + Query::groupBy(['label']), + Query::orderAsc('label'), + ]); + + $this->assertSame([ + ['count' => 2, 'sum_quantity' => 3, 'label' => 'a'], + ['count' => 1, 'sum_quantity' => 4, 'label' => 'b'], + ], $rows); + } + + public function testUnaliasedJoinedAggregateKeepsTheNameTheDatabaseGivesIt(): void + { + $rows = $this->aggregate($this->database(true), [ + Query::sum('o.rank'), + Query::join(self::OWNERS, 'o', [Query::on('ownerId', '$id')]), + Query::groupBy(['o.$id']), + ]); + + \usort($rows, static fn (array $a, array $b): int => \strcmp(self::text($a['o.$id']), self::text($b['o.$id']))); + $this->assertSame([ + ['sum_o_rank' => 6, 'o.$id' => 'ann'], + ['sum_o_rank' => 4, 'o.$id' => 'ben'], + ], $rows); + } + + #[DataProvider('fetches')] + public function testGroupsAreDecodedByTheFiltersTheirAttributesDeclare(bool $stringify): void + { + $database = $this->database($stringify); + $rows = $this->aggregate($database, [ + Query::count('*', 'rows'), + Query::groupBy(['code']), + ]); + + \usort($rows, static fn (array $a, array $b): int => \strcmp(self::text($a['code']), self::text($b['code']))); + $this->assertSame([ + ['rows' => 2, 'code' => 'alpha'], + ['rows' => 1, 'code' => 'beta'], + ], $rows); + $this->assertSame('beta', $database->getDocument(self::ITEMS, 'third')->getAttribute('code')); + } + + public function testJoinedGroupsAreDecodedByTheFiltersOfTheJoinedAttribute(): void + { + $rows = $this->aggregate($this->database(true), [ + Query::count('*', 'rows'), + Query::join(self::OWNERS, 'o', [Query::on('ownerId', '$id')]), + Query::groupBy(['code', 'o.code']), + ]); + + \usort($rows, static fn (array $a, array $b): int => \strcmp(self::text($a['code']) . self::text($a['o.code']), self::text($b['code']) . self::text($b['o.code']))); + $this->assertSame([ + ['rows' => 1, 'code' => 'alpha', 'o.code' => 'xray'], + ['rows' => 1, 'code' => 'alpha', 'o.code' => 'yankee'], + ['rows' => 1, 'code' => 'beta', 'o.code' => 'xray'], + ], $rows); + } + + public function testRowsCarryNoSystemAttributeTheyDoNotGroup(): void + { + $rows = $this->aggregate($this->database(true), [ + Query::count('*', 'rows'), + Query::groupBy(['label']), + ]); + + foreach ($rows as $row) { + $this->assertSame(['rows', 'label'], \array_keys($row)); + } + } + + private static function text(mixed $value): string + { + self::assertIsString($value); + + return $value; + } + + /** + * @param array $queries + * @return list> + */ + private function aggregate(Database $database, array $queries): array + { + return $database->aggregate(self::ITEMS, $queries); + } + + private function database(bool $stringify): Database + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + + $pdo = new PDO('sqlite::memory:', options: [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_STRINGIFY_FETCHES => $stringify, + ]); + $reverse = static fn (mixed $value): mixed => \is_string($value) ? \strrev($value) : $value; + $database = new Database(new SQLite($pdo), new Cache(new None()), [new Callback(self::REVERSED, $reverse, $reverse)]); + $database + ->setAuthorization($authorization) + ->setDatabase('aggregation') + ->setNamespace('aggregation_' . \uniqid()); + $database->create(); + + $permissions = [Permission::read(Role::any()), Permission::create(Role::any())]; + $database->createCollection(Collection::create( + id: self::OWNERS, + attributes: [ + Attribute::string('label', size: 16), + Attribute::integer('rank'), + Attribute::string('code', size: 16, filters: [self::REVERSED]), + ], + permissions: $permissions, + )); + $database->createCollection(Collection::create( + id: self::ITEMS, + attributes: [ + Attribute::string('label', size: 16), + Attribute::integer('quantity'), + Attribute::float('price'), + Attribute::boolean('active'), + Attribute::string('tags', size: 16, array: true), + Attribute::datetime('shippedAt'), + Attribute::string('ownerId', size: 36), + Attribute::string('code', size: 16, filters: [self::REVERSED]), + ], + permissions: $permissions, + )); + + $database->createDocument(self::OWNERS, new Document(['$id' => 'ann', 'label' => 'x', 'rank' => 3, 'code' => 'xray', '$permissions' => self::PERMISSIONS])); + $database->createDocument(self::OWNERS, new Document(['$id' => 'ben', 'label' => 'y', 'rank' => 4, 'code' => 'yankee', '$permissions' => self::PERMISSIONS])); + + foreach ([ + ['first', 'a', 1, 1.5, true, ['p', 'q'], '2023-06-01T00:00:00.000+00:00', 'ann', 'alpha'], + ['second', 'a', 2, 2.5, false, ['p'], '2023-06-02T00:00:00.000+00:00', 'ben', 'alpha'], + ['third', 'b', 4, 3.5, true, [], '2023-06-01T00:00:00.000+00:00', 'ann', 'beta'], + ] as [$id, $label, $quantity, $price, $active, $tags, $shippedAt, $ownerId, $code]) { + $database->createDocument(self::ITEMS, new Document([ + '$id' => $id, + 'label' => $label, + 'quantity' => $quantity, + 'price' => $price, + 'active' => $active, + 'tags' => $tags, + 'shippedAt' => $shippedAt, + 'ownerId' => $ownerId, + 'code' => $code, + '$permissions' => self::PERMISSIONS, + ])); + } + + return $database; + } +} diff --git a/tests/unit/Documents/AggregateOrderTest.php b/tests/unit/Documents/AggregateOrderTest.php index 3ee64d6eb..4855ce088 100644 --- a/tests/unit/Documents/AggregateOrderTest.php +++ b/tests/unit/Documents/AggregateOrderTest.php @@ -87,10 +87,10 @@ public static function groupedOrders(): iterable 'total', [20, 40], ]; - yield 'a joined group' => [[$note, Query::count('*', 'rows'), Query::groupBy(['note.name']), Query::orderDesc('note.name')], 'name', ['second', 'first']]; + yield 'a joined group' => [[$note, Query::count('*', 'rows'), Query::groupBy(['note.name']), Query::orderDesc('note.name')], 'note.name', ['second', 'first']]; yield 'a joined group named without its alias' => [ [$note, Query::count('*', 'rows'), Query::groupBy(['score']), Query::orderDesc('note.score')], - 'score', + 'note.score', [5, 4, 3], ]; } diff --git a/tests/unit/Documents/AggregateSelectTest.php b/tests/unit/Documents/AggregateSelectTest.php index a7a4244aa..1a70936da 100644 --- a/tests/unit/Documents/AggregateSelectTest.php +++ b/tests/unit/Documents/AggregateSelectTest.php @@ -25,7 +25,6 @@ use Utopia\Database\RelationshipSide; use Utopia\Database\RelationshipType; use Utopia\Database\Role; -use Utopia\Database\Storage; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Queries\Document as DocumentValidator; use Utopia\Database\Validator\Queries\Documents as DocumentsValidator; @@ -138,7 +137,7 @@ public function testRelationshipWildcardsNextToAnAggregateAddNothingToTheRows(ar $this->database->aggregate('customers', [Query::count('*', 'rows'), Query::groupBy(['status']), Query::select($selects), Query::orderAsc('status')]), ); $this->assertSame( - [['rows' => 2, 'name' => 'first'], ['rows' => 1, 'name' => 'second']], + [['rows' => 2, 'note.name' => 'first'], ['rows' => 1, 'note.name' => 'second']], $this->database->aggregate('customers', [Query::join('notes', 'note', [Query::on('$id', 'customerId')]), Query::count('*', 'rows'), Query::groupBy(['note.name']), Query::select($selects), Query::orderAsc('note.name')]), ); } @@ -163,7 +162,7 @@ public function testWildcardsNextToAnAggregateOverAFullOuterJoinAddNothingToTheR $this->assertSame([['rows' => 5]], $this->database->aggregate('customers', [$note, Query::count('*', 'rows'), Query::select(['*'])])); $this->assertSame([['rows' => 5]], $this->database->aggregate('customers', [$note, Query::count('*', 'rows'), Query::select(['*', 'account.*', 'account.region.*'])])); $this->assertSame( - [['rows' => 1, 'name' => null], ['rows' => 2, 'name' => 'first'], ['rows' => 1, 'name' => 'second'], ['rows' => 1, 'name' => 'third']], + [['rows' => 1, 'note.name' => null], ['rows' => 2, 'note.name' => 'first'], ['rows' => 1, 'note.name' => 'second'], ['rows' => 1, 'note.name' => 'third']], $this->database->aggregate('customers', [$note, Query::count('*', 'rows'), Query::groupBy(['note.name']), Query::select(['note.name', '*', 'account.*', 'account.region.*']), Query::orderAsc('note.name')]), ); $this->assertInvalidQuery(self::ungrouped('name'), fn (): mixed => $this->database->aggregate('customers', [$note, Query::count('*', 'rows'), Query::select(['name'])])); @@ -202,19 +201,19 @@ public static function groupedSelects(): iterable ]; yield 'a grouped internal attribute' => [ [Query::count('*', 'rows'), Query::groupBy(['$id']), Query::select(['$id']), Query::orderAsc('$id')], - [['rows' => 1, Storage::UID => 'c1'], ['rows' => 1, Storage::UID => 'c2'], ['rows' => 1, Storage::UID => 'c3']], + [['rows' => 1, '$id' => 'c1'], ['rows' => 1, '$id' => 'c2'], ['rows' => 1, '$id' => 'c3']], ]; yield 'a grouped joined attribute' => [ [$note, Query::count('*', 'rows'), Query::groupBy(['note.name']), Query::select(['note.name']), Query::orderAsc('note.name')], - [['rows' => 2, 'name' => 'first'], ['rows' => 1, 'name' => 'second']], + [['rows' => 2, 'note.name' => 'first'], ['rows' => 1, 'note.name' => 'second']], ]; yield 'a joined attribute grouped by the bare name only its join declares' => [ [$note, Query::count('*', 'rows'), Query::groupBy(['score']), Query::select(['note.score']), Query::orderAsc('note.score')], - [['rows' => 1, 'score' => 3], ['rows' => 1, 'score' => 4], ['rows' => 1, 'score' => 5]], + [['rows' => 1, 'note.score' => 3], ['rows' => 1, 'note.score' => 4], ['rows' => 1, 'note.score' => 5]], ]; yield 'a grouped joined internal attribute' => [ [$note, Query::count('*', 'rows'), Query::groupBy(['note.$id']), Query::select(['note.$id']), Query::orderAsc('note.$id')], - [['rows' => 1, Storage::UID => 'n1'], ['rows' => 1, Storage::UID => 'n2'], ['rows' => 1, Storage::UID => 'n3']], + [['rows' => 1, 'note.$id' => 'n1'], ['rows' => 1, 'note.$id' => 'n2'], ['rows' => 1, 'note.$id' => 'n3']], ]; } @@ -236,7 +235,7 @@ public function testGroupedSelectOverAFullOuterJoinReturnsEachGroupOnce(bool $na } $this->assertSame( - [['rows' => 1, 'name' => null], ['rows' => 2, 'name' => 'first'], ['rows' => 1, 'name' => 'second'], ['rows' => 1, 'name' => 'third']], + [['rows' => 1, 'note.name' => null], ['rows' => 2, 'note.name' => 'first'], ['rows' => 1, 'note.name' => 'second'], ['rows' => 1, 'note.name' => 'third']], $this->database->aggregate('customers', [ Query::fullOuterJoin('notes', 'note', [Query::on('$id', 'customerId')]), Query::count('*', 'rows'), @@ -253,7 +252,7 @@ public function testTenantIsSelectedNextToAnAggregateOnlyWhenGrouped(): void $this->assertInvalidQuery(self::ungrouped('$tenant'), fn (): mixed => $this->database->aggregate('customers', [Query::count('*', 'rows'), Query::select(['$tenant'])])); $this->assertSame( - [['rows' => 3, Storage::TENANT => 1]], + [['rows' => 3, '$tenant' => 1]], $this->database->aggregate('customers', [Query::count('*', 'rows'), Query::groupBy(['$tenant']), Query::select(['$tenant'])]), ); } diff --git a/tests/unit/Joins/AggregateMinorsTest.php b/tests/unit/Joins/AggregateMinorsTest.php index 29233cca3..dbeada4c5 100644 --- a/tests/unit/Joins/AggregateMinorsTest.php +++ b/tests/unit/Joins/AggregateMinorsTest.php @@ -143,12 +143,12 @@ public function testJoinedGroupKeepsItsQualifiedName(): void $case.': a having on the qualified group', ); $this->assertSame( - [['orders' => 2, 'name' => 'apple'], ['orders' => 1, 'name' => 'banana']], + [['orders' => 2, 'it.name' => 'apple'], ['orders' => 1, 'it.name' => 'banana']], $database->aggregate('orders', [$item, Query::count('*', 'orders'), Query::groupBy(['it.name']), Query::orderAsc('it.name')]), - $case.': a joined group alone keeps its bare name', + $case.': a joined group alone keeps its qualified name', ); $this->assertSame( - [['orders' => 2, 'code' => 'a'], ['orders' => 1, 'code' => 'b']], + [['orders' => 2, 'it.code' => 'a'], ['orders' => 1, 'it.code' => 'b']], $database->aggregate('orders', [$item, Query::count('*', 'orders'), Query::groupBy(['code']), Query::orderAsc('it.code')]), $case.': a bare name only the join declares', ); diff --git a/tests/unit/Joins/AggregateResultNamesTest.php b/tests/unit/Joins/AggregateResultNamesTest.php index 54d8845d1..45b215e03 100644 --- a/tests/unit/Joins/AggregateResultNamesTest.php +++ b/tests/unit/Joins/AggregateResultNamesTest.php @@ -115,7 +115,7 @@ public function testAliasesNamingNoOtherColumnKeepEveryValue(): void $database->aggregate('main', [Query::count('*', 'links'), Query::sum('score', 'total'), Query::groupBy(['link']), Query::orderAsc('link')]), ); $this->assertSame( - [['score' => 10, 'label' => 'first']], + [['score' => 10, 'a.label' => 'first']], $database->aggregate('main', [Query::join('a', 'a', [Query::on('link', 'link')]), Query::sum('score', 'score'), Query::groupBy(['a.label'])]), ); } diff --git a/tests/unit/Joins/JoinInternalColumnsTest.php b/tests/unit/Joins/JoinInternalColumnsTest.php index f69bee478..95ae437c3 100644 --- a/tests/unit/Joins/JoinInternalColumnsTest.php +++ b/tests/unit/Joins/JoinInternalColumnsTest.php @@ -21,7 +21,6 @@ use Utopia\Database\Query; use Utopia\Database\Relationship; use Utopia\Database\Role; -use Utopia\Database\Storage; use Utopia\Database\Validator\Authorization; use Utopia\Query\Method; @@ -86,7 +85,7 @@ public function testInternalAttributeUnderAnAliasGroupsTheJoinedRows(Method $joi $total = 0; foreach ($groups as $group) { - $this->assertArrayHasKey(Storage::column($attribute), $group, 'a group comes back under its column, as on the main collection'); + $this->assertArrayHasKey('note.'.$attribute, $group, 'a joined group comes back under its qualified name'); $count = $group['rows']; $this->assertIsInt($count); $total += $count; @@ -123,7 +122,7 @@ public function testJoinedIdGroupsOneRowPerJoinedDocument(Method $join, bool $na Query::orderAsc('note.$id'), ]); - $this->assertSame($expected, \array_map(static fn (array $group): mixed => $group[Storage::UID], $groups)); + $this->assertSame($expected, \array_map(static fn (array $group): mixed => $group['note.$id'], $groups)); } public function testCollectionIsNeitherAggregatedNorGrouped(): void @@ -176,9 +175,9 @@ public function testTenantIsReadUnderSharedTables(): void $note = $this->join(Method::Join); $this->assertSame([['total' => 3]], $this->database->aggregate('customers', [Query::count('$tenant', 'total')])); - $this->assertSame([['rows' => 3, Storage::TENANT => 1]], $this->database->aggregate('customers', [Query::count('*', 'rows'), Query::groupBy(['$tenant'])])); + $this->assertSame([['rows' => 3, '$tenant' => 1]], $this->database->aggregate('customers', [Query::count('*', 'rows'), Query::groupBy(['$tenant'])])); $this->assertSame([['total' => 3]], $this->database->aggregate('customers', [$note, Query::count('note.$tenant', 'total')])); - $this->assertSame([['rows' => 3, Storage::TENANT => 1]], $this->database->aggregate('customers', [$note, Query::count('*', 'rows'), Query::groupBy(['note.$tenant'])])); + $this->assertSame([['rows' => 3, 'note.$tenant' => 1]], $this->database->aggregate('customers', [$note, Query::count('*', 'rows'), Query::groupBy(['note.$tenant'])])); $customers = $this->database->find('customers', [$note, Query::select(['name', 'note.$tenant']), Query::orderAsc('note.$id')]); $this->assertSame([self::TENANT, self::TENANT, self::TENANT], \array_map(static fn (Document $customer): mixed => $customer->getAttribute('note.$tenant'), $customers)); diff --git a/tests/unit/Joins/JoinedAttributeResolutionTest.php b/tests/unit/Joins/JoinedAttributeResolutionTest.php index 4b98bbf77..22b185d96 100644 --- a/tests/unit/Joins/JoinedAttributeResolutionTest.php +++ b/tests/unit/Joins/JoinedAttributeResolutionTest.php @@ -71,7 +71,7 @@ public function testBareAttributesResolveTheSameWayOverAFullOuterJoin(bool $nati $byStatus = []; foreach ($this->aggregateCustomers($validate, [$purchases, Query::sum('amount', 'total'), Query::groupBy(['status'])]) as $group) { - $status = $group['status']; + $status = $group['purchase.status']; $this->assertIsString($status, $mode); $byStatus[$status] = $group['total']; } @@ -229,7 +229,7 @@ public function testBareGroupByAttributeResolvesToTheOneJoinThatDeclaresIt(): vo $totals = []; foreach ($results as $result) { - $status = $result['status']; + $status = $result['purchase.status']; $this->assertIsString($status); $totals[$status] = $result['total']; } diff --git a/tests/unit/Joins/JoinedShapesTest.php b/tests/unit/Joins/JoinedShapesTest.php index a621d3caa..4da94822e 100644 --- a/tests/unit/Joins/JoinedShapesTest.php +++ b/tests/unit/Joins/JoinedShapesTest.php @@ -100,12 +100,12 @@ public function testAnOrderOnABareJoinedNameReadsTheJoin(): void ); $this->assertSame(['o2', 'o1', 'o3'], $this->ids($database->find('orders', [$item, Query::orderDesc('price'), Query::orderAsc('$id')])), $case); $this->assertSame( - [['orders' => 1, 'code' => 'b'], ['orders' => 2, 'code' => 'a']], + [['orders' => 1, 'it.code' => 'b'], ['orders' => 2, 'it.code' => 'a']], $database->aggregate('orders', [$item, Query::count('*', 'orders'), Query::groupBy(['code']), Query::orderDesc('code')]), - $case.': a group only the join declares, named bare', + $case.': a group only the join declares, named bare, comes back qualified', ); $this->assertSame( - [['orders' => 1, 'code' => 'b'], ['orders' => 2, 'code' => 'a']], + [['orders' => 1, 'it.code' => 'b'], ['orders' => 2, 'it.code' => 'a']], $database->aggregate('orders', [$item, Query::count('*', 'orders'), Query::groupBy(['it.code']), Query::orderDesc('code')]), $case.': a group named by its alias', ); diff --git a/tests/unit/PermissionScope/FullOuterJoinAggregateVisibilityTest.php b/tests/unit/PermissionScope/FullOuterJoinAggregateVisibilityTest.php index 766d3c08d..7e50e059a 100644 --- a/tests/unit/PermissionScope/FullOuterJoinAggregateVisibilityTest.php +++ b/tests/unit/PermissionScope/FullOuterJoinAggregateVisibilityTest.php @@ -114,8 +114,8 @@ public function testAggregatesCountOnlyTheRowsDirectReadsReturn(bool $native, bo $this->assertSame($totals, $this->totals($rows), 'Direct reads must return the readable documents of the selected tenant'); $this->assertSame($totals, $this->readTotals($database), 'count() and sum() over the join'); - $this->assertSame($this->groupCounts($rows, 'genre'), $this->readGroupCounts($database, self::GENRE, 'genre'), 'counts grouped by a joined attribute'); - $this->assertSame($this->groupCounts($rows, self::TEAM), $this->readGroupCounts($database, self::TEAM, self::TEAM), 'counts grouped by a main attribute'); + $this->assertSame($this->groupCounts($rows, 'genre'), $this->readGroupCounts($database, self::GENRE), 'counts grouped by a joined attribute'); + $this->assertSame($this->groupCounts($rows, self::TEAM), $this->readGroupCounts($database, self::TEAM), 'counts grouped by a main attribute'); } private function database(bool $native, bool $sharedTables, bool $grantAuthors, bool $grantBooks): Database @@ -312,15 +312,15 @@ private function groupCounts(array $rows, string $key): array } /** - * A group comes back under the grouped attribute's own name, without the join alias. + * A group comes back under the name the queries group it by, a joined one qualified by its join alias. * * @return list */ - private function readGroupCounts(Database $database, string $attribute, string $key): array + private function readGroupCounts(Database $database, string $attribute): array { $counts = \array_map( fn (array $group): array => [ - $this->stringOrNull($group[$key] ?? null), + $this->stringOrNull($group[$attribute] ?? null), $this->integerOrNull($group['rows'] ?? null) ?? -1, ], $database->aggregate(self::AUTHORS, [$this->join(), Query::groupBy([$attribute]), Query::count('*', 'rows')]),