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')]),