diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0f583fee..89e34d26 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -34,11 +34,11 @@ jobs: - '4.0' - '7.2' - '7.4' - - '8.0' - '8.2' - '8.4' - '8.6' - '8.8' + - '8.10' steps: @@ -46,11 +46,11 @@ jobs: run: | # Mapping of original redis versions to client test containers declare -A redis_clients_version_mapping=( + ["8.10"]="unstable-27987813126-debian" ["8.8"]="8.8.0" ["8.6"]="8.6.1" ["8.4"]="8.4.0" ["8.2"]="8.2.2" - ["8.0"]="8.0.2" ["7.4"]="7.4.2" ["7.2"]="7.2.7" ) diff --git a/CHANGELOG.md b/CHANGELOG.md index e290577d..711aed70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### Added - Make ZMSCORE command Prefixable and add to a ClusterStrategy (#1692) +- Added support for new TS commands + Indonesian language support integration test (#1695) ### Fixed - Fixed Sentinel does not wipe servers on exception caused (#1694) diff --git a/src/ClientContextInterface.php b/src/ClientContextInterface.php index 2e02140d..6b99b57e 100644 --- a/src/ClientContextInterface.php +++ b/src/ClientContextInterface.php @@ -37,6 +37,7 @@ use Predis\Command\Argument\TimeSeries\IncrByArguments; use Predis\Command\Argument\TimeSeries\InfoArguments; use Predis\Command\Argument\TimeSeries\MGetArguments; use Predis\Command\Argument\TimeSeries\MRangeArguments; +use Predis\Command\Argument\TimeSeries\NRangeArguments; use Predis\Command\Argument\TimeSeries\RangeArguments; use Predis\Command\CommandInterface; use Predis\Command\Container\ACL; @@ -308,6 +309,8 @@ use Predis\Command\Redis\VADD; * @method $this tsmget(MGetArguments $arguments, string ...$filterExpression) * @method $this tsmrange($fromTimestamp, $toTimestamp, MRangeArguments $arguments) * @method $this tsmrevrange($fromTimestamp, $toTimestamp, MRangeArguments $arguments) + * @method $this tsnrange(array $keys, $fromTimestamp, $toTimestamp, ?NRangeArguments $arguments = null) + * @method $this tsnrevrange(array $keys, $fromTimestamp, $toTimestamp, ?NRangeArguments $arguments = null) * @method $this tsqueryindex(string ...$filterExpression) * @method $this tsrange(string $key, $fromTimestamp, $toTimestamp, ?RangeArguments $arguments = null) * @method $this tsrevrange(string $key, $fromTimestamp, $toTimestamp, ?RangeArguments $arguments = null) diff --git a/src/ClientInterface.php b/src/ClientInterface.php index 71d2ed1f..03e08ecb 100644 --- a/src/ClientInterface.php +++ b/src/ClientInterface.php @@ -37,6 +37,7 @@ use Predis\Command\Argument\TimeSeries\IncrByArguments; use Predis\Command\Argument\TimeSeries\InfoArguments; use Predis\Command\Argument\TimeSeries\MGetArguments; use Predis\Command\Argument\TimeSeries\MRangeArguments; +use Predis\Command\Argument\TimeSeries\NRangeArguments; use Predis\Command\Argument\TimeSeries\RangeArguments; use Predis\Command\CommandInterface; use Predis\Command\Container\ACL; @@ -320,6 +321,8 @@ use Predis\Response\Status; * @method array tsmget(MGetArguments $arguments, string ...$filterExpression) * @method array tsmrange($fromTimestamp, $toTimestamp, MRangeArguments $arguments) * @method array tsmrevrange($fromTimestamp, $toTimestamp, MRangeArguments $arguments) + * @method array tsnrange(array $keys, $fromTimestamp, $toTimestamp, ?NRangeArguments $arguments = null) + * @method array tsnrevrange(array $keys, $fromTimestamp, $toTimestamp, ?NRangeArguments $arguments = null) * @method array tsqueryindex(string ...$filterExpression) * @method array tsrange(string $key, $fromTimestamp, $toTimestamp, ?RangeArguments $arguments = null) * @method array tsrevrange(string $key, $fromTimestamp, $toTimestamp, ?RangeArguments $arguments = null) diff --git a/src/Command/Argument/TimeSeries/NRangeArguments.php b/src/Command/Argument/TimeSeries/NRangeArguments.php new file mode 100644 index 00000000..2dd94e14 --- /dev/null +++ b/src/Command/Argument/TimeSeries/NRangeArguments.php @@ -0,0 +1,51 @@ + 0) { + array_push($this->arguments, 'ALIGN', $align); + } + + array_push($this->arguments, 'AGGREGATION', ...$aggregators, ...[$bucketDuration]); + + if ($bucketTimestamp > 0) { + array_push($this->arguments, 'BUCKETTIMESTAMP', $bucketTimestamp); + } + + if (true === $empty) { + $this->arguments[] = 'EMPTY'; + } + + return $this; + } +} diff --git a/src/Command/Redis/TimeSeries/TSNRANGE.php b/src/Command/Redis/TimeSeries/TSNRANGE.php new file mode 100644 index 00000000..99711ed3 --- /dev/null +++ b/src/Command/Redis/TimeSeries/TSNRANGE.php @@ -0,0 +1,61 @@ +toArray() : []; + + parent::setArguments(array_merge( + [count($keys)], + $keys, + [$fromTimestamp, $toTimestamp], + $commandArguments + )); + } + + public function prefixKeys($prefix) + { + $arguments = $this->getArguments(); + + $keysCount = $arguments[0]; + $keys = array_slice($arguments, 1, $keysCount); + $prefixedKeys = array_map(static function ($key) use ($prefix) { + return $prefix . $key; + }, $keys); + + $this->setRawArguments(array_merge( + [$arguments[0]], + $prefixedKeys, + array_slice($arguments, $keysCount + 1) + )); + } +} diff --git a/src/Command/Redis/TimeSeries/TSNREVRANGE.php b/src/Command/Redis/TimeSeries/TSNREVRANGE.php new file mode 100644 index 00000000..8a743367 --- /dev/null +++ b/src/Command/Redis/TimeSeries/TSNREVRANGE.php @@ -0,0 +1,29 @@ +arguments = new NRangeArguments(); + } + + /** + * @return void + */ + public function testInheritsCommonRangeModifiers(): void + { + $this->arguments + ->latest() + ->filterByTs(1000, 1001) + ->filterByValue(1000, 1001) + ->count(100); + + $this->assertSame( + ['LATEST', 'FILTER_BY_TS', 1000, 1001, 'FILTER_BY_VALUE', 1000, 1001, 'COUNT', 100], + $this->arguments->toArray() + ); + } + + /** + * @dataProvider aggregatorProvider + * @param array $arguments + * @param array $expectedResponse + * @return void + */ + public function testCreatesArgumentsWithAggregatorModifierAsSeparateTokens(array $arguments, array $expectedResponse): void + { + $this->arguments->aggregation(...$arguments); + + $this->assertSame($expectedResponse, $this->arguments->toArray()); + } + + public function aggregatorProvider(): array + { + return [ + 'with single aggregator' => [ + [NRangeArguments::AGG_SUM, 1000], + ['AGGREGATION', NRangeArguments::AGG_SUM, 1000], + ], + 'with multiple aggregators as array' => [ + [[NRangeArguments::AGG_MIN, NRangeArguments::AGG_MAX], 1000], + ['AGGREGATION', 'min', 'max', 1000], + ], + 'with multiple aggregators as comma-separated string' => [ + ['min,max', 1000], + ['AGGREGATION', 'min', 'max', 1000], + ], + 'with ALIGN modifier' => [ + [[NRangeArguments::AGG_MIN, NRangeArguments::AGG_MAX], 1000, 10], + ['ALIGN', 10, 'AGGREGATION', 'min', 'max', 1000], + ], + 'with multiple aggregators and all arguments' => [ + [[NRangeArguments::AGG_MIN, NRangeArguments::AGG_MAX], 1000, 10, 10000, true], + ['ALIGN', 10, 'AGGREGATION', 'min', 'max', 1000, 'BUCKETTIMESTAMP', 10000, 'EMPTY'], + ], + ]; + } +} diff --git a/tests/Predis/Command/Redis/Search/FTSEARCH_Test.php b/tests/Predis/Command/Redis/Search/FTSEARCH_Test.php index e93f8225..b13d542d 100644 --- a/tests/Predis/Command/Redis/Search/FTSEARCH_Test.php +++ b/tests/Predis/Command/Redis/Search/FTSEARCH_Test.php @@ -181,6 +181,41 @@ class FTSEARCH_Test extends PredisCommandTestCase $this->assertNotEmpty($actualResponse); } + /** + * @group connected + * @group relay-resp3 + * @return void + * @requiresRedisVersion >= 8.9.0 + */ + public function testSearchValuesByIndonesianLanguage(): void + { + $redis = $this->getClient(); + + $this->assertEquals( + 'OK', + $redis->hmset('doc:1', 'content', 'mereka membaca buku di perpustakaan') + ); + + $ftCreateArguments = (new CreateArguments()) + ->prefix(['doc:']) + ->language('indonesian'); + + $schema = [new TextField('content')]; + + $this->assertEquals('OK', $redis->ftcreate('idx_indonesian', $schema, $ftCreateArguments)); + + // Timeout to make sure that index created before search performed. + usleep(10000); + + // The Indonesian stemmer reduces "membaca" to its root "baca", so querying + // the stem with LANGUAGE indonesian matches the indexed document. + $ftSearchArguments = (new SearchArguments()) + ->language('indonesian') + ->noContent(); + + $this->assertSame([1, 'doc:1'], $redis->ftsearch('idx_indonesian', 'baca', $ftSearchArguments)); + } + /** * @group connected * @group relay-resp3 diff --git a/tests/Predis/Command/Redis/TimeSeries/TSNRANGE_Test.php b/tests/Predis/Command/Redis/TimeSeries/TSNRANGE_Test.php new file mode 100644 index 00000000..74dd2cbd --- /dev/null +++ b/tests/Predis/Command/Redis/TimeSeries/TSNRANGE_Test.php @@ -0,0 +1,302 @@ +getCommand(); + $command->setArguments($actualArguments); + + $this->assertSameValues($expectedArguments, $command->getArguments()); + } + + /** + * @group disconnected + */ + public function testParseResponse(): void + { + $this->assertSame(1, $this->getCommand()->parseResponse(1)); + } + + /** + * @group disconnected + * @dataProvider parseResponseProvider + */ + public function testParseResponsePassesThroughTimestampMajorResults(array $response): void + { + $this->assertSame($response, $this->getCommand()->parseResponse($response)); + } + + public function parseResponseProvider(): array + { + return [ + 'single key' => [ + [[1000, ['100']], [1020, ['120']]], + ], + 'multiple keys' => [ + [[1000, ['100', '200']], [1020, ['120', '170']]], + ], + ]; + } + + /** + * @group disconnected + */ + public function testPrefixKeys(): void + { + /** @var PrefixableCommand $command */ + $command = $this->getCommand(); + $actualArguments = [['key1', 'key2'], 1000, 1001, (new NRangeArguments())->count(100)]; + $prefix = 'prefix:'; + $expectedArguments = [2, 'prefix:key1', 'prefix:key2', 1000, 1001, 'COUNT', 100]; + + $command->setArguments($actualArguments); + $command->prefixKeys($prefix); + + $this->assertSame($expectedArguments, $command->getArguments()); + } + + /** + * @group connected + * @group relay-resp3 + * @return void + * @requiresRedisVersion >= 8.9.0 + */ + public function testReturnsQueriedRangeForMultipleKeysInForwardDirection(): void + { + $redis = $this->getClient(); + + $this->assertEquals('OK', $redis->tscreate('temp:TLV', (new CreateArguments())->labels('type', 'temp', 'location', 'TLV'))); + $this->assertEquals('OK', $redis->tscreate('temp:JLM', (new CreateArguments())->labels('type', 'temp', 'location', 'JLM'))); + + $this->assertSame([1000, 1010, 1020], $redis->tsmadd('temp:TLV', 1000, 30, 'temp:TLV', 1010, 35, 'temp:TLV', 1020, 40)); + $this->assertSame([1000, 1010, 1020], $redis->tsmadd('temp:JLM', 1000, 25, 'temp:JLM', 1010, 27, 'temp:JLM', 1020, 29)); + + $this->assertEquals( + [ + [1000, ['30', '25']], + [1010, ['35', '27']], + [1020, ['40', '29']], + ], + $redis->tsnrange(['temp:TLV', 'temp:JLM'], '-', '+') + ); + } + + /** + * @group connected + * @return void + * @requiresRedisVersion >= 8.9.0 + */ + public function testReturnsQueriedRangeForMultipleKeysInForwardDirectionResp3(): void + { + $redis = $this->getResp3Client(); + + $this->assertEquals('OK', $redis->tscreate('temp:TLV', (new CreateArguments())->labels('type', 'temp', 'location', 'TLV'))); + $this->assertEquals('OK', $redis->tscreate('temp:JLM', (new CreateArguments())->labels('type', 'temp', 'location', 'JLM'))); + + $this->assertSame([1000, 1010, 1020], $redis->tsmadd('temp:TLV', 1000, 30, 'temp:TLV', 1010, 35, 'temp:TLV', 1020, 40)); + $this->assertSame([1000, 1010, 1020], $redis->tsmadd('temp:JLM', 1000, 25, 'temp:JLM', 1010, 27, 'temp:JLM', 1020, 29)); + + $this->assertEquals( + [ + [1000, ['30', '25']], + [1010, ['35', '27']], + [1020, ['40', '29']], + ], + $redis->tsnrange(['temp:TLV', 'temp:JLM'], '-', '+') + ); + } + + /** + * @group connected + * @group relay-resp3 + * @return void + * @requiresRedisVersion >= 8.9.0 + */ + public function testPreservesInputKeyOrder(): void + { + $redis = $this->getClient(); + + $this->assertEquals('OK', $redis->tscreate('temp:TLV', (new CreateArguments())->labels('type', 'temp', 'location', 'TLV'))); + $this->assertEquals('OK', $redis->tscreate('temp:JLM', (new CreateArguments())->labels('type', 'temp', 'location', 'JLM'))); + + $this->assertSame([1000], $redis->tsmadd('temp:TLV', 1000, 30)); + $this->assertSame([1000], $redis->tsmadd('temp:JLM', 1000, 25)); + + // Reversed key order should reverse the values in each row. + $this->assertEquals( + [[1000, ['25', '30']]], + $redis->tsnrange(['temp:JLM', 'temp:TLV'], '-', '+') + ); + } + + /** + * @group connected + * @group relay-resp3 + * @return void + * @requiresRedisVersion >= 8.9.0 + */ + public function testReturnsRangeLimitedByCount(): void + { + $redis = $this->getClient(); + + $this->assertEquals('OK', $redis->tscreate('temp:TLV', (new CreateArguments())->labels('type', 'temp', 'location', 'TLV'))); + $this->assertEquals('OK', $redis->tscreate('temp:JLM', (new CreateArguments())->labels('type', 'temp', 'location', 'JLM'))); + + $this->assertSame([1000, 1010, 1020], $redis->tsmadd('temp:TLV', 1000, 30, 'temp:TLV', 1010, 35, 'temp:TLV', 1020, 40)); + $this->assertSame([1000, 1010, 1020], $redis->tsmadd('temp:JLM', 1000, 25, 'temp:JLM', 1010, 27, 'temp:JLM', 1020, 29)); + + $arguments = (new NRangeArguments())->count(2); + + $this->assertEquals( + [ + [1000, ['30', '25']], + [1010, ['35', '27']], + ], + $redis->tsnrange(['temp:TLV', 'temp:JLM'], '-', '+', $arguments) + ); + } + + /** + * @group connected + * @group relay-resp3 + * @return void + * @requiresRedisVersion >= 8.9.0 + */ + public function testReturnsRangeWithAggregationPerKey(): void + { + $redis = $this->getClient(); + + $this->assertEquals('OK', $redis->tscreate('stock:A', (new CreateArguments())->labels('type', 'stock', 'name', 'A'))); + $this->assertEquals('OK', $redis->tscreate('stock:B', (new CreateArguments())->labels('type', 'stock', 'name', 'B'))); + + $this->assertSame([1000, 1010, 1020], $redis->tsmadd('stock:A', 1000, 100, 'stock:A', 1010, 110, 'stock:A', 1020, 120)); + $this->assertSame([1000, 1010, 1020], $redis->tsmadd('stock:B', 1000, 200, 'stock:B', 1010, 210, 'stock:B', 1020, 220)); + + // Exactly numkeys aggregators are required, one per key. + $arguments = (new NRangeArguments())->aggregation([NRangeArguments::AGG_MIN, NRangeArguments::AGG_MAX], 1000); + + $response = $redis->tsnrange(['stock:A', 'stock:B'], '-', '+', $arguments); + + // Response is timestamp-major and each row holds one aggregated value + // per queried key, preserving the input key order. + $this->assertNotEmpty($response); + + foreach ($response as $row) { + $this->assertIsInt($row[0]); + $this->assertCount(2, $row[1]); + } + } + + /** + * @group connected + * @group relay-resp3 + * @return void + * @requiresRedisVersion >= 8.9.0 + */ + public function testThrowsExceptionOnNonExistingKey(): void + { + $redis = $this->getClient(); + + $this->expectException(ServerException::class); + $this->expectExceptionMessage('ERR TSDB: the key does not exist'); + + $redis->tsnrange(['non_existing_key'], 1000, 1000); + } + + public function argumentsProvider(): array + { + return [ + 'with single key' => [ + [['key'], 10000, 10001], + [1, 'key', 10000, 10001], + ], + 'with multiple keys' => [ + [['key1', 'key2'], 10000, 10001], + [2, 'key1', 'key2', 10000, 10001], + ], + 'with duplicate keys preserved' => [ + [['key', 'key'], 10000, 10001], + [2, 'key', 'key', 10000, 10001], + ], + 'with LATEST modifier' => [ + [['key'], 10000, 10001, (new NRangeArguments())->latest()], + [1, 'key', 10000, 10001, 'LATEST'], + ], + 'with FILTER_BY_TS modifier' => [ + [['key'], 10000, 10001, (new NRangeArguments())->filterByTs(1000, 1001)], + [1, 'key', 10000, 10001, 'FILTER_BY_TS', 1000, 1001], + ], + 'with FILTER_BY_VALUE modifier' => [ + [['key'], 10000, 10001, (new NRangeArguments())->filterByValue(1000, 1001)], + [1, 'key', 10000, 10001, 'FILTER_BY_VALUE', 1000, 1001], + ], + 'with COUNT modifier' => [ + [['key'], 10000, 10001, (new NRangeArguments())->count(100)], + [1, 'key', 10000, 10001, 'COUNT', 100], + ], + 'with AGGREGATION modifier - default arguments' => [ + [['key1', 'key2'], 10000, 10001, (new NRangeArguments())->aggregation(['min', 'max'], 100)], + [2, 'key1', 'key2', 10000, 10001, 'AGGREGATION', 'min', 'max', 100], + ], + 'with AGGREGATION modifier - with ALIGN' => [ + [['key'], 10000, 10001, (new NRangeArguments())->aggregation('sum', 100, 100)], + [1, 'key', 10000, 10001, 'ALIGN', 100, 'AGGREGATION', 'sum', 100], + ], + 'with AGGREGATION modifier - with BUCKETTIMESTAMP' => [ + [['key'], 10000, 10001, (new NRangeArguments())->aggregation('sum', 100, 0, 1000)], + [1, 'key', 10000, 10001, 'AGGREGATION', 'sum', 100, 'BUCKETTIMESTAMP', 1000], + ], + 'with AGGREGATION modifier - with EMPTY' => [ + [['key'], 10000, 10001, (new NRangeArguments())->aggregation('sum', 100, 0, 0, true)], + [1, 'key', 10000, 10001, 'AGGREGATION', 'sum', 100, 'EMPTY'], + ], + 'with all modifiers' => [ + [['key1', 'key2'], 10000, 10001, (new NRangeArguments())->latest()->filterByTs(1000, 1001)->filterByValue(1000, 1001)->count(100)->aggregation(['min', 'max'], 100)], + [2, 'key1', 'key2', 10000, 10001, 'LATEST', 'FILTER_BY_TS', 1000, 1001, 'FILTER_BY_VALUE', 1000, 1001, 'COUNT', 100, 'AGGREGATION', 'min', 'max', 100], + ], + ]; + } +} diff --git a/tests/Predis/Command/Redis/TimeSeries/TSNREVRANGE_Test.php b/tests/Predis/Command/Redis/TimeSeries/TSNREVRANGE_Test.php new file mode 100644 index 00000000..93675f1b --- /dev/null +++ b/tests/Predis/Command/Redis/TimeSeries/TSNREVRANGE_Test.php @@ -0,0 +1,303 @@ +getCommand(); + $command->setArguments($actualArguments); + + $this->assertSameValues($expectedArguments, $command->getArguments()); + } + + /** + * @group disconnected + */ + public function testParseResponse(): void + { + $this->assertSame(1, $this->getCommand()->parseResponse(1)); + } + + /** + * @group disconnected + * @dataProvider parseResponseProvider + */ + public function testParseResponsePassesThroughTimestampMajorResults(array $response): void + { + $this->assertSame($response, $this->getCommand()->parseResponse($response)); + } + + public function parseResponseProvider(): array + { + return [ + 'single key' => [ + [[1020, ['120']], [1000, ['100']]], + ], + 'multiple keys' => [ + [[1020, ['120', '170']], [1000, ['100', '200']]], + ], + ]; + } + + /** + * @group disconnected + */ + public function testPrefixKeys(): void + { + /** @var PrefixableCommand $command */ + $command = $this->getCommand(); + $actualArguments = [['key1', 'key2'], 1000, 1001, (new NRangeArguments())->count(100)]; + $prefix = 'prefix:'; + $expectedArguments = [2, 'prefix:key1', 'prefix:key2', 1000, 1001, 'COUNT', 100]; + + $command->setArguments($actualArguments); + $command->prefixKeys($prefix); + + $this->assertSame($expectedArguments, $command->getArguments()); + } + + /** + * @group connected + * @group relay-resp3 + * @return void + * @requiresRedisVersion >= 8.9.0 + */ + public function testReturnsQueriedRangeForMultipleKeysInReverseDirection(): void + { + $redis = $this->getClient(); + + $this->assertEquals('OK', $redis->tscreate('temp:TLV', (new CreateArguments())->labels('type', 'temp', 'location', 'TLV'))); + $this->assertEquals('OK', $redis->tscreate('temp:JLM', (new CreateArguments())->labels('type', 'temp', 'location', 'JLM'))); + + $this->assertSame([1000, 1010, 1020], $redis->tsmadd('temp:TLV', 1000, 30, 'temp:TLV', 1010, 35, 'temp:TLV', 1020, 40)); + $this->assertSame([1000, 1010, 1020], $redis->tsmadd('temp:JLM', 1000, 25, 'temp:JLM', 1010, 27, 'temp:JLM', 1020, 29)); + + $this->assertEquals( + [ + [1020, ['40', '29']], + [1010, ['35', '27']], + [1000, ['30', '25']], + ], + $redis->tsnrevrange(['temp:TLV', 'temp:JLM'], '-', '+') + ); + } + + /** + * @group connected + * @return void + * @requiresRedisVersion >= 8.9.0 + */ + public function testReturnsQueriedRangeForMultipleKeysInReverseDirectionResp3(): void + { + $redis = $this->getResp3Client(); + + $this->assertEquals('OK', $redis->tscreate('temp:TLV', (new CreateArguments())->labels('type', 'temp', 'location', 'TLV'))); + $this->assertEquals('OK', $redis->tscreate('temp:JLM', (new CreateArguments())->labels('type', 'temp', 'location', 'JLM'))); + + $this->assertSame([1000, 1010, 1020], $redis->tsmadd('temp:TLV', 1000, 30, 'temp:TLV', 1010, 35, 'temp:TLV', 1020, 40)); + $this->assertSame([1000, 1010, 1020], $redis->tsmadd('temp:JLM', 1000, 25, 'temp:JLM', 1010, 27, 'temp:JLM', 1020, 29)); + + $this->assertEquals( + [ + [1020, ['40', '29']], + [1010, ['35', '27']], + [1000, ['30', '25']], + ], + $redis->tsnrevrange(['temp:TLV', 'temp:JLM'], '-', '+') + ); + } + + /** + * @group connected + * @group relay-resp3 + * @return void + * @requiresRedisVersion >= 8.9.0 + */ + public function testPreservesInputKeyOrder(): void + { + $redis = $this->getClient(); + + $this->assertEquals('OK', $redis->tscreate('temp:TLV', (new CreateArguments())->labels('type', 'temp', 'location', 'TLV'))); + $this->assertEquals('OK', $redis->tscreate('temp:JLM', (new CreateArguments())->labels('type', 'temp', 'location', 'JLM'))); + + $this->assertSame([1000], $redis->tsmadd('temp:TLV', 1000, 30)); + $this->assertSame([1000], $redis->tsmadd('temp:JLM', 1000, 25)); + + // Reversed key order should reverse the values in each row. + $this->assertEquals( + [[1000, ['25', '30']]], + $redis->tsnrevrange(['temp:JLM', 'temp:TLV'], '-', '+') + ); + } + + /** + * @group connected + * @group relay-resp3 + * @return void + * @requiresRedisVersion >= 8.9.0 + */ + public function testReturnsRangeLimitedByCount(): void + { + $redis = $this->getClient(); + + $this->assertEquals('OK', $redis->tscreate('temp:TLV', (new CreateArguments())->labels('type', 'temp', 'location', 'TLV'))); + $this->assertEquals('OK', $redis->tscreate('temp:JLM', (new CreateArguments())->labels('type', 'temp', 'location', 'JLM'))); + + $this->assertSame([1000, 1010, 1020], $redis->tsmadd('temp:TLV', 1000, 30, 'temp:TLV', 1010, 35, 'temp:TLV', 1020, 40)); + $this->assertSame([1000, 1010, 1020], $redis->tsmadd('temp:JLM', 1000, 25, 'temp:JLM', 1010, 27, 'temp:JLM', 1020, 29)); + + $arguments = (new NRangeArguments())->count(2); + + // In reverse direction COUNT keeps the rows with the highest timestamps. + $this->assertEquals( + [ + [1020, ['40', '29']], + [1010, ['35', '27']], + ], + $redis->tsnrevrange(['temp:TLV', 'temp:JLM'], '-', '+', $arguments) + ); + } + + /** + * @group connected + * @group relay-resp3 + * @return void + * @requiresRedisVersion >= 8.9.0 + */ + public function testReturnsRangeWithAggregationPerKey(): void + { + $redis = $this->getClient(); + + $this->assertEquals('OK', $redis->tscreate('stock:A', (new CreateArguments())->labels('type', 'stock', 'name', 'A'))); + $this->assertEquals('OK', $redis->tscreate('stock:B', (new CreateArguments())->labels('type', 'stock', 'name', 'B'))); + + $this->assertSame([1000, 1010, 1020], $redis->tsmadd('stock:A', 1000, 100, 'stock:A', 1010, 110, 'stock:A', 1020, 120)); + $this->assertSame([1000, 1010, 1020], $redis->tsmadd('stock:B', 1000, 200, 'stock:B', 1010, 210, 'stock:B', 1020, 220)); + + // Exactly numkeys aggregators are required, one per key. + $arguments = (new NRangeArguments())->aggregation([NRangeArguments::AGG_MIN, NRangeArguments::AGG_MAX], 1000); + + $response = $redis->tsnrevrange(['stock:A', 'stock:B'], '-', '+', $arguments); + + // Response is timestamp-major and each row holds one aggregated value + // per queried key, preserving the input key order. + $this->assertNotEmpty($response); + + foreach ($response as $row) { + $this->assertIsInt($row[0]); + $this->assertCount(2, $row[1]); + } + } + + /** + * @group connected + * @group relay-resp3 + * @return void + * @requiresRedisVersion >= 8.9.0 + */ + public function testThrowsExceptionOnNonExistingKey(): void + { + $redis = $this->getClient(); + + $this->expectException(ServerException::class); + $this->expectExceptionMessage('ERR TSDB: the key does not exist'); + + $redis->tsnrevrange(['non_existing_key'], 1000, 1000); + } + + public function argumentsProvider(): array + { + return [ + 'with single key' => [ + [['key'], 10000, 10001], + [1, 'key', 10000, 10001], + ], + 'with multiple keys' => [ + [['key1', 'key2'], 10000, 10001], + [2, 'key1', 'key2', 10000, 10001], + ], + 'with duplicate keys preserved' => [ + [['key', 'key'], 10000, 10001], + [2, 'key', 'key', 10000, 10001], + ], + 'with LATEST modifier' => [ + [['key'], 10000, 10001, (new NRangeArguments())->latest()], + [1, 'key', 10000, 10001, 'LATEST'], + ], + 'with FILTER_BY_TS modifier' => [ + [['key'], 10000, 10001, (new NRangeArguments())->filterByTs(1000, 1001)], + [1, 'key', 10000, 10001, 'FILTER_BY_TS', 1000, 1001], + ], + 'with FILTER_BY_VALUE modifier' => [ + [['key'], 10000, 10001, (new NRangeArguments())->filterByValue(1000, 1001)], + [1, 'key', 10000, 10001, 'FILTER_BY_VALUE', 1000, 1001], + ], + 'with COUNT modifier' => [ + [['key'], 10000, 10001, (new NRangeArguments())->count(100)], + [1, 'key', 10000, 10001, 'COUNT', 100], + ], + 'with AGGREGATION modifier - default arguments' => [ + [['key1', 'key2'], 10000, 10001, (new NRangeArguments())->aggregation(['min', 'max'], 100)], + [2, 'key1', 'key2', 10000, 10001, 'AGGREGATION', 'min', 'max', 100], + ], + 'with AGGREGATION modifier - with ALIGN' => [ + [['key'], 10000, 10001, (new NRangeArguments())->aggregation('sum', 100, 100)], + [1, 'key', 10000, 10001, 'ALIGN', 100, 'AGGREGATION', 'sum', 100], + ], + 'with AGGREGATION modifier - with BUCKETTIMESTAMP' => [ + [['key'], 10000, 10001, (new NRangeArguments())->aggregation('sum', 100, 0, 1000)], + [1, 'key', 10000, 10001, 'AGGREGATION', 'sum', 100, 'BUCKETTIMESTAMP', 1000], + ], + 'with AGGREGATION modifier - with EMPTY' => [ + [['key'], 10000, 10001, (new NRangeArguments())->aggregation('sum', 100, 0, 0, true)], + [1, 'key', 10000, 10001, 'AGGREGATION', 'sum', 100, 'EMPTY'], + ], + 'with all modifiers' => [ + [['key1', 'key2'], 10000, 10001, (new NRangeArguments())->latest()->filterByTs(1000, 1001)->filterByValue(1000, 1001)->count(100)->aggregation(['min', 'max'], 100)], + [2, 'key1', 'key2', 10000, 10001, 'LATEST', 'FILTER_BY_TS', 1000, 1001, 'FILTER_BY_VALUE', 1000, 1001, 'COUNT', 100, 'AGGREGATION', 'min', 'max', 100], + ], + ]; + } +}