Added support for EXCLUDEEMPTY argument for TS.MRANGE and TS.MREVRANGE commands (#1704)

This commit is contained in:
Vladyslav Vildanov
2026-07-21 10:59:59 +03:00
committed by GitHub
parent 66ce62657d
commit bc406c7f55
5 changed files with 323 additions and 1 deletions
+1
View File
@@ -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
@@ -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;
}
}
@@ -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');
}
}
@@ -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'],
@@ -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'],