From bc406c7f552bf67bf3ed4b0b7b5c1befcf67edd8 Mon Sep 17 00:00:00 2001 From: Vladyslav Vildanov <117659936+vladvildanov@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:59:59 +0300 Subject: [PATCH] Added support for EXCLUDEEMPTY argument for TS.MRANGE and TS.MREVRANGE commands (#1704) --- CHANGELOG.md | 1 + .../Argument/TimeSeries/MRangeArguments.php | 28 +++- .../TimeSeries/MRangeArgumentsTest.php | 63 ++++++++ .../Redis/TimeSeries/TSMRANGE_Test.php | 134 ++++++++++++++++++ .../Redis/TimeSeries/TSMREVRANGE_Test.php | 98 +++++++++++++ 5 files changed, 323 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b011b27..bc82953e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Added support for new COLLECT reducer for FT.AGGREGATE (#1699) - Added support for `SDIFFCARD` and `SUNIONCARD` command - Added support for `TS.READ` command +- Added support for `EXCLUDEEMPTY` argument for `TS.MRANGE` and `TS.MREVRANGE` commands - Added explicit testing for FT.SEARCH timeout policies ### Fixed diff --git a/src/Command/Argument/TimeSeries/MRangeArguments.php b/src/Command/Argument/TimeSeries/MRangeArguments.php index 6e28d098..b4d65cf1 100644 --- a/src/Command/Argument/TimeSeries/MRangeArguments.php +++ b/src/Command/Argument/TimeSeries/MRangeArguments.php @@ -51,7 +51,8 @@ class MRangeArguments extends RangeArguments * Splits time series into groups, each group contains time series that share the same * value for the provided label name, then aggregates results in each group. * - * GROUPBY cannot be combined with multiple aggregators set via aggregation(). + * GROUPBY cannot be combined with multiple aggregators set via aggregation() + * or with excludeEmpty(). * * @param string $label * @param string $reducer @@ -69,8 +70,33 @@ class MRangeArguments extends RangeArguments throw new UnexpectedValueException('GROUPBY cannot be combined with multiple aggregators.'); } + if (in_array('EXCLUDEEMPTY', $this->arguments, true)) { + throw new UnexpectedValueException('GROUPBY cannot be combined with EXCLUDEEMPTY.'); + } + array_push($this->arguments, 'GROUPBY', $label, 'REDUCE', $reducer); return $this; } + + /** + * Omits matching time series whose reported samples array is empty + * from the command reply. + * + * EXCLUDEEMPTY cannot be combined with groupBy(). + * + * @return $this + */ + public function excludeEmpty(): self + { + if (in_array('GROUPBY', $this->arguments, true)) { + throw new UnexpectedValueException('EXCLUDEEMPTY cannot be combined with GROUPBY.'); + } + + if (!in_array('EXCLUDEEMPTY', $this->arguments, true)) { + $this->arguments[] = 'EXCLUDEEMPTY'; + } + + return $this; + } } diff --git a/tests/Predis/Command/Argument/TimeSeries/MRangeArgumentsTest.php b/tests/Predis/Command/Argument/TimeSeries/MRangeArgumentsTest.php index 75bab0d0..ffb9d9f3 100644 --- a/tests/Predis/Command/Argument/TimeSeries/MRangeArgumentsTest.php +++ b/tests/Predis/Command/Argument/TimeSeries/MRangeArgumentsTest.php @@ -100,4 +100,67 @@ class MRangeArgumentsTest extends TestCase $this->arguments->toArray() ); } + + /** + * @return void + */ + public function testCreatesArgumentsWithExcludeEmptyModifier(): void + { + $this->arguments->excludeEmpty(); + + $this->assertSame(['EXCLUDEEMPTY'], $this->arguments->toArray()); + } + + /** + * @return void + */ + public function testExcludeEmptyIsEmittedAtMostOnce(): void + { + $this->arguments->excludeEmpty()->excludeEmpty(); + + $this->assertSame(['EXCLUDEEMPTY'], $this->arguments->toArray()); + } + + /** + * @return void + */ + public function testExcludeEmptyComposesWithOtherRangeOptions(): void + { + $this->arguments + ->withLabels() + ->aggregation(RangeArguments::AGG_MIN, 100) + ->excludeEmpty() + ->filter('sensor=1'); + + $this->assertSame( + ['WITHLABELS', 'AGGREGATION', RangeArguments::AGG_MIN, 100, 'EXCLUDEEMPTY', 'FILTER', 'sensor=1'], + $this->arguments->toArray() + ); + } + + /** + * @return void + */ + public function testThrowsOnExcludeEmptyWhenGroupByAlreadySet(): void + { + $this->arguments->groupBy('label', 'reducer'); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('EXCLUDEEMPTY cannot be combined with GROUPBY.'); + + $this->arguments->excludeEmpty(); + } + + /** + * @return void + */ + public function testThrowsOnGroupByWhenExcludeEmptyAlreadySet(): void + { + $this->arguments->excludeEmpty(); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('GROUPBY cannot be combined with EXCLUDEEMPTY.'); + + $this->arguments->groupBy('label', 'reducer'); + } } diff --git a/tests/Predis/Command/Redis/TimeSeries/TSMRANGE_Test.php b/tests/Predis/Command/Redis/TimeSeries/TSMRANGE_Test.php index 704d416d..a8a57922 100644 --- a/tests/Predis/Command/Redis/TimeSeries/TSMRANGE_Test.php +++ b/tests/Predis/Command/Redis/TimeSeries/TSMRANGE_Test.php @@ -12,6 +12,7 @@ namespace Predis\Command\Redis\TimeSeries; +use Predis\ClientInterface; use Predis\Command\Argument\TimeSeries\CommonArguments; use Predis\Command\Argument\TimeSeries\CreateArguments; use Predis\Command\Argument\TimeSeries\MRangeArguments; @@ -300,6 +301,135 @@ class TSMRANGE_Test extends PredisCommandTestCase $this->assertSame(1000, $samples[0][0]); } + /** + * @group connected + * @group relay-resp3 + * @return void + * @requiresRedisVersion >= 8.10.0 + */ + public function testQueryRangeExcludesEmptySeriesWithExcludeEmptyModifier(): void + { + $redis = $this->getClient(); + + $this->createExcludeEmptyFixture($redis); + + // The matching series "u" is omitted because it has no samples + // within the requested range. + $expectedResponse = [ + [ + 's', + [['sensor', '1'], ['type', 'demo']], + [[100, '100'], [200, '200'], [400, '400']], + ], + [ + 't', + [['sensor', '1'], ['type', 'demo']], + [[100, '100'], [300, '300'], [400, '400']], + ], + ]; + + $mrangeArguments = (new MRangeArguments()) + ->withLabels() + ->excludeEmpty() + ->filter('sensor=1'); + + $this->assertEquals($expectedResponse, $redis->tsmrange('-', 500, $mrangeArguments)); + + // Without EXCLUDEEMPTY the empty series "u" is still reported. + $mrangeArguments = (new MRangeArguments())->filter('sensor=1'); + $this->assertCount(3, $redis->tsmrange('-', 500, $mrangeArguments)); + + // When every matching series is empty the reply is an empty array. + $mrangeArguments = (new MRangeArguments())->excludeEmpty()->filter('sensor=1'); + $this->assertSame([], $redis->tsmrange(1, 50, $mrangeArguments)); + } + + /** + * @group connected + * @group relay-resp3 + * @return void + * @requiresRedisVersion >= 8.10.0 + */ + public function testQueryRangeExcludesEmptySeriesWithExcludeEmptyAndAggregation(): void + { + $redis = $this->getClient(); + + $this->createExcludeEmptyFixture($redis); + + $expectedResponse = [ + [ + 's', + [], + [[100, '100'], [200, '200'], [400, '400']], + ], + [ + 't', + [], + [[100, '100'], [300, '300'], [400, '400']], + ], + ]; + + $mrangeArguments = (new MRangeArguments()) + ->aggregation('min', 100) + ->excludeEmpty() + ->filter('sensor=1'); + + $this->assertEquals($expectedResponse, $redis->tsmrange('-', 500, $mrangeArguments)); + } + + /** + * @group connected + * @return void + * @requiresRedisVersion >= 8.10.0 + */ + public function testQueryRangeExcludesEmptySeriesWithExcludeEmptyModifierResp3(): void + { + $redis = $this->getResp3Client(); + + $this->createExcludeEmptyFixture($redis); + + $expectedResponse = [ + 's' => [ + ['sensor' => '1', 'type' => 'demo'], + ['aggregators' => []], + [[100, 100], [200, 200], [400, 400]], + ], + 't' => [ + ['sensor' => '1', 'type' => 'demo'], + ['aggregators' => []], + [[100, 100], [300, 300], [400, 400]], + ], + ]; + + $mrangeArguments = (new MRangeArguments()) + ->withLabels() + ->excludeEmpty() + ->filter('sensor=1'); + + $this->assertEquals($expectedResponse, $redis->tsmrange('-', 500, $mrangeArguments)); + } + + private function createExcludeEmptyFixture(ClientInterface $redis): void + { + $this->assertEquals( + 'OK', + $redis->tscreate('s', (new CreateArguments())->labels('sensor', 1, 'type', 'demo')) + ); + $this->assertEquals( + 'OK', + $redis->tscreate('t', (new CreateArguments())->labels('sensor', 1, 'type', 'demo')) + ); + $this->assertEquals( + 'OK', + $redis->tscreate('u', (new CreateArguments())->labels('sensor', 1, 'type', 'demo')) + ); + + $this->assertSame( + [100, 100, 200, 300, 400, 400, 2000], + $redis->tsmadd('s', 100, 100, 't', 100, 100, 's', 200, 200, 't', 300, 300, 's', 400, 400, 't', 400, 400, 'u', 2000, 2000) + ); + } + public function argumentsProvider(): array { return [ @@ -323,6 +453,10 @@ class TSMRANGE_Test extends PredisCommandTestCase [1000, 1001, (new MRangeArguments())->withLabels()->filter('filterExpression1', 'filterExpression2')], [1000, 1001, 'WITHLABELS', 'FILTER', 'filterExpression1', 'filterExpression2'], ], + 'with EXCLUDEEMPTY modifier' => [ + [1000, 1001, (new MRangeArguments())->excludeEmpty()->filter('filterExpression1', 'filterExpression2')], + [1000, 1001, 'EXCLUDEEMPTY', 'FILTER', 'filterExpression1', 'filterExpression2'], + ], 'with SELECTED_LABELS modifier' => [ [1000, 1001, (new MRangeArguments())->selectedLabels('label1', 'label2')->filter('filterExpression1', 'filterExpression2')], [1000, 1001, 'SELECTED_LABELS', 'label1', 'label2', 'FILTER', 'filterExpression1', 'filterExpression2'], diff --git a/tests/Predis/Command/Redis/TimeSeries/TSMREVRANGE_Test.php b/tests/Predis/Command/Redis/TimeSeries/TSMREVRANGE_Test.php index a2166a40..e16c6d07 100644 --- a/tests/Predis/Command/Redis/TimeSeries/TSMREVRANGE_Test.php +++ b/tests/Predis/Command/Redis/TimeSeries/TSMREVRANGE_Test.php @@ -12,6 +12,7 @@ namespace Predis\Command\Redis\TimeSeries; +use Predis\ClientInterface; use Predis\Command\Argument\TimeSeries\CommonArguments; use Predis\Command\Argument\TimeSeries\CreateArguments; use Predis\Command\Argument\TimeSeries\MRangeArguments; @@ -300,6 +301,99 @@ class TSMREVRANGE_Test extends PredisCommandTestCase $this->assertSame(1000, $samples[0][0]); } + /** + * @group connected + * @group relay-resp3 + * @return void + * @requiresRedisVersion >= 8.10.0 + */ + public function testQueryRangeExcludesEmptySeriesWithExcludeEmptyModifier(): void + { + $redis = $this->getClient(); + + $this->createExcludeEmptyFixture($redis); + + // The matching series "u" is omitted because it has no samples within + // the requested range; samples are reported in reverse timestamp order. + $expectedResponse = [ + [ + 's', + [], + [[400, '400'], [200, '200'], [100, '100']], + ], + [ + 't', + [], + [[400, '400'], [300, '300'], [100, '100']], + ], + ]; + + $mrangeArguments = (new MRangeArguments())->excludeEmpty()->filter('sensor=1'); + + $this->assertEquals($expectedResponse, $redis->tsmrevrange('-', 500, $mrangeArguments)); + + // Without EXCLUDEEMPTY the empty series "u" is still reported. + $mrangeArguments = (new MRangeArguments())->filter('sensor=1'); + $this->assertCount(3, $redis->tsmrevrange('-', 500, $mrangeArguments)); + + // When every matching series is empty the reply is an empty array. + $mrangeArguments = (new MRangeArguments())->excludeEmpty()->filter('sensor=1'); + $this->assertSame([], $redis->tsmrevrange(1, 50, $mrangeArguments)); + } + + /** + * @group connected + * @return void + * @requiresRedisVersion >= 8.10.0 + */ + public function testQueryRangeExcludesEmptySeriesWithExcludeEmptyModifierResp3(): void + { + $redis = $this->getResp3Client(); + + $this->createExcludeEmptyFixture($redis); + + $expectedResponse = [ + 's' => [ + ['sensor' => '1', 'type' => 'demo'], + ['aggregators' => []], + [[400, 400], [200, 200], [100, 100]], + ], + 't' => [ + ['sensor' => '1', 'type' => 'demo'], + ['aggregators' => []], + [[400, 400], [300, 300], [100, 100]], + ], + ]; + + $mrangeArguments = (new MRangeArguments()) + ->withLabels() + ->excludeEmpty() + ->filter('sensor=1'); + + $this->assertEquals($expectedResponse, $redis->tsmrevrange('-', 500, $mrangeArguments)); + } + + private function createExcludeEmptyFixture(ClientInterface $redis): void + { + $this->assertEquals( + 'OK', + $redis->tscreate('s', (new CreateArguments())->labels('sensor', 1, 'type', 'demo')) + ); + $this->assertEquals( + 'OK', + $redis->tscreate('t', (new CreateArguments())->labels('sensor', 1, 'type', 'demo')) + ); + $this->assertEquals( + 'OK', + $redis->tscreate('u', (new CreateArguments())->labels('sensor', 1, 'type', 'demo')) + ); + + $this->assertSame( + [100, 100, 200, 300, 400, 400, 2000], + $redis->tsmadd('s', 100, 100, 't', 100, 100, 's', 200, 200, 't', 300, 300, 's', 400, 400, 't', 400, 400, 'u', 2000, 2000) + ); + } + public function argumentsProvider(): array { return [ @@ -323,6 +417,10 @@ class TSMREVRANGE_Test extends PredisCommandTestCase [1000, 1001, (new MRangeArguments())->withLabels()->filter('filterExpression1', 'filterExpression2')], [1000, 1001, 'WITHLABELS', 'FILTER', 'filterExpression1', 'filterExpression2'], ], + 'with EXCLUDEEMPTY modifier' => [ + [1000, 1001, (new MRangeArguments())->excludeEmpty()->filter('filterExpression1', 'filterExpression2')], + [1000, 1001, 'EXCLUDEEMPTY', 'FILTER', 'filterExpression1', 'filterExpression2'], + ], 'with SELECTED_LABELS modifier' => [ [1000, 1001, (new MRangeArguments())->selectedLabels('label1', 'label2')->filter('filterExpression1', 'filterExpression2')], [1000, 1001, 'SELECTED_LABELS', 'label1', 'label2', 'FILTER', 'filterExpression1', 'filterExpression2'],