diff --git a/Datastore/src/DatastoreClient.php b/Datastore/src/DatastoreClient.php index 353ab3e39866..d80f862899f9 100644 --- a/Datastore/src/DatastoreClient.php +++ b/Datastore/src/DatastoreClient.php @@ -26,6 +26,7 @@ use Google\Cloud\Datastore\Connection\Grpc; use Google\Cloud\Datastore\Connection\Rest; use Google\Cloud\Datastore\Query\AggregationQuery; +use Google\Cloud\Datastore\Query\AggregationQueryResult; use Google\Cloud\Datastore\Query\GqlQuery; use Google\Cloud\Datastore\Query\Query; use Google\Cloud\Datastore\Query\QueryInterface; @@ -175,7 +176,12 @@ public function __construct(array $config = []) // The second parameter here should change to a variable // when gRPC support is added for variable encoding. - $this->entityMapper = new EntityMapper($this->projectId, true, $config['returnInt64AsObject']); + $this->entityMapper = new EntityMapper( + $this->projectId, + true, + $config['returnInt64AsObject'], + $connectionType + ); $this->operation = new Operation( $this->connection, $this->projectId, diff --git a/Datastore/src/EntityMapper.php b/Datastore/src/EntityMapper.php index 39120c4bf06a..34dea73d13c0 100644 --- a/Datastore/src/EntityMapper.php +++ b/Datastore/src/EntityMapper.php @@ -49,6 +49,14 @@ class EntityMapper */ private $returnInt64AsObject; + /** + * The connection type of the client. Required while mapping + * `INF`, `-INF` and `NAN` to datastore equivalent values. + * + * @var string + */ + private $connectionType; + /** * Create an Entity Mapper * @@ -57,12 +65,19 @@ class EntityMapper * @param bool $returnInt64AsObject If true, 64 bit integers will be * returned as a {@see \Google\Cloud\Core\Int64} object for 32 bit * platform compatibility. + * @param string $connectionType [optional] The connection type of the client. + * Can be `rest` or `grpc`, defaults to `grpc`. */ - public function __construct($projectId, $encode, $returnInt64AsObject) - { + public function __construct( + $projectId, + $encode, + $returnInt64AsObject, + $connectionType = 'grpc' + ) { $this->projectId = $projectId; $this->encode = $encode; $this->returnInt64AsObject = $returnInt64AsObject; + $this->connectionType = $connectionType; } /** @@ -189,6 +204,22 @@ public function convertValue($type, $value, $className = Entity::class) break; case 'doubleValue': + // Flow will enter this logic only when REST transport is used + // because gRPC response values are always parsed correctly. Therefore + // the default $connectionType is set to 'grpc' + if (is_string($value)) { + switch ($value) { + case 'Infinity': + $value = INF; + break; + case '-Infinity': + $value = -INF; + break; + case 'NaN': + $value = NAN; + break; + } + } $result = (float) $value; break; @@ -328,6 +359,18 @@ public function valueObject($value, $exclude = false, $meaning = null) break; case 'double': + // The mappings happen automatically for grpc hence + // this is required only incase of rest as grpc + // doesn't recognises 'Infinity', '-Infinity' and 'NaN'. + if ($this->connectionType == 'rest') { + if ($value == INF) { + $value = 'Infinity'; + } elseif ($value == -INF) { + $value = '-Infinity'; + } elseif (is_nan($value)) { + $value = 'NaN'; + } + } $propertyValue = [ 'doubleValue' => $value ]; diff --git a/Datastore/src/Operation.php b/Datastore/src/Operation.php index 992571002ae2..2b68f566ce8d 100644 --- a/Datastore/src/Operation.php +++ b/Datastore/src/Operation.php @@ -586,7 +586,7 @@ public function runAggregationQuery(AggregationQuery $runQueryObj, array $option ] + $requestQueryArr + $this->readOptions($options) + $options; $res = $this->connection->runAggregationQuery($request); - return new AggregationQueryResult($res); + return new AggregationQueryResult($res, $this->entityMapper); } /** diff --git a/Datastore/src/Query/Aggregation.php b/Datastore/src/Query/Aggregation.php index 0f2d59eaebda..71fd32c32306 100644 --- a/Datastore/src/Query/Aggregation.php +++ b/Datastore/src/Query/Aggregation.php @@ -18,7 +18,7 @@ namespace Google\Cloud\Datastore\Query; /** - * Represents Count Aggregation properties. + * Represents Aggregation properties. * * Example: * ``` @@ -28,6 +28,8 @@ * * echo json_encode($count->getProps()); * ``` + * + * Aggregations considers non existing property name as an empty query set */ class Aggregation { @@ -36,6 +38,16 @@ class Aggregation */ private const TYPE_COUNT = 'count'; + /** + * Default placeholder for all sum aggregation props. + */ + private const TYPE_SUM = 'sum'; + + /** + * Default placeholder for all average aggregation props. + */ + private const TYPE_AVG = 'avg'; + /** * @var array Properties for an aggregation query. */ @@ -67,9 +79,63 @@ private function __construct($aggregationType) */ public static function count() { - $count = new Aggregation(self::TYPE_COUNT); - $count->props[$count->aggregationType] = []; - return $count; + return self::createAggregation(self::TYPE_COUNT); + } + + /** + * Creates sum aggregation properties. + * + * Example: + * ``` + * $sum = Aggregate::sum('property_to_aggregate_upon'); + * ``` + * Result of SUM aggregation can be a integer or a float. + * Sum of integers which exceed maxinum integer value returns a float. + * Sum of numbers exceeding max float value returns `INF`. + * Sum of data which contains `NaN` returns `NaN`. + * Non numeric values are ignored. + * + * @param string $property The relative path of the field to aggregate upon. + * @return Aggregation + */ + public static function sum($property) + { + return self::createAggregation(self::TYPE_SUM, $property); + } + + /** + * Creates average aggregation properties. + * + * Example: + * ``` + * $avg = Aggregate::avg('property_to_aggregate_upon'); + * ``` + * Result of AVG aggregation can be a float or a null. + * Average of empty valid data set return `null`. + * Average of numbers exceeding max float value returns `INF`. + * Average of data which contains `NaN` returns `NaN`. + * Non numeric values are ignored. + * + * @param string $property The relative path of the field to aggregate upon. + * @return Aggregation + */ + public static function avg($property) + { + return self::createAggregation(self::TYPE_AVG, $property); + } + + private static function createAggregation(string $type, $property = null) + { + $aggregation = new Aggregation($type); + $aggregation->props[$aggregation->aggregationType] = []; + if (!is_null($property)) { + $aggregation->props[$aggregation->aggregationType] = [ + 'property' => [ + 'name' => $property + ] + ]; + } + return $aggregation; } /** diff --git a/Datastore/src/Query/AggregationQueryResult.php b/Datastore/src/Query/AggregationQueryResult.php index 68eb1d1ffc5e..15d8c61e53f5 100644 --- a/Datastore/src/Query/AggregationQueryResult.php +++ b/Datastore/src/Query/AggregationQueryResult.php @@ -19,6 +19,7 @@ use Google\Cloud\Core\Timestamp; use Google\Cloud\Core\TimestampTrait; +use Google\Cloud\Datastore\EntityMapper; use InvalidArgumentException; /** @@ -68,13 +69,20 @@ class AggregationQueryResult */ private $transaction; + /** + * @var EntityMapper + */ + private $mapper; + /** * Create AggregationQueryResult object. * * @param array $result Response of * [RunAggregationQuery](https://cloud.google.com/datastore/docs/reference/data/rest/v1/projects/runAggregationQuery) + * @param EntityMapper $mapper [Optional] Entity mapper to map datastore values + * to their equivalent php values incase of `null`, `NAN`, `INF` and `-INF` */ - public function __construct($result = []) + public function __construct($result = [], $mapper = null) { // When executing an Agggregation query nested with GqlQuery, the server will return // the parsed query with the first response batch. @@ -93,6 +101,14 @@ public function __construct($result = []) if (isset($result['batch']['readTime'])) { $this->readTime = $result['batch']['readTime']; } + + // Though the client always passes an entity mapper object, we need to + // re-instantiate an entity mapper incase a user creates an instance of + // AggregationQueryResult manually without supplying the `$entityMapper`. + // Here, entity mapper is used to parse response +/- 'Infinity' to +/- `INF`, + // 'NaN' to `NAN` and ['nullValue' => 0] to ['nullValue' => null]. Therefore the + // usage is independent of `$projectId` or other arguments. + $this->mapper = $mapper ?? new EntityMapper('', true, false); } /** @@ -109,7 +125,7 @@ public function __construct($result = []) * ``` * * @param string $alias The aggregation alias. - * @return int + * @return mixed * @throws InvalidArgumentException If provided alias does not exist in result. */ public function get($alias) @@ -119,7 +135,8 @@ public function get($alias) } $result = $this->aggregationResults[0]['aggregateProperties'][$alias]; if (is_array($result)) { - return $result['integerValue']; + $key = array_key_first($result); + return $this->mapper->convertValue($key, $result[$key]); } return $result; } diff --git a/Datastore/tests/System/AggregationQueryTest.php b/Datastore/tests/System/AggregationQueryTest.php new file mode 100644 index 000000000000..283a8e3c3c35 --- /dev/null +++ b/Datastore/tests/System/AggregationQueryTest.php @@ -0,0 +1,436 @@ + 1], + ['score' => 2], + ['score' => 3], + ['score' => 4], + ['maxScore' => PHP_FLOAT_MAX], + ['maxScore' => PHP_FLOAT_MAX], + ['minScore' => -PHP_FLOAT_MAX], + ['minScore' => -PHP_FLOAT_MAX], + ['nullScore' => null], + ['nanScore' => NAN], + ['arrayScore' => [1, 2, 3]], + ['arrayScore' => [10]] + ]; + + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + self::$kind = uniqid('testKind'); + $keys = self::$restClient->keys(self::$kind, ['number' => count(self::$data)]); + $keys = self::$restClient->allocateIds($keys); + foreach ($keys as $count => $key) { + self::$restClient->insert(self::$restClient->entity($key, self::$data[$count])); + } + + // on rare occasions the queries below are returning no results when + // triggered immediately after an insert operation. the sleep here + // is intended to help alleviate this issue. + sleep(1); + + foreach ($keys as $key) { + self::$localDeletionQueue->add($key); + } + } + + public static function tearDownAfterClass(): void + { + self::tearDownFixtures(); + } + + /** + * @dataProvider filterCases + */ + public function testQueryShouldFailForIncorrectAlias( + DatastoreClient $client, + $type, + $property, + $expected + ) { + $this->skipEmulatorTests(); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('alias does not exist'); + $aggregation = (is_null($property) ? Aggregation::$type() : Aggregation::$type($property)); + $aggregationQuery = $client->query() + ->kind(self::$kind) + ->aggregation($aggregation); + + $results = $client->runAggregationQuery($aggregationQuery); + + $results->get('total'); + } + + /** + * @dataProvider filterCases + * @dataProvider cornerCases + */ + public function testQueryWithFilter(DatastoreClient $client, $type, $property, $expected) + { + $this->skipEmulatorTests(); + $aggregation = (is_null($property) ? Aggregation::$type() : Aggregation::$type($property)); + $aggregationQuery = $client->query() + ->kind(self::$kind) + ->aggregation($aggregation->alias('result')); + + $results = $client->runAggregationQuery($aggregationQuery); + + $this->compareResult($expected, $results->get('result')); + } + + /** + * @dataProvider filterCases + */ + public function testOverQueryWithFilter(DatastoreClient $client, $type, $property, $expected) + { + $this->skipEmulatorTests(); + $aggregation = (is_null($property) ? Aggregation::$type() : Aggregation::$type($property)); + $query = $client->query() + ->kind(self::$kind); + $aggregationQuery = $client->aggregationQuery() + ->over($query) + ->addAggregation($aggregation); + + $results = $client->runAggregationQuery($aggregationQuery); + + $this->compareResult($expected, $results->get('property_1')); + } + + /** + * @dataProvider filterCases + */ + public function testGqlQueryWithFilter(DatastoreClient $client, $type, $property, $expected) + { + $this->skipEmulatorTests(); + $aggregationQuery = $client->gqlQuery( + sprintf("SELECT %s(%s) as result From %s", $type, ($property ?? '*'), self::$kind), + ['allowLiterals' => true] + )->aggregation(); + + $results = $client->runAggregationQuery($aggregationQuery); + + $this->compareResult($expected, $results->get('result')); + } + + /** + * @dataProvider filterCases + */ + public function testOverGqlQueryWithFilter(DatastoreClient $client, $type, $property, $expected) + { + $this->skipEmulatorTests(); + $query = $client->gqlQuery( + sprintf( + "AGGREGATE %s(%s) as result OVER" + . " (SELECT * FROM %s)", + $type, + ($property ?? '*'), + self::$kind + ) + ); + $aggregationQuery = $client->aggregationQuery() + ->over($query); + + $results = $client->runAggregationQuery($aggregationQuery); + + $this->compareResult($expected, $results->get('result')); + } + + /** + * @dataProvider limitCases + */ + public function testQueryWithLimit(DatastoreClient $client, $type, $property, $expected) + { + $this->skipEmulatorTests(); + $aggregation = (is_null($property) ? Aggregation::$type() : Aggregation::$type($property)); + $query = $client->query() + ->kind(self::$kind) + ->limit(2); + $aggregationQuery = $client->aggregationQuery() + ->over($query) + ->addAggregation($aggregation->alias('result')); + + $results = $client->runAggregationQuery($aggregationQuery); + + $this->compareResult($expected, $results->get('result')); + } + + /** + * @dataProvider limitCases + */ + public function testGqlQueryWithLimit(DatastoreClient $client, $type, $property, $expected) + { + $this->skipEmulatorTests(); + $queryString = sprintf( + "AGGREGATE + %s(%s) AS result + OVER ( + SELECT * From %s LIMIT 2 + )", + $type, + ($property ?? '*'), + self::$kind + ); + $query = $client->gqlQuery( + $queryString, + ['allowLiterals' => true] + ); + $aggregationQuery = $client->aggregationQuery() + ->over($query); + + $results = $client->runAggregationQuery($aggregationQuery); + + $this->compareResult($expected, $results->get('result')); + } + + /** + * @dataProvider multipleAggregationCases + */ + public function testQueryWithMultipleAggregations( + DatastoreClient $client, + $types, + $properties, + $expected + ) { + $this->skipEmulatorTests(); + $query = $client->query() + ->kind(self::$kind); + $aggregationQuery = $client->aggregationQuery() + ->over($query); + foreach ($types as $type) { + $aggregationQuery->addAggregation( + ( + is_null($properties[$type]) + ? Aggregation::$type() + : Aggregation::$type($properties[$type]) + )->alias($type) + ); + } + + $results = $client->runAggregationQuery($aggregationQuery); + + foreach ($types as $type) { + $this->compareResult($expected[$type], $results->get($type)); + } + } + + /** + * @dataProvider multipleAggregationCases + */ + public function testGqlQueryWithMultipleAggregations( + DatastoreClient $client, + $types, + $properties, + $expected + ) { + $this->skipEmulatorTests(); + $aggregateString = ''; + foreach ($types as $type) { + $str = sprintf('%s(%s) AS %sResult', $type, ($properties[$type] ?? '*'), $type); + if (!empty($aggregateString)) { + $aggregateString .= ', '; + } + $aggregateString .= $str; + } + $queryString = sprintf( + "AGGREGATE %s OVER (SELECT * From %s)", + $aggregateString, + self::$kind + ); + $query = $client->gqlQuery( + $queryString, + ['allowLiterals' => true] + ); + $aggregationQuery = $client->aggregationQuery() + ->over($query); + + $results = $client->runAggregationQuery($aggregationQuery); + + foreach ($types as $type) { + $this->compareResult($expected[$type], $results->get($type . 'Result')); + } + } + + /** + * Each case is of the format: + * [ + * // Datastore client + * DatastoreClient $client, + * + * // Aggregation Type to test + * string $type, + * + * // Property to aggregate upon + * string $property + * + * // Expected result + * mixed $expected + * ] + */ + public function filterCases() + { + $clients = $this->defaultDbClientProvider(); + $cases = []; + foreach ($clients as $name => $client) { + $client = $client[0]; + $cases[] = [$client, 'count', null, count(self::$data)]; + $cases[] = [$client, 'sum', 'score', 10]; + $cases[] = [$client, 'avg', 'score', 2.5]; + } + return $cases; + } + + /** + * Each case is of the format: + * [ + * // Datastore client + * DatastoreClient $client, + * + * // Aggregation Type to test + * string $type, + * + * // Property to aggregate upon + * string $property + * + * // Expected results + * array $expected + * ] + */ + public function limitCases() + { + $clients = $this->defaultDbClientProvider(); + $cases = []; + foreach ($clients as $name => $client) { + $client = $client[0]; + $cases[] = [$client, 'count', null, 2]; + $cases[] = [$client, 'sum', 'score', 3]; + $cases[] = [$client, 'avg', 'score', 1.5]; + } + return $cases; + } + + /** + * Each case is of the format: + * [ + * // Datastore client + * DatastoreClient $client, + * + * // Aggregation Type to test + * string $type, + * + * // Property to aggregate upon + * string $property + * + * // Expected results + * array $expected + * ] + */ + public function cornerCases() + { + $clients = $this->defaultDbClientProvider(); + + // An array of the format: + // ['casename' => [expectedSum, expectedAvg], ...] + $caseNamesWithExpectedValues = [ + 'max' => [INF, INF], + 'min' => [-INF, -INF], + 'null' => [0, null], + 'nan' => [NAN, NAN], + + // Datastore considers each value of array type as single + // element (on contrary, firestore considers array type as + // non numeric data type and ignores for sum / avg) + 'array' => [16, 4.0], + ]; + $cases = []; + foreach ($clients as $name => $client) { + $client = $client[0]; + foreach ($caseNamesWithExpectedValues as $name => $expected) { + // These corner cases are not applicable for COUNT hence omitted. + $cases[] = [$client, 'sum', $name . 'Score', $expected[0]]; + $cases[] = [$client, 'avg', $name . 'Score', $expected[1]]; + } + } + return $cases; + } + + /** + * Each case is of the format: + * [ + * // Datastore client + * DatastoreClient $client, + * + * // Aggregation Types to test + * array $types, + * + * // Properties of respective aggregates of the format [$type1 => $property1, $type2 => $property2...] + * string $properties + * + * // Expected results of the format [$type1 => $result1, $type2 => $result2...] + * array $expected + * ] + */ + public function multipleAggregationCases() + { + $clients = $this->defaultDbClientProvider(); + $cases = []; + foreach ($clients as $name => $client) { + $cases[] = [ + $client[0], + ['count', 'sum', 'avg'], + [ + 'count' => null, + 'sum' => 'score', + 'avg' => 'score' + ], + [ + 'count' => 4, + 'sum' => 10, + 'avg' => 2.5 + ] + ]; + } + return $cases; + } + + private function compareResult($expected, $actual) + { + if (is_float($expected)) { + if (is_nan($expected)) { + $this->assertNan($actual); + } else { + $this->assertEqualsWithDelta($expected, $actual, 0.01); + } + } else { + // Used because assertEquals(null, '') doesn't fails + $this->assertSame($expected, $actual); + } + } +} diff --git a/Datastore/tests/System/DatastoreMultipleDbTest.php b/Datastore/tests/System/DatastoreMultipleDbTest.php index fc8e2cb853a7..43c0ce5b64a5 100644 --- a/Datastore/tests/System/DatastoreMultipleDbTest.php +++ b/Datastore/tests/System/DatastoreMultipleDbTest.php @@ -100,19 +100,20 @@ public function testQueryMultipleDbClients(DatastoreClient $client) } /** - * @dataProvider multiDbClientProvider + * @dataProvider aggregationCases */ - public function testAggregationQueryMultipleDbClients(DatastoreClient $client) + public function testAggregationQueryMultipleDbClients(DatastoreClient $client, $type, $property, $expected) { $this->skipEmulatorTests(); + $aggregation = (is_null($property) ? Aggregation::$type() : Aggregation::$type($property)); $aggregationQuery = $client->query() ->kind(self::$kind) ->order('knownDances') - ->aggregation(Aggregation::count()->alias('total')); + ->aggregation($aggregation->alias('total')); $results = $client->runAggregationQuery($aggregationQuery); - $this->assertEquals(4, $results->get('total')); + $this->assertEquals($expected, $results->get('total')); } /** @@ -128,4 +129,34 @@ public function testQueryDefaultDbClients(DatastoreClient $client) $this->assertCount(0, $results); } + + /** + * Test cases for testing aggregation queries in multiple DBs + * + * Each case is of the format: + * [ + * // Datastore client + * DatastoreClient $client, + * + * // Aggregation Type + * string $type, + * + * // Property to aggregate upon + * string $property + * + * // Expected result + * mixed $expected + * ] + */ + public function aggregationCases() + { + $clients = $this->multiDbClientProvider(); + $cases = []; + foreach ($clients as $name => $client) { + $cases[] = [$client[0], 'count', null, 4]; + $cases[] = [$client[0], 'sum', 'knownDances', 18]; + $cases[] = [$client[0], 'avg', 'knownDances', 4.5]; + } + return $cases; + } } diff --git a/Datastore/tests/System/LookupTest.php b/Datastore/tests/System/LookupTest.php index d49ef0f48226..7020a3cdc8ad 100644 --- a/Datastore/tests/System/LookupTest.php +++ b/Datastore/tests/System/LookupTest.php @@ -99,4 +99,48 @@ public function testLookupBatchWithReadTime(DatastoreClient $client) $person = $client->lookupBatch([$key], ['readTime' => $time]); $this->assertEquals($person['found'][0]['lastName'], $lastName); } + + /** + * Tests whether double values `INF`, `-INF` and `NAN` are getting parsed + * properly in rest and grpc clients. + * + * @dataProvider clientProvider + */ + public function testLookupDoubleCases(DatastoreClient $client) + { + $kind = uniqid('double'); + $values = [ + ['value' => INF], + ['value' => -INF], + ['value' => NAN], + ['value' => 1.1] + ]; + $entities = []; + $keys = $client->keys($kind, ['number' => count($values)]); + foreach ($keys as $index => $key) { + $entities[] = $client->entity($key, $values[$index]); + } + + $client->insertBatch($entities); + foreach ($entities as $index => $entity) { + $key = $entity->key(); + self::$localDeletionQueue->add($key); + $result = $client->lookup($key); + $this->compareResult($values[$index]['value'], $result['value']); + } + } + + private function compareResult($expected, $actual) + { + if (is_float($expected)) { + if (is_nan($expected)) { + $this->assertNan($actual); + } else { + $this->assertEqualsWithDelta($expected, $actual, 0.01); + } + } else { + // Used because assertEquals(null, '') doesn't fails + $this->assertSame($expected, $actual); + } + } } diff --git a/Datastore/tests/System/RunQueryTest.php b/Datastore/tests/System/RunQueryTest.php index 6b5695626995..868f7b520957 100644 --- a/Datastore/tests/System/RunQueryTest.php +++ b/Datastore/tests/System/RunQueryTest.php @@ -392,184 +392,6 @@ public function testRunQueryWithReadTime(DatastoreClient $client) $this->assertEquals($personListEntities[0]['lastName'], $lastName); } - /** - * @dataProvider defaultDbClientProvider - */ - public function testAggregationQueryShouldFailForIncorrectAlias(DatastoreClient $client) - { - $this->skipEmulatorTests(); - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('alias does not exist'); - $aggregationQuery = $client->query() - ->kind(self::$kind) - ->filter('lastName', '=', 'Smith') - ->aggregation(Aggregation::count()); - - $results = $client->runAggregationQuery($aggregationQuery); - - $results->get('total'); - } - - /** - * @dataProvider defaultDbClientProvider - */ - public function testAggregationQueryWithFilter(DatastoreClient $client) - { - $this->skipEmulatorTests(); - $aggregationQuery = $client->query() - ->kind(self::$kind) - ->filter('lastName', '=', 'Smith') - ->aggregation(Aggregation::count()->alias('total')); - - $results = $client->runAggregationQuery($aggregationQuery); - - $this->assertEquals(3, $results->get('total')); - } - - /** - * @dataProvider defaultDbClientProvider - */ - public function testAggregationOverQueryWithFilter(DatastoreClient $client) - { - $this->skipEmulatorTests(); - $query = $client->query() - ->kind(self::$kind) - ->filter('lastName', '=', 'Smith'); - $aggregationQuery = $client->aggregationQuery() - ->over($query) - ->addAggregation(Aggregation::count()); - - $results = $client->runAggregationQuery($aggregationQuery); - - $this->assertEquals(3, $results->get('property_1')); - } - - /** - * @dataProvider defaultDbClientProvider - */ - public function testAggregationGqlQueryWithFilter(DatastoreClient $client) - { - $this->skipEmulatorTests(); - $aggregationQuery = $client->gqlQuery("SELECT count(*) as total From Person WHERE lastName = 'Smith'", [ - 'allowLiterals' => true - ]) - ->aggregation(); - - $results = $client->runAggregationQuery($aggregationQuery); - - $this->assertEquals(3, $results->get('total')); - } - - /** - * @dataProvider defaultDbClientProvider - */ - public function testAggregationOverGqlQueryWithFilter(DatastoreClient $client) - { - $this->skipEmulatorTests(); - $query = $client->gqlQuery("SELECT count(*) as total From Person WHERE lastName = 'Smith'", [ - 'allowLiterals' => true - ]); - $aggregationQuery = $client->aggregationQuery() - ->over($query); - - $results = $client->runAggregationQuery($aggregationQuery); - - $this->assertEquals(3, $results->get('total')); - } - - /** - * @dataProvider defaultDbClientProvider - */ - public function testAggregationQueryWithLimit(DatastoreClient $client) - { - $this->skipEmulatorTests(); - $query = $client->query() - ->kind(self::$kind) - ->filter('lastName', '=', 'Smith') - ->limit(2); - $aggregationQuery = $client->aggregationQuery() - ->over($query) - ->addAggregation(Aggregation::count()->alias('total_upto_2')); - - $results = $client->runAggregationQuery($aggregationQuery); - - $this->assertEquals(2, $results->get('total_upto_2')); - } - - /** - * @dataProvider defaultDbClientProvider - */ - public function testAggregationGqlQueryWithLimit(DatastoreClient $client) - { - $this->skipEmulatorTests(); - $queryString = sprintf( - "AGGREGATE - COUNT_UP_TO(2) AS total_upto_2 - OVER ( - SELECT * From Person WHERE lastName = 'Smith' - )", - ); - $query = $client->gqlQuery( - $queryString, - ['allowLiterals' => true] - ); - $aggregationQuery = $client->aggregationQuery() - ->over($query); - - $results = $client->runAggregationQuery($aggregationQuery); - - $this->assertEquals(2, $results->get('total_upto_2')); - } - - /** - * @dataProvider defaultDbClientProvider - */ - public function testAggregationQueryWithMultipleAggregations(DatastoreClient $client) - { - $this->skipEmulatorTests(); - $query = $client->query() - ->kind(self::$kind) - ->filter('lastName', '=', 'Smith'); - $aggregationQuery = $client->aggregationQuery() - ->over($query) - ->addAggregation(Aggregation::count()->alias('total_count')) - ->addAggregation(Aggregation::count()->alias('max_count')); - - $results = $client->runAggregationQuery($aggregationQuery); - - $this->assertEquals(3, $results->get('total_count')); - $this->assertEquals(3, $results->get('max_count')); - } - - /** - * @dataProvider defaultDbClientProvider - */ - public function testAggregationGqlQueryWithMultipleAggregations(DatastoreClient $client) - { - $this->skipEmulatorTests(); - $queryString = sprintf( - "AGGREGATE - COUNT(*) AS total_count, - COUNT_UP_TO(1) AS count_up_to_1, - COUNT_UP_TO(2) AS count_up_to_2 - OVER ( - SELECT * From Person WHERE lastName = 'Smith' - )", - ); - $query = $client->gqlQuery( - $queryString, - ['allowLiterals' => true] - ); - $aggregationQuery = $client->aggregationQuery() - ->over($query); - - $results = $client->runAggregationQuery($aggregationQuery); - - $this->assertEquals(3, $results->get('total_count')); - $this->assertEquals(2, $results->get('count_up_to_2')); - $this->assertEquals(1, $results->get('count_up_to_1')); - } - private function runQueryAndSortResults($client, $query) { $results = iterator_to_array($client->runQuery($query)); diff --git a/Datastore/tests/System/RunTransactionTest.php b/Datastore/tests/System/RunTransactionTest.php index 4bf80fa81b7e..adb1120f6779 100644 --- a/Datastore/tests/System/RunTransactionTest.php +++ b/Datastore/tests/System/RunTransactionTest.php @@ -82,51 +82,48 @@ public function testRunTransactions(DatastoreClient $client) } /** - * @dataProvider multiDbClientProvider + * @dataProvider aggregationCases */ - public function testRunAggregationQueryWithTransactions(DatastoreClient $client) + public function testRunAggregationQueryWithTransactions(DatastoreClient $client, $type, $property, $expected) { $this->skipEmulatorTests(); - $kind = 'Person'; - $testId = rand(1, 999999); - $key1 = $client->key($kind, $testId); - $data = ['lastName' => 'Smith']; - $newLastName = 'NotSmith'; - $entity1 = $client->entity($key1, $data); + $kind = uniqid('Person'); + $key1 = $client->key($kind, 1); + $key2 = $client->key($kind, 2); + $entity1 = $client->entity($key1, ['score' => 10]); + $entity2 = $client->entity($key2, ['score' => 20]); $transaction = $client->transaction(); $transaction->insert($entity1); + $transaction->upsert($entity2); $transaction->commit(); self::$localDeletionQueue->add($key1); + self::$localDeletionQueue->add($key2); // validate default DB should not have data $defaultDbClient = current(self::defaultDbClientProvider())[0]; - $this->assertOtherDbEntities($defaultDbClient, $kind, $testId, 0); + $this->assertOtherDbEntities($defaultDbClient, $kind, $key1->pathEndIdentifier(), 0); // transaction with query $transaction2 = $client->transaction(); $query = $client->query() - ->kind($kind) - ->hasAncestor($key1) - ->filter('lastName', '=', 'Smith'); + ->kind($kind); $results = iterator_to_array($transaction2->runQuery($query)); - $this->assertAggregationQueryResult($transaction2, $query, 1); + $this->assertAggregationQueryResult($transaction2, $query, $expected[0], $type, $property); - $results[0]['lastName'] = $newLastName; - $transaction2->update($results[0]); + $results[1]['score'] = 100; + $transaction2->update($results[1]); $transaction2->commit(); - $this->assertCount(1, $results); + $this->assertCount(2, $results); // read transaction with aggregation query $transaction3 = $client->readOnlyTransaction(); $query = $client->query() - ->kind($kind) - ->hasAncestor($key1) - ->filter('lastName', '=', 'Smith'); - $this->assertAggregationQueryResult($transaction3, $query, 0); + ->kind($kind); + $this->assertAggregationQueryResult($transaction3, $query, $expected[1], $type, $property); } /** @@ -247,10 +244,46 @@ private function assertOtherDbEntities($client, $kind, $id, $expectedCount) $this->assertCount($expectedCount, $results); } - private function assertAggregationQueryResult($transaction, $query, $expectedAggregationCount) + /** + * Test cases for testing aggregation queries in transaction + * + * Each case is of the format: + * [ + * // Datastore client + * DatastoreClient $client, + * + * // Aggregation Type + * string $type, + * + * // Property to aggregate upon + * string $property + * + * // Expected results of the format [$expectedBeforeUpdate, $expectedAfterUpdate] + * array $expected + * ] + */ + public function aggregationCases() { - $aggregationQuery = $query->aggregation(Aggregation::count()->alias('total')); + $clients = $this->multiDbClientProvider(); + $cases = []; + foreach ($clients as $name => $client) { + $cases[] = [$client[0], 'count', null, [2, 2]]; + $cases[] = [$client[0], 'sum', 'score', [30, 110]]; + $cases[] = [$client[0], 'avg', 'score', [15, 55]]; + } + return $cases; + } + + private function assertAggregationQueryResult( + $transaction, + $query, + $expected, + $type, + $property = null + ) { + $aggregation = (is_null($property) ? Aggregation::$type() : Aggregation::$type($property)); + $aggregationQuery = $query->aggregation($aggregation->alias('total')); $results = $transaction->runAggregationQuery($aggregationQuery); - $this->assertEquals($expectedAggregationCount, $results->get('total')); + $this->assertEquals($expected, $results->get('total')); } } diff --git a/Datastore/tests/Unit/DatastoreClientTest.php b/Datastore/tests/Unit/DatastoreClientTest.php index 450c6ea7b859..d0dc09156c86 100644 --- a/Datastore/tests/Unit/DatastoreClientTest.php +++ b/Datastore/tests/Unit/DatastoreClientTest.php @@ -679,6 +679,43 @@ public function testRunAggregationQuery() $this->assertInstanceOf(AggregationQueryResult::class, $res); } + /** + * @dataProvider aggregationReturnTypesCases + */ + public function testAggregationQueryWithDifferentReturnTypes($response, $expected) + { + $this->connection->runAggregationQuery(Argument::allOf( + Argument::withEntry('partitionId', ['projectId' => self::PROJECT]), + Argument::withEntry('gqlQuery', [ + 'queryString' => 'foo bar' + ]) + ))->shouldBeCalled()->willReturn([ + 'batch' => [ + 'aggregationResults' => [ + [ + 'aggregateProperties' => ['property_1' => $response] + ] + ], + 'readTime' => (new \DateTime)->format('Y-m-d\TH:i:s') .'.000001Z' + ] + ]); + + $this->refreshOperation($this->client, $this->connection->reveal(), [ + 'projectId' => self::PROJECT + ]); + + $query = $this->prophesize(AggregationQuery::class); + $query->queryObject()->willReturn([ + 'gqlQuery' => [ + 'queryString' => 'foo bar' + ] + ]); + + $res = $this->client->runAggregationQuery($query->reveal()); + $this->assertInstanceOf(AggregationQueryResult::class, $res); + $this->compareResult($expected, $res->get('property_1')); + } + public function testRunQueryWithReadTime() { $key = $this->client->key('Person', 'John'); @@ -710,6 +747,27 @@ public function testRunQueryWithReadTime() $this->assertContainsOnlyInstancesOf(Entity::class, $res); } + public function aggregationReturnTypesCases() + { + return [ + [['integerValue' => 1], 1], + [['doubleValue' => 1.1], 1.1], + + // Returned incase of grpc client + [['doubleValue' => INF], INF], + [['doubleValue' => -INF], -INF], + [['doubleValue' => NAN], NAN], + + // Returned incase of rest client + [['doubleValue' => 'Infinity'], INF], + [['doubleValue' => '-Infinity'], -INF], + [['doubleValue' => 'NaN'], NAN], + + + [['nullValue' => ''], null], + ]; + } + private function commitResponse() { return [ @@ -749,4 +807,18 @@ private function mutationsProviderProvider($id, $partialKey = false) ['upsert', $mutation, clone $key, $id], ]; } + + private function compareResult($expected, $actual) + { + if (is_float($expected)) { + if (is_nan($expected)) { + $this->assertNan($actual); + } else { + $this->assertEqualsWithDelta($expected, $actual, 0.01); + } + } else { + // Used because assertEquals(null, '') doesn't fails + $this->assertSame($expected, $actual); + } + } } diff --git a/Datastore/tests/Unit/EntityMapperTest.php b/Datastore/tests/Unit/EntityMapperTest.php index 684c96b7cc4e..f1937a85af6b 100644 --- a/Datastore/tests/Unit/EntityMapperTest.php +++ b/Datastore/tests/Unit/EntityMapperTest.php @@ -91,17 +91,20 @@ public function testResponseToPropertiesBooleanValue() $this->assertTrue($res['foo']); } - public function testResponseToPropertiesDoubleValue() + /** + * @dataProvider datastoreToSimpleDoubleValueCases + */ + public function testResponseToPropertiesDoubleValue($input, $expected) { $data = [ 'foo' => [ - 'doubleValue' => 1.1 + 'doubleValue' => $input ] ]; $res = $this->mapper->responseToEntityProperties($data)['properties']; - $this->assertEquals(1.1, $res['foo']); + $this->compareResult($expected, $res['foo']); } public function testResponseToPropertiesTimestampValue() @@ -563,14 +566,17 @@ public function testConvertValueEntityWithoutKeyExcludes() $this->assertEquals(['prop'], $res[Entity::EXCLUDE_FROM_INDEXES]); } - public function testConvertValueDouble() + /** + * @dataProvider datastoreToSimpleDoubleValueCases + */ + public function testConvertValueDouble($input, $expected) { $type = 'doubleValue'; - $val = 1.1; + $val = $input; $res = $this->mapper->convertValue($type, $val); $this->assertIsFloat($res); - $this->assertEquals(1.1, $res); + $this->compareResult($expected, $res); } public function testConvertValueDoubleWithCast() @@ -670,11 +676,24 @@ public function testValueObjectInt() $this->assertEquals(1, $int['integerValue']); } - public function testValueObjectDouble() + /** + * @dataProvider valueObjectDoubleCases + */ + public function testValueObjectDoubleForGrpcClient($input, $expected) { - $double = $this->mapper->valueObject(1.1); + $double = $this->mapper->valueObject($input); + + $this->compareResult($expected, $double['doubleValue']); + } - $this->assertEquals(1.1, $double['doubleValue']); + /** + * @dataProvider valueObjectDoubleForRestCases + */ + public function testValueObjectDoubleForRestClient($input, $expected) + { + $mapper = new EntityMapper('foo', true, false, 'rest'); + $double = $mapper->valueObject($input); + $this->compareResult($expected, $double['doubleValue']); } public function testValueObjectString() @@ -898,4 +917,55 @@ public function testObjectPropertyInt64() 'integerValue' => $int64->get() ], $res); } + + public function datastoreToSimpleDoubleValueCases() + { + return [ + [1.1, 1.1], + + // Happens when using rest client + ['Infinity', INF], + ['-Infinity', -INF], + ['NaN', NAN], + + // Happens when using grpc client + [INF, INF], + [-INF, -INF], + [NAN, NAN] + ]; + } + + public function valueObjectDoubleCases() + { + return [ + [INF, INF], + [1.1, 1.1], + [-INF, -INF], + [NAN, NAN] + ]; + } + + public function valueObjectDoubleForRestCases() + { + return [ + [INF, 'Infinity'], + [1.1, 1.1], + [-INF, '-Infinity'], + [NAN, 'NaN'] + ]; + } + + private function compareResult($expected, $actual) + { + if (is_float($expected)) { + if (is_nan($expected)) { + $this->assertNan($actual); + } else { + $this->assertEqualsWithDelta($expected, $actual, 0.01); + } + } else { + // Used because assertEquals(null, '') doesn't fails + $this->assertSame($expected, $actual); + } + } } diff --git a/Datastore/tests/Unit/Query/AggregationQueryTest.php b/Datastore/tests/Unit/Query/AggregationQueryTest.php index c74fbfe50b14..8d5bcf5fc30d 100644 --- a/Datastore/tests/Unit/Query/AggregationQueryTest.php +++ b/Datastore/tests/Unit/Query/AggregationQueryTest.php @@ -92,27 +92,33 @@ public function testLimit() $this->assertArrayHasKey('aggregations', $query->queryObject()['aggregationQuery']); } - public function testCount() + /** + * @dataProvider aggregationTypes + */ + public function testAggregation($type) { $expectedQuery = [ 'aggregations' => [ - ['count' => []] + [$type => ($type == 'count' ? [] : ['property' => ['name' => 'foo']])] ], 'nestedQuery' => [] ]; $query = new AggregationQuery($this->query); - $query->addAggregation(Aggregation::count()); + $query->addAggregation($type == 'count' ? Aggregation::$type() : Aggregation::$type('foo')); $this->assertEquals($expectedQuery, $query->queryObject()['aggregationQuery']); } - public function testAlias() + /** + * @dataProvider aggregationTypes + */ + public function testAlias($type) { $expectedQuery = [ 'aggregations' => [ [ - 'count' => [], + $type => ($type == 'count' ? [] : ['property' => ['name' => 'foo']]), 'alias' => 'total' ] ], @@ -120,8 +126,19 @@ public function testAlias() ]; $query = new AggregationQuery($this->query); - $query->addAggregation(Aggregation::count()->alias('total')); + $query->addAggregation( + ($type == 'count' ? Aggregation::$type() : Aggregation::$type('foo'))->alias('total') + ); $this->assertEquals($expectedQuery, $query->queryObject()['aggregationQuery']); } + + public function aggregationTypes() + { + return [ + ['count'], + ['sum'], + ['avg'] + ]; + } } diff --git a/Datastore/tests/Unit/Query/AggregationTest.php b/Datastore/tests/Unit/Query/AggregationTest.php index c0489fafae02..8d697bf515bd 100644 --- a/Datastore/tests/Unit/Query/AggregationTest.php +++ b/Datastore/tests/Unit/Query/AggregationTest.php @@ -25,27 +25,42 @@ */ class AggregationTest extends TestCase { - public function testCountType() + /** + * @dataProvider aggregationTypes + */ + public function testAggregationType($type) { $expectedQuery = [ - 'count' => [] + $type => ($type == 'count' ? [] : ['property' => ['name' => 'foo']]), ]; - $aggregation = Aggregation::count(); + $aggregation = ($type == 'count' ? Aggregation::$type() : Aggregation::$type('foo')); $this->assertEquals($expectedQuery, $aggregation->getProps()); } - public function testAlias() + /** + * @dataProvider aggregationTypes + */ + public function testAlias($type) { $alias = uniqid(); $expectedQuery = [ - 'count' => [], + $type => ($type == 'count' ? [] : ['property' => ['name' => 'foo']]), 'alias' => $alias ]; - $aggregation = Aggregation::count()->alias($alias); + $aggregation = ($type == 'count' ? Aggregation::$type() : Aggregation::$type('foo'))->alias($alias); $this->assertEquals($expectedQuery, $aggregation->getProps()); } + + public function aggregationTypes() + { + return [ + ['count'], + ['sum'], + ['avg'] + ]; + } }