├── .github └── workflows │ └── main.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── composer.json ├── composer.lock ├── docker-compose.yml ├── docker └── xdebug.ini ├── phpunit.xml ├── psalm.xml ├── src ├── AggregationRule.php ├── Client │ ├── RedisClient.php │ ├── RedisClientInterface.php │ └── RedisConnectionParams.php ├── DateTimeUtils.php ├── Exception │ ├── InvalidAggregationException.php │ ├── InvalidDuplicatePolicyException.php │ ├── InvalidFilterOperationException.php │ ├── RedisAuthenticationException.php │ ├── RedisClientException.php │ └── TimestampParsingException.php ├── Filter.php ├── Label.php ├── Metadata.php ├── Sample.php ├── SampleWithLabels.php └── TimeSeries.php └── tests ├── Integration └── IntegrationTest.php └── Unit ├── AggregationRuleTest.php ├── DateTimeUtilsTest.php ├── FilterTest.php ├── MetadataTest.php ├── RedisClientTest.php ├── SampleTest.php ├── SampleWithLabelsTest.php └── TimeSeriesTest.php /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | # Controls when the action will run. 4 | on: 5 | # Triggers the workflow on push or pull request events but only for the master branch 6 | push: 7 | branches: [ master ] 8 | pull_request: 9 | branches: [ master ] 10 | 11 | # Allows you to run this workflow manually from the Actions tab 12 | workflow_dispatch: 13 | 14 | jobs: 15 | build: 16 | name: PHP ${{ matrix.php-version }} - RedisTimeSeries ${{ matrix.rts-version }} 17 | runs-on: ubuntu-latest 18 | strategy: 19 | matrix: 20 | php-version: ['7.2', '7.3', '7.4', '8.0'] 21 | rts-version: ['1.4.10'] 22 | services: 23 | redis: 24 | image: redislabs/redistimeseries:${{ matrix.rts-version }} 25 | ports: 26 | - 6379:6379 27 | options: >- 28 | --health-cmd="redis-cli ping" 29 | --health-interval=10s 30 | --health-timeout=5s 31 | --health-retries=5 32 | --health-start-period=20s 33 | steps: 34 | - name: Checkout 35 | uses: actions/checkout@v2 36 | - name: Setup PHP 37 | uses: shivammathur/setup-php@v2 38 | with: 39 | php-version: ${{ matrix.php-version }} 40 | extensions: redis 41 | coverage: xdebug 42 | - name: Get composer cache directory 43 | id: composer-cache 44 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 45 | - name: Cache composer dependencies 46 | uses: actions/cache@v2 47 | with: 48 | path: ${{ steps.composer-cache.outputs.dir }} 49 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 50 | restore-keys: ${{ runner.os }}-composer- 51 | - name: Install dependencies 52 | run: | 53 | composer install --no-progress --prefer-dist --optimize-autoloader 54 | - name: Run tests 55 | run: vendor/bin/phpunit --coverage-text 56 | env: 57 | REDIS_HOST: localhost 58 | REDIS_TIMESERIES_VERSION: ${{ matrix.rts-version }} 59 | - name: Run psalm 60 | run: vendor/bin/psalm 61 | - name: Export coverage 62 | uses: paambaati/codeclimate-action@v2.7.5 63 | if: ${{ matrix.php-version == '7.4' && matrix.rts-version == '1.4.10' }} 64 | env: 65 | CC_TEST_REPORTER_ID: ${{secrets.CC_TEST_REPORTER_ID}} 66 | with: 67 | coverageLocations: clover.xml:clover 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /vendor/ 3 | /clover.xml 4 | /.phpunit.result.cache 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:7.4-cli 2 | ARG DEBIAN_FRONTEND=noninteractive 3 | WORKDIR /app 4 | 5 | ENV XDEBUG_MODE=coverage 6 | 7 | RUN apt-get -y upgrade && \ 8 | apt-get - dist-upgrade && \ 9 | apt-get update && \ 10 | apt-get install -yqq zip git wget 11 | 12 | RUN pecl install redis && \ 13 | pecl install xdebug && \ 14 | docker-php-ext-enable redis xdebug 15 | 16 | RUN wget https://github.com/composer/composer/releases/download/2.0.12/composer.phar -q && \ 17 | mv composer.phar /usr/bin/composer && \ 18 | chmod +x /usr/bin/composer 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Alessandro Balasco 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # phpRedisTimeSeries 2 | 3 | Use [Redis Time Series](https://oss.redislabs.com/redistimeseries/) in PHP! 4 | 5 | [![Maintainability](https://api.codeclimate.com/v1/badges/fea927b90378dd63a9d8/maintainability)](https://codeclimate.com/github/palicao/phpRedisTimeSeries/maintainability) 6 | [![Test Coverage](https://api.codeclimate.com/v1/badges/fea927b90378dd63a9d8/test_coverage)](https://codeclimate.com/github/palicao/phpRedisTimeSeries/test_coverage) 7 | [![Build Status](https://github.com/palicao/phpRedisTimeSeries/actions/workflows/main.yml/badge.svg)](https://travis-ci.com/palicao/phpRedisTimeSeries) 8 | [![Latest Stable Version](https://img.shields.io/packagist/v/palicao/php-redis-time-series.svg)](https://packagist.org/packages/palicao/php-redis-time-series) 9 | ![PHP version](https://img.shields.io/packagist/php-v/palicao/php-redis-time-series/2.0.0) 10 | ![GitHub](https://img.shields.io/github/license/palicao/phpRedisTimeSeries) 11 | 12 | ## Getting up and running 13 | 14 | ### Install 15 | `composer require palicao/php-redis-time-series` 16 | 17 | ### Requirements 18 | The library is tested against: 19 | - PHP 7.2, 7.3, 7.4, 8.0 20 | - RedisTimeSeries 1.4.10 (but it should work with any 1.4 version) 21 | 22 | In order to use RedisTimeSeries 1.2 please use version 2.1.1 of this library. 23 | 24 | ### Construct 25 | ``` 26 | $ts = new TimeSeries( 27 | new RedisClient( 28 | new Redis(), 29 | new RedisConnectionParams($host, $port) 30 | ) 31 | ); 32 | ``` 33 | 34 | ## `TimeSeries` methods 35 | 36 | ### `create` 37 | 38 | `TimeSeries::create(string $key, ?int $retentionMs = null, array $labels = []): void` 39 | 40 | Creates a key, optionally setting a retention time (in milliseconds) and some labels. 41 | 42 | See https://oss.redislabs.com/redistimeseries/commands/#tscreate. 43 | 44 | ### `alter` 45 | 46 | `TimeSeries::alter(string $key, ?int $retentionMs = null, array $labels = []): void` 47 | 48 | Modifies an existing key's retention time and/or labels. 49 | 50 | See https://oss.redislabs.com/redistimeseries/commands/#tsalter. 51 | 52 | ### `add` 53 | 54 | `TimeSeries::add(Sample $sample, ?int $retentionMs = null, array $labels = []): Sample` 55 | 56 | Adds a sample. 57 | 58 | If the key was not explicitly `create`d, it's possible to set retention and labels. 59 | 60 | See https://oss.redislabs.com/redistimeseries/commands/#tsadd. 61 | 62 | Examples: 63 | * `$sample = $ts->add(new Sample('myKey', 32.10), 10, [new Label('myLabel', 'myValue')]);` Adds a sample to `myKey` at 64 | the current timestamp (time of the redis server) and returns it (complete with dateTime). 65 | * `$sample = $ts->add(new Sample('myKey', 32.10, new DateTimeImmutable()), 10, [new Label('myLabel', 'myValue')]);` 66 | Adds a sample to `myKey` at a given datetime and returns it. 67 | 68 | Please notice that RedisTimeSeries only allows to add samples in order (no sample older than the latest is allowed) 69 | 70 | ### `addMany` 71 | 72 | `TimeSeries::addMany(array $samples): array` 73 | 74 | Adds several samples. 75 | 76 | As usual, if no timestamp is provided, the redis server current time is used. Added samples are returned in an array. 77 | 78 | See https://oss.redislabs.com/redistimeseries/commands/#tsmadd. 79 | 80 | ### `incrementBy` and `decrementBy` 81 | 82 | `TimeSeries::incrementBy(Sample $sample, ?int $resetMs = null, ?int $retentionMs = null, array $labels = []): void` 83 | 84 | `TimeSeries::decrementBy(Sample $sample, ?int $resetMs = null, ?int $retentionMs = null, array $labels = []): void` 85 | 86 | Add a sample to a key, incrementing or decrementing the last value by an amount specified in `$sample`. 87 | The value can be optionally reset after `$resetMs` milliseconds. 88 | 89 | Similarly to `add`, if the command is used on a non-existing key, it's possible to set retention and labels. 90 | 91 | See https://oss.redislabs.com/redistimeseries/commands/#tsincrbytsdecrby. 92 | 93 | ### `createRule` 94 | 95 | `TimeSeries::createRule(string $sourceKey, string $destKey, AggregationRule $rule): void` 96 | 97 | Creates an aggregation rule which applies the given `$rule` to `$sourceKey` in order to populate `$destKey`. 98 | 99 | Notice that both key must already exist otherwise the command will fail. 100 | 101 | See https://oss.redislabs.com/redistimeseries/commands/#tscreaterule. 102 | 103 | Example: 104 | 105 | * `$ts->createRule('source', 'destination', new AggregationRule(AggregationRule::AVG, 10000)`, will populate the 106 | `destination` key by averaging the samples contained in `source` over 10 second buckets. 107 | 108 | ### `deleteRule` 109 | 110 | `TimeSeries::deleteRule(string $sourceKey, string $destKey): void` 111 | 112 | Deletes an existing aggregation rule. 113 | 114 | See https://oss.redislabs.com/redistimeseries/commands/#tsdeleterule. 115 | 116 | ### `range` 117 | 118 | `TimeSeries::range(string $key, ?DateTimeInterface $from = null, ?DateTimeInterface $to = null, ?int $count = null, ?AggregationRule $rule = null, bool $reverse = false): Sample[]` 119 | 120 | Retrieves samples from a key. It's possible to limit the query in a given time frame (passing `$from` and `$to`), 121 | to limit the retrieved amount of samples (passing `$count`), and also to pre-aggregate the results using a `$rule`. 122 | 123 | The flag `$reverse` will return the results in reverse time order. 124 | 125 | See https://oss.redislabs.com/redistimeseries/commands/#tsrange. 126 | 127 | ### `multiRange` and `multiRangeWithLabels` 128 | 129 | `TimeSeries::multiRange(Filter $filter, ?DateTimeInterface $from = null, ?DateTimeInterface $to = null, ?int $count = null, ?AggregationRule $rule = null, bool $reverse = false): Sample[]` 130 | 131 | `TimeSeries::multiRangeWithLabels(Filter $filter, ?DateTimeInterface $from = null, ?DateTimeInterface $to = null, ?int $count = null, ?AggregationRule $rule = null, bool $reverse = false): SampleWithLabels[]` 132 | 133 | Similar to `range`, but instead of querying by key, queries for a specific set of labels specified in `$filter`. 134 | 135 | `multiRangeWithLabels` will return an array of `SampleWithLabels` instead of simple `Sample`s. 136 | 137 | The flag `$reverse` will return the results in reverse time order. 138 | 139 | See https://oss.redislabs.com/redistimeseries/commands/#tsmrange. 140 | 141 | ### `getLastSample` 142 | 143 | `TimeSeries::getLastSample(string $key): Sample` 144 | 145 | Gets the last sample for a key. 146 | 147 | See https://oss.redislabs.com/redistimeseries/commands/#tsget. 148 | 149 | ### `getLastSamples` 150 | 151 | `TimeSeries::getLastSamples(Filter $filter): Sample[]` 152 | 153 | Gets the last sample for a set of keys matching a given `$filter`. 154 | 155 | See https://oss.redislabs.com/redistimeseries/commands/#tsmget. 156 | 157 | ### `info` 158 | 159 | `TimeSeries::info(string $key): Metadata` 160 | 161 | Gets useful metadata for a given key. 162 | 163 | See https://oss.redislabs.com/redistimeseries/commands/#tsinfo. 164 | 165 | ### `getKeysByFilter` 166 | 167 | `getKeysByFilter(Filter $filter): string[]` 168 | 169 | Retrieves the list of keys matching a given `$filter`. 170 | 171 | See https://oss.redislabs.com/redistimeseries/commands/#tsqueryindex. 172 | 173 | ## Advanced connection parameters 174 | 175 | You can manipulate your `RedisConnectionParams` using the following methods: 176 | 177 | * `RedisConnectionParams::setPersistentConnection(bool $persistentConnection)` enable/disable a persistent connection 178 | * `RedisConnectionParams::setTimeout(int $timeout)` connection timeout in seconds 179 | * `RedisConnectionParams::setRetryInterval(int $retryInterval)` retry interval in seconds 180 | * `RedisConnectionParams::setReadTimeout(float $readTimeout)` read timeout in seconds 181 | 182 | ## Building Filters 183 | 184 | A `Filter` is composed of multiple filtering operations. At least one equality operation must be provided (in the 185 | constructor). Adding filtering operations can be chained using the method `add`. 186 | 187 | Examples: 188 | 189 | * `$f = (new Filter('label1', 'value1'))->add('label2', Filter::OP_EXISTS);` filters items which have label1 = value1 190 | and have label2. 191 | * `$f = (new Filter('label1', 'value1'))->add('label2', Filter::OP_IN, ['a', 'b', 'c']);` filters items which have 192 | label1 = value1 and where label2 is one of 'a', 'b' or 'c'. 193 | 194 | ## Local testing 195 | For local testing you can use the provided `docker-compose.yml` file, which will create a PHP container (with the redis 196 | extension pre-installed), and a redis container (with Redis Time Series included). 197 | 198 | ## Contributors 199 | Thanks [Mrkisha](https://github.com/Mrkisha) for the precious contributions. 200 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "palicao/php-redis-time-series", 3 | "description": "Use Redis Time Series in PHP", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Alessandro Balasco", 8 | "email": "alessandro.balasco@gmail.com" 9 | } 10 | ], 11 | "autoload": { 12 | "psr-4": { 13 | "Palicao\\PhpRedisTimeSeries\\": "src" 14 | } 15 | }, 16 | "autoload-dev": { 17 | "psr-4": { 18 | "Palicao\\PhpRedisTimeSeries\\Tests\\": "tests" 19 | } 20 | }, 21 | "require": { 22 | "php": ">=7.2.0", 23 | "ext-redis": "*" 24 | }, 25 | "require-dev": { 26 | "phpunit/phpunit": "^8.3", 27 | "vimeo/psalm": "^4.7" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | php-rts-php: 5 | build: 6 | dockerfile: Dockerfile 7 | context: . 8 | entrypoint: sleep infinity 9 | volumes: 10 | - .:/app 11 | - ./docker/xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini 12 | php-rts-redis: 13 | image: redislabs/redistimeseries:1.4.10 14 | -------------------------------------------------------------------------------- /docker/xdebug.ini: -------------------------------------------------------------------------------- 1 | zend_extension=xdebug 2 | 3 | [xdebug] 4 | xdebug.mode=develop,debug 5 | xdebug.client_host=host.docker.internal 6 | xdebug.start_with_request=yes 7 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | ./tests/ 9 | 10 | 11 | 12 | 13 | ./src/ 14 | 15 | ./src/Exception 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/AggregationRule.php: -------------------------------------------------------------------------------- 1 | type = $type; 57 | $this->timeBucketMs = $timeBucketMs; 58 | } 59 | 60 | /** 61 | * @codeCoverageIgnore 62 | */ 63 | public function getType(): string 64 | { 65 | return $this->type; 66 | } 67 | 68 | /** 69 | * @codeCoverageIgnore 70 | */ 71 | public function getTimeBucketMs(): int 72 | { 73 | return $this->timeBucketMs; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Client/RedisClient.php: -------------------------------------------------------------------------------- 1 | redis = $redis; 22 | $this->connectionParams = $connectionParams; 23 | } 24 | 25 | /** 26 | * @throws RedisClientException 27 | */ 28 | private function connectIfNeeded(): void 29 | { 30 | if ($this->redis->isConnected()) { 31 | return; 32 | } 33 | 34 | $params = $this->connectionParams; 35 | 36 | if ($params->isPersistentConnection()) { 37 | /** @psalm-suppress TooManyArguments */ 38 | $result = $this->redis->pconnect( 39 | $params->getHost(), 40 | $params->getPort(), 41 | $params->getTimeout(), 42 | gethostname(), 43 | $params->getRetryInterval(), 44 | $params->getReadTimeout() 45 | ); 46 | } else { 47 | /** @psalm-suppress InvalidArgument */ 48 | $result = $this->redis->connect( 49 | $params->getHost(), 50 | $params->getPort(), 51 | $params->getTimeout(), 52 | (PHP_VERSION_ID >= 70300) ? null : '', 53 | $params->getRetryInterval(), 54 | $params->getReadTimeout() 55 | ); 56 | } 57 | 58 | if ($result === false) { 59 | throw new RedisClientException(sprintf( 60 | 'Unable to connect to redis server %s:%s: %s', 61 | $params->getHost(), 62 | $params->getPort(), 63 | $this->redis->getLastError() ?? 'unknown error' 64 | )); 65 | } 66 | 67 | $this->authenticate($params->getUsername(), $params->getPassword()); 68 | } 69 | 70 | /** 71 | * @param string[] $params 72 | * @return mixed 73 | * @throws RedisException 74 | * @throws RedisClientException 75 | */ 76 | public function executeCommand(array $params) 77 | { 78 | $this->connectIfNeeded(); 79 | // UNDOCUMENTED FEATURE: option 8 is REDIS_OPT_REPLY_LITERAL 80 | $value = (PHP_VERSION_ID < 70300) ? '1' : 1; 81 | $this->redis->setOption(8, $value); 82 | return $this->redis->rawCommand(...$params); 83 | } 84 | 85 | /** 86 | * @param string|null $username 87 | * @param string|null $password 88 | * @throws RedisAuthenticationException 89 | */ 90 | private function authenticate(?string $username, ?string $password): void 91 | { 92 | try { 93 | if ($password) { 94 | if ($username) { 95 | // Calling auth() with an array throws a TypeError in some cases 96 | /** 97 | * @noinspection PhpMethodParametersCountMismatchInspection 98 | * @var bool $result 99 | */ 100 | $result = $this->redis->rawCommand('AUTH', $username, $password); 101 | } else { 102 | /** @psalm-suppress PossiblyNullArgument */ 103 | $result = $this->redis->auth($password); 104 | } 105 | if ($result === false) { 106 | throw new RedisAuthenticationException(sprintf( 107 | 'Failure authenticating user %s', $username ?: 'default' 108 | )); 109 | } 110 | } 111 | } /** @noinspection PhpRedundantCatchClauseInspection */ catch (RedisException $e) { 112 | throw new RedisAuthenticationException(sprintf( 113 | 'Failure authenticating user %s: %s', $username ?: 'default', $e->getMessage() 114 | )); 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/Client/RedisClientInterface.php: -------------------------------------------------------------------------------- 1 | persistentConnection = false; 46 | $this->host = $host; 47 | $this->port = $port; 48 | $this->username = $username; 49 | $this->password = $password; 50 | $this->timeout = 0; 51 | $this->retryInterval = 0; 52 | $this->readTimeout = 0.0; 53 | } 54 | 55 | /** 56 | * Whether to use a persistent connection 57 | * @param bool $persistentConnection 58 | * @return RedisConnectionParams 59 | */ 60 | public function setPersistentConnection(bool $persistentConnection): RedisConnectionParams 61 | { 62 | $this->persistentConnection = $persistentConnection; 63 | return $this; 64 | } 65 | 66 | /** 67 | * Connection timeout (in seconds) 68 | * @param int $timeout 69 | * @return RedisConnectionParams 70 | */ 71 | public function setTimeout(int $timeout): RedisConnectionParams 72 | { 73 | $this->timeout = $timeout; 74 | return $this; 75 | } 76 | 77 | /** 78 | * Retry interval (in seconds) 79 | * @param int $retryInterval 80 | * @return RedisConnectionParams 81 | */ 82 | public function setRetryInterval(int $retryInterval): RedisConnectionParams 83 | { 84 | $this->retryInterval = $retryInterval; 85 | return $this; 86 | } 87 | 88 | /** 89 | * Read timeout in seconds 90 | * @param float $readTimeout 91 | * @return RedisConnectionParams 92 | */ 93 | public function setReadTimeout(float $readTimeout): RedisConnectionParams 94 | { 95 | $this->readTimeout = $readTimeout; 96 | return $this; 97 | } 98 | 99 | public function isPersistentConnection(): bool 100 | { 101 | return $this->persistentConnection; 102 | } 103 | 104 | public function getHost(): string 105 | { 106 | return $this->host; 107 | } 108 | 109 | public function getPort(): int 110 | { 111 | return $this->port; 112 | } 113 | 114 | public function getTimeout(): int 115 | { 116 | return $this->timeout; 117 | } 118 | 119 | public function getRetryInterval(): int 120 | { 121 | return $this->retryInterval; 122 | } 123 | 124 | public function getReadTimeout(): float 125 | { 126 | return $this->readTimeout; 127 | } 128 | 129 | public function getUsername(): ?string 130 | { 131 | return $this->username; 132 | } 133 | 134 | public function getPassword(): ?string 135 | { 136 | return $this->password; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/DateTimeUtils.php: -------------------------------------------------------------------------------- 1 | format('Uu') / 1000); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Exception/InvalidAggregationException.php: -------------------------------------------------------------------------------- 1 | 29 | */ 30 | private $filters = []; 31 | 32 | public function __construct(string $label, string $value) 33 | { 34 | $this->filters[] = [$label, self::OP_EQUALS, $value]; 35 | } 36 | 37 | /** 38 | * @param string $label 39 | * @param int $operation 40 | * @param string|array|null $value 41 | * @return self 42 | */ 43 | public function add(string $label, int $operation, $value = null): self 44 | { 45 | if (!in_array($operation, self::OPERATIONS, true)) { 46 | throw new InvalidFilterOperationException('Operation is not valid'); 47 | } 48 | 49 | if (!is_string($value) && in_array($operation, [self::OP_EQUALS, self::OP_NOT_EQUALS], true)) { 50 | throw new InvalidFilterOperationException('The provided operation requires the value to be string'); 51 | } 52 | 53 | if ($value !== null && in_array($operation, [self::OP_EXISTS, self::OP_NOT_EXISTS], true)) { 54 | throw new InvalidFilterOperationException('The provided operation requires the value to be null'); 55 | } 56 | 57 | if (!is_array($value) && in_array($operation, [self::OP_IN, self::OP_NOT_IN], true)) { 58 | throw new InvalidFilterOperationException('The provided operation requires the value to be an array'); 59 | } 60 | 61 | $this->filters[] = [$label, $operation, $value]; 62 | 63 | return $this; 64 | } 65 | 66 | /** 67 | * @return string[] 68 | */ 69 | public function toRedisParams(): array 70 | { 71 | $params = []; 72 | foreach ($this->filters as $filter) { 73 | switch ($filter[1]) { 74 | case self::OP_EQUALS: 75 | assert(is_string($filter[2])); 76 | $params[] = $filter[0] . '=' . $filter[2]; 77 | break; 78 | case self::OP_NOT_EQUALS: 79 | assert(is_string($filter[2])); 80 | $params[] = $filter[0] . '!=' . $filter[2]; 81 | break; 82 | case self::OP_EXISTS: 83 | $params[] = $filter[0] . '='; 84 | break; 85 | case self::OP_NOT_EXISTS: 86 | $params[] = $filter[0] . '!='; 87 | break; 88 | case self::OP_IN: 89 | assert(is_array($filter[2])); 90 | $params[] = $filter[0] . '=(' . implode(',', $filter[2]) . ')'; 91 | break; 92 | case self::OP_NOT_IN: 93 | assert(is_array($filter[2])); 94 | $params[] = $filter[0] . '!=(' . implode(',', $filter[2]) . ')'; 95 | break; 96 | } 97 | } 98 | return $params; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Label.php: -------------------------------------------------------------------------------- 1 | key = $key; 22 | $this->value = $value; 23 | } 24 | 25 | /** 26 | * @codeCoverageIgnore 27 | */ 28 | public function getKey(): string 29 | { 30 | return $this->key; 31 | } 32 | 33 | /** 34 | * @codeCoverageIgnore 35 | */ 36 | public function getValue(): string 37 | { 38 | return $this->value; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Metadata.php: -------------------------------------------------------------------------------- 1 | lastTimestamp = $lastTimestamp; 51 | $this->retentionTime = $retentionTime; 52 | $this->chunkCount = $chunkCount; 53 | $this->maxSamplesPerChunk = $maxSamplesPerChunk; 54 | $this->labels = $labels; 55 | $this->sourceKey = $sourceKey; 56 | $this->rules = $rules; 57 | } 58 | 59 | /** 60 | * @param int $lastTimestamp 61 | * @param int $retentionTime 62 | * @param int $chunkCount 63 | * @param int $maxSamplesPerChunk 64 | * @param Label[] $labels 65 | * @param string|null $sourceKey 66 | * @param AggregationRule[] $rules 67 | * @return static 68 | */ 69 | public static function fromRedis( 70 | int $lastTimestamp, 71 | int $retentionTime = 0, 72 | int $chunkCount = 0, 73 | int $maxSamplesPerChunk = 0, 74 | array $labels = [], 75 | ?string $sourceKey = null, 76 | array $rules = [] 77 | ): self 78 | { 79 | $dateTime = DateTimeUtils::dateTimeFromTimestampWithMs($lastTimestamp); 80 | return new self($dateTime, $retentionTime, $chunkCount, $maxSamplesPerChunk, $labels, $sourceKey, $rules); 81 | } 82 | 83 | /** 84 | * @return DateTimeInterface 85 | */ 86 | public function getLastTimestamp(): DateTimeInterface 87 | { 88 | return $this->lastTimestamp; 89 | } 90 | 91 | /** 92 | * @return int 93 | */ 94 | public function getRetentionTime(): int 95 | { 96 | return $this->retentionTime; 97 | } 98 | 99 | /** 100 | * @return int 101 | */ 102 | public function getChunkCount(): int 103 | { 104 | return $this->chunkCount; 105 | } 106 | 107 | /** 108 | * @return int 109 | */ 110 | public function getMaxSamplesPerChunk(): int 111 | { 112 | return $this->maxSamplesPerChunk; 113 | } 114 | 115 | /** 116 | * @return Label[] 117 | */ 118 | public function getLabels(): array 119 | { 120 | return $this->labels; 121 | } 122 | 123 | /** 124 | * @return string 125 | */ 126 | public function getSourceKey(): ?string 127 | { 128 | return $this->sourceKey; 129 | } 130 | 131 | /** 132 | * @return AggregationRule[] 133 | */ 134 | public function getRules(): array 135 | { 136 | return $this->rules; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/Sample.php: -------------------------------------------------------------------------------- 1 | key = $key; 23 | $this->value = $value; 24 | $this->dateTime = $dateTime; 25 | } 26 | 27 | public static function createFromTimestamp(string $key, float $value, int $timestamp): Sample 28 | { 29 | return new self($key, $value, DateTimeUtils::dateTimeFromTimestampWithMs($timestamp)); 30 | } 31 | 32 | public function getKey(): string 33 | { 34 | return $this->key; 35 | } 36 | 37 | public function getValue(): float 38 | { 39 | return $this->value; 40 | } 41 | 42 | public function getDateTime(): ?DateTimeInterface 43 | { 44 | return $this->dateTime; 45 | } 46 | 47 | /** 48 | * @return string 49 | * @psalm-external-mutation-free 50 | */ 51 | public function getTimestampWithMs(): string 52 | { 53 | if ($this->dateTime === null) { 54 | return '*'; 55 | } 56 | return (string)DateTimeUtils::timestampWithMsFromDateTime($this->dateTime); 57 | } 58 | 59 | /** 60 | * @return string[] 61 | */ 62 | public function toRedisParams(): array 63 | { 64 | return [$this->getKey(), $this->getTimestampWithMs(), (string) $this->getValue()]; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/SampleWithLabels.php: -------------------------------------------------------------------------------- 1 | labels = $labels; 25 | } 26 | 27 | /** 28 | * @param string $key 29 | * @param float $value 30 | * @param int $timestamp 31 | * @param Label[] $labels 32 | * @return SampleWithLabels 33 | */ 34 | public static function createFromTimestampAndLabels( 35 | string $key, 36 | float $value, 37 | int $timestamp, 38 | array $labels 39 | ): SampleWithLabels 40 | { 41 | return new self($key, $value, DateTimeUtils::dateTimeFromTimestampWithMs($timestamp), $labels); 42 | } 43 | 44 | /** 45 | * @return Label[] 46 | */ 47 | public function getLabels(): array 48 | { 49 | return $this->labels; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/TimeSeries.php: -------------------------------------------------------------------------------- 1 | redis = $redis; 40 | } 41 | 42 | /** 43 | * Creates a key 44 | * @see https://oss.redislabs.com/redistimeseries/commands/#tscreate 45 | * @param string $key 46 | * @param int|null $retentionMs 47 | * @param Label[] $labels 48 | * @param bool $uncompressed 49 | * @param int|null $chunkSize 50 | * @param string|null $duplicatePolicy 51 | * @return void 52 | * @throws RedisException 53 | */ 54 | public function create( 55 | string $key, 56 | ?int $retentionMs = null, 57 | array $labels = [], 58 | bool $uncompressed = false, 59 | ?int $chunkSize = null, 60 | ?string $duplicatePolicy = null 61 | ): void 62 | { 63 | $params = []; 64 | 65 | if ($uncompressed === true) { 66 | $params[] = 'UNCOMPRESSED'; 67 | } 68 | 69 | if ($chunkSize !== null) { 70 | $params[] = 'CHUNK_SIZE'; 71 | $params[] = (string) $chunkSize; 72 | } 73 | 74 | if ($duplicatePolicy !== null) { 75 | if (!in_array($duplicatePolicy, self::DUPLICATE_POLICIES)) { 76 | throw new InvalidDuplicatePolicyException(sprintf("Duplicate policy %s is invalid", $duplicatePolicy)); 77 | } 78 | $params[] = 'DUPLICATE_POLICY'; 79 | $params[] = $duplicatePolicy; 80 | } 81 | 82 | $this->redis->executeCommand(array_merge( 83 | ['TS.CREATE', $key], 84 | $this->getRetentionParams($retentionMs), 85 | $params, 86 | $this->getLabelsParams(...$labels) 87 | )); 88 | } 89 | 90 | /** 91 | * Modifies an existing key 92 | * @see https://oss.redislabs.com/redistimeseries/commands/#tsalter 93 | * @param string $key 94 | * @param int|null $retentionMs 95 | * @param Label[] $labels 96 | * @return void 97 | * @throws RedisClientException 98 | * @throws RedisException 99 | */ 100 | public function alter(string $key, ?int $retentionMs = null, array $labels = []): void 101 | { 102 | $this->redis->executeCommand(array_merge( 103 | ['TS.ALTER', $key], 104 | $this->getRetentionParams($retentionMs), 105 | $this->getLabelsParams(...$labels) 106 | )); 107 | } 108 | 109 | /** 110 | * Adds a sample 111 | * @see https://oss.redislabs.com/redistimeseries/commands/#tsadd 112 | * @param Sample $sample 113 | * @param int|null $retentionMs 114 | * @param Label[] $labels 115 | * @param bool $uncompressed 116 | * @param int|null $chunkSize 117 | * @param string|null $duplicatePolicy 118 | * @return Sample 119 | * @throws RedisException 120 | */ 121 | public function add( 122 | Sample $sample, 123 | ?int $retentionMs = null, 124 | array $labels = [], 125 | bool $uncompressed = false, 126 | ?int $chunkSize = null, 127 | ?string $duplicatePolicy = null 128 | ): Sample 129 | { 130 | $params = []; 131 | 132 | if ($uncompressed === true) { 133 | $params[] = 'UNCOMPRESSED'; 134 | } 135 | 136 | if ($chunkSize !== null) { 137 | $params[] = 'CHUNK_SIZE'; 138 | $params[] = (string) $chunkSize; 139 | } 140 | 141 | if ($duplicatePolicy !== null) { 142 | if (!in_array($duplicatePolicy, self::DUPLICATE_POLICIES)) { 143 | throw new InvalidDuplicatePolicyException(sprintf("Duplicate policy %s is invalid", $duplicatePolicy)); 144 | } 145 | $params[] = 'ON_DUPLICATE'; 146 | $params[] = $duplicatePolicy; 147 | } 148 | 149 | $timestamp = (int)$this->redis->executeCommand(array_merge( 150 | ['TS.ADD'], 151 | $sample->toRedisParams(), 152 | $params, 153 | $this->getRetentionParams($retentionMs), 154 | $this->getLabelsParams(...$labels) 155 | )); 156 | return Sample::createFromTimestamp($sample->getKey(), $sample->getValue(), $timestamp); 157 | } 158 | 159 | /** 160 | * Adds many samples 161 | * @see https://oss.redislabs.com/redistimeseries/commands/#tsmadd 162 | * @param Sample[] $samples 163 | * @return Sample[] 164 | * @throws RedisClientException 165 | * @throws RedisException 166 | */ 167 | public function addMany(array $samples): array 168 | { 169 | if (empty($samples)) { 170 | return []; 171 | } 172 | $params = ['TS.MADD']; 173 | foreach ($samples as $sample) { 174 | $sampleParams = $sample->toRedisParams(); 175 | foreach ($sampleParams as $sampleParam) { 176 | $params[] = $sampleParam; 177 | } 178 | } 179 | /** @var int[] $timestamps */ 180 | $timestamps = $this->redis->executeCommand($params); 181 | $count = count($timestamps); 182 | $results = []; 183 | for ($i = 0; $i < $count; $i++) { 184 | $results[] = Sample::createFromTimestamp( 185 | $samples[$i]->getKey(), 186 | $samples[$i]->getValue(), 187 | $timestamps[$i] 188 | ); 189 | } 190 | return $results; 191 | } 192 | 193 | /** 194 | * Increments a sample by the amount given in the passed sample 195 | * @see https://oss.redislabs.com/redistimeseries/commands/#tsincrbytsdecrby 196 | * @param Sample $sample 197 | * @param int|null $resetMs 198 | * @param int|null $retentionMs 199 | * @param Label[] $labels 200 | * @throws RedisClientException 201 | * @throws RedisException 202 | */ 203 | public function incrementBy(Sample $sample, ?int $resetMs = null, ?int $retentionMs = null, array $labels = []): void 204 | { 205 | $this->incrementOrDecrementBy('TS.INCRBY', $sample, $resetMs, $retentionMs, $labels); 206 | } 207 | 208 | /** 209 | * Decrements a sample by the amount given in the passed sample 210 | * @see https://oss.redislabs.com/redistimeseries/commands/#tsincrbytsdecrby 211 | * @param Sample $sample 212 | * @param int|null $resetMs 213 | * @param int|null $retentionMs 214 | * @param Label[] $labels 215 | * @throws RedisClientException 216 | * @throws RedisException 217 | */ 218 | public function decrementBy(Sample $sample, ?int $resetMs = null, ?int $retentionMs = null, array $labels = []): void 219 | { 220 | $this->incrementOrDecrementBy('TS.DECRBY', $sample, $resetMs, $retentionMs, $labels); 221 | } 222 | 223 | /** 224 | * @param string $op 225 | * @param Sample $sample 226 | * @param int|null $resetMs 227 | * @param int|null $retentionMs 228 | * @param Label[] $labels 229 | * @return void 230 | * @throws RedisClientException 231 | * @throws RedisException 232 | */ 233 | private function incrementOrDecrementBy( 234 | string $op, 235 | Sample $sample, 236 | ?int $resetMs = null, 237 | ?int $retentionMs = null, 238 | array $labels = [] 239 | ): void 240 | { 241 | $params = [$op, $sample->getKey(), (string)$sample->getValue()]; 242 | if ($resetMs !== null) { 243 | $params[] = 'RESET'; 244 | $params[] = (string)$resetMs; 245 | } 246 | if ($sample->getDateTime() !== null) { 247 | $params[] = 'TIMESTAMP'; 248 | $params[] = $sample->getTimestampWithMs(); 249 | } 250 | $params = array_merge( 251 | $params, 252 | $this->getRetentionParams($retentionMs), 253 | $this->getLabelsParams(...$labels) 254 | ); 255 | $this->redis->executeCommand($params); 256 | } 257 | 258 | /** 259 | * Creates an aggregation rules for a key 260 | * @see https://oss.redislabs.com/redistimeseries/commands/#tscreaterule 261 | * @param string $sourceKey 262 | * @param string $destKey 263 | * @param AggregationRule $rule 264 | * @return void 265 | * @throws RedisClientException 266 | * @throws RedisException 267 | */ 268 | public function createRule(string $sourceKey, string $destKey, AggregationRule $rule): void 269 | { 270 | $this->redis->executeCommand(array_merge( 271 | ['TS.CREATERULE', $sourceKey, $destKey], 272 | $this->getAggregationParams($rule) 273 | )); 274 | } 275 | 276 | /** 277 | * Deletes an existing aggregation rule 278 | * @see https://oss.redislabs.com/redistimeseries/commands/#tsdeleterule 279 | * @param string $sourceKey 280 | * @param string $destKey 281 | * @return void 282 | * @throws RedisClientException 283 | * @throws RedisException 284 | */ 285 | public function deleteRule(string $sourceKey, string $destKey): void 286 | { 287 | $this->redis->executeCommand(['TS.DELETERULE', $sourceKey, $destKey]); 288 | } 289 | 290 | /** 291 | * Gets samples for a key, optionally aggregating them 292 | * @see https://oss.redislabs.com/redistimeseries/commands/#tsrange 293 | * @param string $key 294 | * @param DateTimeInterface|null $from 295 | * @param DateTimeInterface|null $to 296 | * @param int|null $count 297 | * @param AggregationRule|null $rule 298 | * @param bool $reverse 299 | * @return Sample[] 300 | * @throws RedisClientException 301 | * @throws RedisException 302 | */ 303 | public function range( 304 | string $key, 305 | ?DateTimeInterface $from = null, 306 | ?DateTimeInterface $to = null, 307 | ?int $count = null, 308 | ?AggregationRule $rule = null, 309 | bool $reverse = false 310 | ): array 311 | { 312 | $fromTs = $from ? (string)DateTimeUtils::timestampWithMsFromDateTime($from) : '-'; 313 | $toTs = $to ? (string)DateTimeUtils::timestampWithMsFromDateTime($to) : '+'; 314 | 315 | $command = $reverse ? 'TS.REVRANGE' : 'TS.RANGE'; 316 | $params = [$command, $key, $fromTs, $toTs]; 317 | if ($count !== null) { 318 | $params[] = 'COUNT'; 319 | $params[] = (string)$count; 320 | } 321 | 322 | $rawResults = $this->redis->executeCommand(array_merge($params, $this->getAggregationParams($rule))); 323 | 324 | $samples = []; 325 | foreach ($rawResults as $rawResult) { 326 | $samples[] = Sample::createFromTimestamp($key, (float)$rawResult[1], (int)$rawResult[0]); 327 | } 328 | return $samples; 329 | } 330 | 331 | /** 332 | * Gets samples from multiple keys, searching by a given filter. 333 | * @param Filter $filter 334 | * @param DateTimeInterface|null $from 335 | * @param DateTimeInterface|null $to 336 | * @param int|null $count 337 | * @param AggregationRule|null $rule 338 | * @param bool $reverse 339 | * @return Sample[] 340 | * @throws RedisClientException 341 | * @throws RedisException 342 | */ 343 | public function multiRange( 344 | Filter $filter, 345 | ?DateTimeInterface $from = null, 346 | ?DateTimeInterface $to = null, 347 | ?int $count = null, 348 | ?AggregationRule $rule = null, 349 | bool $reverse = false 350 | ): array 351 | { 352 | $results = $this->multiRangeRaw($filter, $from, $to, $count, $rule, $reverse); 353 | 354 | $samples = []; 355 | foreach ($results as $groupByKey) { 356 | $key = $groupByKey[0]; 357 | foreach ($groupByKey[2] as $result) { 358 | $samples[] = Sample::createFromTimestamp($key, (float)$result[1], (int)$result[0]); 359 | } 360 | } 361 | return $samples; 362 | } 363 | 364 | /** 365 | * @param Filter $filter 366 | * @param DateTimeInterface|null $from 367 | * @param DateTimeInterface|null $to 368 | * @param int|null $count 369 | * @param AggregationRule|null $rule 370 | * @param bool $reverse 371 | * @return SampleWithLabels[] 372 | * @throws RedisClientException 373 | * @throws RedisException 374 | */ 375 | public function multiRangeWithLabels( 376 | Filter $filter, 377 | ?DateTimeInterface $from = null, 378 | ?DateTimeInterface $to = null, 379 | ?int $count = null, 380 | ?AggregationRule $rule = null, 381 | bool $reverse = false 382 | ): array 383 | { 384 | $results = $this->multiRangeRaw($filter, $from, $to, $count, $rule, $reverse, true); 385 | 386 | $samples = []; 387 | foreach ($results as $groupByKey) { 388 | $key = $groupByKey[0]; 389 | $labels = []; 390 | foreach ($groupByKey[1] as $label) { 391 | $labels[] = new Label($label[0], $label[1]); 392 | } 393 | foreach ($groupByKey[2] as $result) { 394 | $samples[] = SampleWithLabels::createFromTimestampAndLabels( 395 | $key, 396 | (float)$result[1], 397 | $result[0], 398 | $labels 399 | ); 400 | } 401 | } 402 | return $samples; 403 | } 404 | 405 | /** 406 | * @param Filter $filter 407 | * @param DateTimeInterface|null $from 408 | * @param DateTimeInterface|null $to 409 | * @param int|null $count 410 | * @param AggregationRule|null $rule 411 | * @param bool $reverse 412 | * @param bool $withLabels 413 | * @return array 414 | * @throws RedisException 415 | */ 416 | private function multiRangeRaw( 417 | Filter $filter, 418 | ?DateTimeInterface $from = null, 419 | ?DateTimeInterface $to = null, 420 | ?int $count = null, 421 | ?AggregationRule $rule = null, 422 | bool $reverse = false, 423 | bool $withLabels = false 424 | ): array 425 | { 426 | $fromTs = $from ? (string)DateTimeUtils::timestampWithMsFromDateTime($from) : '-'; 427 | $toTs = $to ? (string)DateTimeUtils::timestampWithMsFromDateTime($to) : '+'; 428 | 429 | $command = $reverse ? 'TS.MREVRANGE' : 'TS.MRANGE'; 430 | $params = [$command, $fromTs, $toTs]; 431 | 432 | if ($count !== null) { 433 | $params[] = 'COUNT'; 434 | $params[] = (string)$count; 435 | } 436 | 437 | $params = array_merge($params, $this->getAggregationParams($rule)); 438 | 439 | if ($withLabels) { 440 | $params[] = 'WITHLABELS'; 441 | } 442 | 443 | $params = array_merge($params, ['FILTER'], $filter->toRedisParams()); 444 | 445 | return $this->redis->executeCommand($params); 446 | } 447 | 448 | /** 449 | * Gets the last sample for a key 450 | * @param string $key 451 | * @return Sample 452 | * @throws RedisClientException 453 | * @throws RedisException 454 | */ 455 | public function getLastSample(string $key): Sample 456 | { 457 | $result = $this->redis->executeCommand(['TS.GET', $key]); 458 | return Sample::createFromTimestamp($key, (float)$result[1], (int)$result[0]); 459 | } 460 | 461 | /** 462 | * Gets the last samples for multiple keys using a filter 463 | * @param Filter $filter 464 | * @return array 465 | * @throws RedisClientException 466 | * @throws RedisException 467 | */ 468 | public function getLastSamples(Filter $filter): array 469 | { 470 | $results = $this->redis->executeCommand( 471 | array_merge(['TS.MGET', 'FILTER'], $filter->toRedisParams()) 472 | ); 473 | $samples = []; 474 | foreach ($results as $result) { 475 | // most recent versions of TS.MGET return results in a nested array 476 | if (count($result) === 3) { 477 | $samples[] = Sample::createFromTimestamp($result[0], (float)$result[2][1], (int)$result[2][0]); 478 | } else { 479 | $samples[] = Sample::createFromTimestamp($result[0], (float)$result[3], (int)$result[2]); 480 | } 481 | } 482 | return $samples; 483 | } 484 | 485 | /** 486 | * Gets metadata regarding a key 487 | * @param string $key 488 | * @return Metadata 489 | * @throws RedisException 490 | */ 491 | public function info(string $key): Metadata 492 | { 493 | $result = $this->redis->executeCommand(['TS.INFO', $key]); 494 | 495 | $labels = []; 496 | foreach ($result[9] as $strLabel) { 497 | $labels[] = new Label($strLabel[0], $strLabel[1]); 498 | } 499 | 500 | $sourceKey = $result[11] === false ? null : $result[11]; 501 | 502 | $rules = []; 503 | foreach ($result[13] as $rule) { 504 | $rules[$rule[0]] = new AggregationRule($rule[2], $rule[1]); 505 | } 506 | 507 | return Metadata::fromRedis($result[1], $result[3], $result[5], $result[7], $labels, $sourceKey, $rules); 508 | } 509 | 510 | /** 511 | * Lists the keys matching a filter 512 | * @param Filter $filter 513 | * @return string[] 514 | * @throws RedisException 515 | */ 516 | public function getKeysByFilter(Filter $filter): array 517 | { 518 | return $this->redis->executeCommand( 519 | array_merge(['TS.QUERYINDEX'], $filter->toRedisParams()) 520 | ); 521 | } 522 | 523 | /** 524 | * @param int|null $retentionMs 525 | * @return string[] 526 | */ 527 | private function getRetentionParams(?int $retentionMs = null): array 528 | { 529 | if ($retentionMs === null) { 530 | return []; 531 | } 532 | return ['RETENTION', (string)$retentionMs]; 533 | } 534 | 535 | /** 536 | * @param Label ...$labels 537 | * @return string[] 538 | */ 539 | private function getLabelsParams(Label ...$labels): array 540 | { 541 | $params = []; 542 | foreach ($labels as $label) { 543 | $params[] = $label->getKey(); 544 | $params[] = $label->getValue(); 545 | } 546 | 547 | if (empty($params)) { 548 | return []; 549 | } 550 | 551 | array_unshift($params, 'LABELS'); 552 | return $params; 553 | } 554 | 555 | /** 556 | * @param AggregationRule|null $rule 557 | * @return string[] 558 | */ 559 | private function getAggregationParams(?AggregationRule $rule = null): array 560 | { 561 | if ($rule === null) { 562 | return []; 563 | } 564 | return ['AGGREGATION', $rule->getType(), (string)$rule->getTimeBucketMs()]; 565 | } 566 | } 567 | -------------------------------------------------------------------------------- /tests/Integration/IntegrationTest.php: -------------------------------------------------------------------------------- 1 | redisClient = new RedisClient(new Redis(), $connectionParams); 34 | $this->redisClient->executeCommand(['FLUSHDB']); 35 | $this->sut = new TimeSeries($this->redisClient); 36 | } 37 | 38 | protected function tearDown(): void 39 | { 40 | $this->redisClient->executeCommand(['FLUSHDB']); 41 | } 42 | 43 | public function testAddAndRetrieveAsRange(): void 44 | { 45 | $from = new DateTimeImmutable('2019-11-06 20:34:17.000'); 46 | $to = new DateTimeImmutable('2019-11-06 20:34:17.100'); 47 | 48 | $this->sut->create( 49 | 'temperature:3:11', 50 | 6000, 51 | [new Label('sensor_id', '2'), new Label('area_id', '32')] 52 | ); 53 | $this->sut->add(new Sample('temperature:3:11', 30, $from)); 54 | $this->sut->add(new Sample('temperature:3:11', 42, $to)); 55 | 56 | $range = $this->sut->range( 57 | 'temperature:3:11', 58 | $from, 59 | $to, 60 | null, 61 | new AggregationRule(AggregationRule::AGG_AVG, 10) 62 | ); 63 | 64 | $expectedRange = [ 65 | new Sample('temperature:3:11', 30, new DateTimeImmutable('2019-11-06 20:34:17.000')), 66 | new Sample('temperature:3:11', 42, new DateTimeImmutable('2019-11-06 20:34:17.100')) 67 | ]; 68 | 69 | self::assertEquals($expectedRange, $range); 70 | } 71 | 72 | public function testAddAndRetrieveWithDuplicatePolicySum(): void 73 | { 74 | $dt = new DateTimeImmutable('2019-11-06 20:34:17.000'); 75 | $this->sut->create( 76 | 'temperature:3:11', 77 | 6000, 78 | [new Label('sensor_id', '2'), new Label('area_id', '32')], 79 | false, 80 | null, 81 | TimeSeries::DUPLICATE_POLICY_SUM 82 | ); 83 | 84 | $this->sut->add(new Sample('temperature:3:11', 10.0, $dt)); 85 | $this->sut->add(new Sample('temperature:3:11', 20.0, $dt)); 86 | 87 | $result = $this->sut->range('temperature:3:11', $dt, $dt); 88 | 89 | self::assertEquals([new Sample('temperature:3:11', 30.0, $dt)], $result); 90 | } 91 | 92 | public function testAddAndRetrieveAsMultirangeWithLabelsReverse(): void 93 | { 94 | $this->sut->create( 95 | 'temperature:3:11', 96 | 60000, 97 | [new Label('sensor_id', '3'), new Label('area_id', '11')] 98 | ); 99 | $this->sut->add( 100 | new Sample('temperature:3:11', 30, new DateTimeImmutable('2019-11-06 20:34:10.400')) 101 | ); 102 | $this->sut->add( 103 | new Sample('temperature:3:11', 42, new DateTimeImmutable('2019-11-06 20:34:11.400')) 104 | ); 105 | 106 | $this->sut->create( 107 | 'temperature:3:12', 108 | 60000, 109 | [new Label('sensor_id', '3'), new Label('area_id', '12')] 110 | ); 111 | $this->sut->add( 112 | new Sample('temperature:3:12', 34, new DateTimeImmutable('2019-11-06 20:34:10.000')) 113 | ); 114 | $this->sut->add( 115 | new Sample('temperature:3:12', 48, new DateTimeImmutable('2019-11-06 20:34:11.000')) 116 | ); 117 | 118 | $range = $this->sut->multiRangeWithLabels( 119 | new Filter('sensor_id', '3'), 120 | null, 121 | null, 122 | null, 123 | new AggregationRule(AggregationRule::AGG_AVG, 60000), // 1-minute aggregation 124 | true 125 | ); 126 | 127 | $expectedRange = [ 128 | new SampleWithLabels( 129 | 'temperature:3:11', 130 | 36, // average between 30 and 42 131 | new DateTimeImmutable('2019-11-06 20:34:00.000'), 132 | [new Label('sensor_id', '3'), new Label('area_id', '11')] 133 | ), 134 | new SampleWithLabels( 135 | 'temperature:3:12', 136 | 41, //average beween 34 and 48 137 | new DateTimeImmutable('2019-11-06 20:34:00.000'), 138 | [new Label('sensor_id', '3'), new Label('area_id', '12')] 139 | ), 140 | ]; 141 | 142 | self::assertEquals($expectedRange, $range); 143 | } 144 | 145 | public function testAddAndRetrieveAsMultiRangeWithMultipleFilters(): void 146 | { 147 | $from = new DateTimeImmutable('2019-11-06 20:34:17.000'); 148 | $to = new DateTimeImmutable('2019-11-06 20:34:17.100'); 149 | 150 | $this->sut->create( 151 | 'temperature:3:11', 152 | 6000, 153 | [new Label('sensor_id', '2'), new Label('area_id', '32')] 154 | ); 155 | $this->sut->add(new Sample('temperature:3:11', 30, $from)); 156 | $this->sut->add(new Sample('temperature:3:11', 42, $to)); 157 | 158 | $filter = new Filter('sensor_id', '2'); 159 | $filter->add('area_id', Filter::OP_EQUALS, '32'); 160 | 161 | $range = $this->sut->multiRange($filter); 162 | 163 | $expectedRange = [ 164 | new Sample('temperature:3:11', 30, new DateTimeImmutable('2019-11-06 20:34:17.000')), 165 | new Sample('temperature:3:11', 42, new DateTimeImmutable('2019-11-06 20:34:17.100')) 166 | ]; 167 | 168 | self::assertEquals($expectedRange, $range); 169 | } 170 | 171 | public function testAddAndRetrieveAsLastSamplesWithMultipleFilters(): void 172 | { 173 | $from = new DateTimeImmutable('2019-11-06 20:34:17.000'); 174 | $to = new DateTimeImmutable('2019-11-06 20:34:18.000'); 175 | 176 | $this->sut->create( 177 | 'temperature:3:11', 178 | 6000, 179 | [new Label('sensor_id', '2'), new Label('area_id', '32')] 180 | ); 181 | $this->sut->add(new Sample('temperature:3:11', 30, $from)); 182 | $this->sut->add(new Sample('temperature:3:11', 42, $to)); 183 | 184 | $this->sut->create( 185 | 'temperature:3:12', 186 | 6000, 187 | [new Label('sensor_id', '2'), new Label('area_id', '32')] 188 | ); 189 | $this->sut->add(new Sample('temperature:3:12', 30, $from)); 190 | $this->sut->add(new Sample('temperature:3:12', 42, $to)); 191 | 192 | $filter = new Filter('sensor_id', '2'); 193 | $filter->add('area_id', Filter::OP_EQUALS, '32'); 194 | 195 | $range = $this->sut->getLastSamples($filter); 196 | 197 | $expectedResult = [ 198 | new Sample('temperature:3:11', 42, new DateTimeImmutable('2019-11-06 20:34:18.000')), 199 | new Sample('temperature:3:12', 42, new DateTimeImmutable('2019-11-06 20:34:18.000')) 200 | ]; 201 | 202 | self::assertEquals($expectedResult, $range); 203 | } 204 | 205 | public function testAddAndRetrieveKeysWithMultipleFilters(): void 206 | { 207 | $from = new DateTimeImmutable('2019-11-06 20:34:17.000'); 208 | $to = new DateTimeImmutable('2019-11-06 20:34:17.100'); 209 | 210 | $this->sut->create( 211 | 'temperature:3:11', 212 | 6000, 213 | [new Label('sensor_id', '2'), new Label('area_id', '32')] 214 | ); 215 | $this->sut->add(new Sample('temperature:3:11', 30, $from)); 216 | $this->sut->add(new Sample('temperature:3:11', 42, $to)); 217 | 218 | $this->sut->create( 219 | 'temperature:3:12', 220 | 6000, 221 | [new Label('sensor_id', '2'), new Label('area_id', '32')] 222 | ); 223 | $this->sut->add(new Sample('temperature:3:12', 30, $from)); 224 | $this->sut->add(new Sample('temperature:3:12', 42, $to)); 225 | 226 | $filter = new Filter('sensor_id', '2'); 227 | $filter->add('area_id', Filter::OP_EQUALS, '32'); 228 | 229 | $range = $this->sut->getKeysByFilter($filter); 230 | 231 | $expectedResult = ['temperature:3:11', 'temperature:3:12']; 232 | 233 | self::assertEquals($expectedResult, $range); 234 | } 235 | 236 | public function testAddAndRetrieveWithDateTimeObjectAsMultiRangeWithMultipleFilters(): void 237 | { 238 | $currentDate = new DateTime(); 239 | $from = (clone $currentDate)->sub(new DateInterval('P1D')); 240 | $to = $currentDate; 241 | 242 | $this->sut->create( 243 | 'temperature:3:11', 244 | 6000, 245 | [new Label('sensor_id', '2'), new Label('area_id', '32')] 246 | ); 247 | $this->sut->add(new Sample('temperature:3:11', 30, $from)); 248 | $this->sut->add(new Sample('temperature:3:11', 42, $to)); 249 | 250 | $filter = new Filter('sensor_id', '2'); 251 | $filter->add('area_id', Filter::OP_EQUALS, '32'); 252 | 253 | $range = $this->sut->multiRange($filter); 254 | 255 | $expectedRange = [ 256 | Sample::createFromTimestamp('temperature:3:11', (float)42, DateTimeUtils::timestampWithMsFromDateTime(new DateTimeImmutable($to->format('Y-m-d H:i:s.u')))) 257 | ]; 258 | 259 | self::assertEquals($expectedRange, $range); 260 | } 261 | 262 | public function testAddAndRetrieveWithDateTimeObjectAsRange(): void 263 | { 264 | $from = new DateTimeImmutable('2019-11-06 20:34:17.103000'); 265 | $to = new DateTimeImmutable('2019-11-06 20:34:17.107000'); 266 | 267 | $this->sut->create( 268 | 'temperature:3:11', 269 | null, 270 | [new Label('sensor_id', '2'), new Label('area_id', '32')] 271 | ); 272 | 273 | $this->sut->add(new Sample('temperature:3:11', 30, $from)); 274 | $this->sut->add(new Sample('temperature:3:11', 42, $to)); 275 | 276 | $range = $this->sut->range( 277 | 'temperature:3:11' 278 | ); 279 | 280 | $expectedRange = [ 281 | Sample::createFromTimestamp( 282 | 'temperature:3:11', 283 | (float)30, 284 | DateTimeUtils::timestampWithMsFromDateTime(new DateTimeImmutable($from->format('Y-m-d H:i:s.u'))) 285 | ), 286 | Sample::createFromTimestamp( 287 | 'temperature:3:11', 288 | (float)42, 289 | DateTimeUtils::timestampWithMsFromDateTime(new DateTimeImmutable($to->format('Y-m-d H:i:s.u'))) 290 | ), 291 | ]; 292 | 293 | self::assertEquals($expectedRange, $range); 294 | } 295 | } 296 | -------------------------------------------------------------------------------- /tests/Unit/AggregationRuleTest.php: -------------------------------------------------------------------------------- 1 | expectException(InvalidAggregationException::class); 16 | new AggregationRule('foo', 1000); 17 | } 18 | 19 | public function testTypesAreCaseInsensitive(): void 20 | { 21 | $rule = new AggregationRule('avg', 1000); 22 | self::assertEquals(AggregationRule::AGG_AVG, $rule->getType()); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/Unit/DateTimeUtilsTest.php: -------------------------------------------------------------------------------- 1 | expectException(InvalidFilterOperationException::class); 16 | $this->expectExceptionMessage('Operation is not valid'); 17 | 18 | $filter = new Filter('a', 'b'); 19 | $filter->add('a', 666); 20 | } 21 | 22 | /** 23 | * @param mixed $operation 24 | * @dataProvider stringOperations 25 | */ 26 | public function testOperationRequiresString($operation): void 27 | { 28 | $this->expectException(InvalidFilterOperationException::class); 29 | $this->expectExceptionMessage('The provided operation requires the value to be string'); 30 | 31 | $filter = new Filter('a', 'b'); 32 | $filter->add('a', $operation, []); 33 | } 34 | 35 | /** 36 | * @param mixed $operation 37 | * @dataProvider nullOperations 38 | */ 39 | public function testOperationRequiresNull($operation): void 40 | { 41 | $this->expectException(InvalidFilterOperationException::class); 42 | $this->expectExceptionMessage('The provided operation requires the value to be null'); 43 | 44 | $filter = new Filter('a', 'b'); 45 | $filter->add('a', $operation, []); 46 | } 47 | 48 | /** 49 | * @param mixed $operation 50 | * @dataProvider arrayOperations 51 | */ 52 | public function testOperationRequiresArray($operation): void 53 | { 54 | $this->expectException(InvalidFilterOperationException::class); 55 | $this->expectExceptionMessage('The provided operation requires the value to be an array'); 56 | 57 | $filter = new Filter('a', 'b'); 58 | $filter->add('a', $operation, null); 59 | } 60 | 61 | public function testToRedisParams(): void 62 | { 63 | $filter = new Filter('lab1', 'val1'); 64 | $filter->add('lab2', Filter::OP_NOT_EQUALS, 'val2'); 65 | $filter->add('lab3', Filter::OP_EXISTS); 66 | $filter->add('lab4', Filter::OP_NOT_EXISTS); 67 | $filter->add('lab5', Filter::OP_IN, ['a', 'b', 'c']); 68 | $filter->add('lab6', Filter::OP_NOT_IN, ['d', 'e', 'f']); 69 | 70 | $result = $filter->toRedisParams(); 71 | $expected = ['lab1=val1', 'lab2!=val2', 'lab3=', 'lab4!=', 'lab5=(a,b,c)', 'lab6!=(d,e,f)']; 72 | 73 | self::assertEquals($expected, $result); 74 | } 75 | 76 | public function stringOperations(): array 77 | { 78 | return [[Filter::OP_EQUALS], [Filter::OP_NOT_EQUALS]]; 79 | } 80 | 81 | public function nullOperations(): array 82 | { 83 | return [[Filter::OP_EXISTS], [Filter::OP_NOT_EXISTS]]; 84 | } 85 | 86 | public function arrayOperations(): array 87 | { 88 | return [[Filter::OP_IN], [Filter::OP_NOT_IN]]; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /tests/Unit/MetadataTest.php: -------------------------------------------------------------------------------- 1 | getLastTimestamp()); 28 | self::assertEquals(1, $metadata->getRetentionTime()); 29 | self::assertEquals(2, $metadata->getChunkCount()); 30 | self::assertEquals(3, $metadata->getMaxSamplesPerChunk()); 31 | self::assertEquals($labels, $metadata->getLabels()); 32 | self::assertEquals(null, $metadata->getSourceKey()); 33 | self::assertEquals($rules, $metadata->getRules()); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/Unit/RedisClientTest.php: -------------------------------------------------------------------------------- 1 | createMock(Redis::class); 19 | $redisMock->expects(self::once())->method('isConnected')->willReturn(false); 20 | $redisMock->expects(self::once())->method('connect')->with( 21 | '127.0.0.1', 22 | 6379, 23 | 3, 24 | null, 25 | 1, 26 | 2.2 27 | ); 28 | $redisMock->expects(self::once())->method('rawCommand')->with('MY', 'command'); 29 | $connectionParams = new RedisConnectionParams(); 30 | $connectionParams->setRetryInterval(1) 31 | ->setReadTimeout(2.2) 32 | ->setTimeout(3); 33 | $sut = new RedisClient($redisMock, $connectionParams); 34 | $sut->executeCommand(['MY', 'command']); 35 | } 36 | 37 | public function testPersistentConnection(): void 38 | { 39 | $redisMock = $this->createMock(Redis::class); 40 | $redisMock->expects(self::once())->method('isConnected')->willReturn(false); 41 | $redisMock->expects(self::once())->method('pconnect')->with( 42 | '127.0.0.1', 43 | 6379, 44 | 0, 45 | self::isType(IsType::TYPE_STRING), 46 | 0, 47 | 0.0 48 | ); 49 | $connectionParams = new RedisConnectionParams(); 50 | $connectionParams->setPersistentConnection(true); 51 | $sut = new RedisClient($redisMock, $connectionParams); 52 | $sut->executeCommand(['MY', 'command']); 53 | } 54 | 55 | public function testDontConnectIfNotNecessary(): void 56 | { 57 | $redisMock = $this->createMock(Redis::class); 58 | $redisMock->expects(self::once())->method('isConnected')->willReturn(true); 59 | $redisMock->expects(self::never())->method('connect'); 60 | $redisMock->expects(self::never())->method('pconnect'); 61 | $connectionParams = new RedisConnectionParams(); 62 | $sut = new RedisClient($redisMock, $connectionParams); 63 | $sut->executeCommand(['MY', 'command']); 64 | } 65 | 66 | public function testFailureToConnectThrowsException(): void 67 | { 68 | $this->expectException(RedisClientException::class); 69 | $redisMock = $this->createMock(Redis::class); 70 | $redisMock->expects(self::once())->method('connect')->willReturn(false); 71 | $connectionParams = new RedisConnectionParams(); 72 | $sut = new RedisClient($redisMock, $connectionParams); 73 | $sut->executeCommand(['MY', 'command']); 74 | } 75 | 76 | public function testConnectionWithPassword(): void 77 | { 78 | $redisMock = $this->createMock(Redis::class); 79 | $redisMock->expects(self::once())->method('isConnected')->willReturn(false); 80 | $redisMock->expects(self::once()) 81 | ->method('auth') 82 | ->with('pass'); 83 | $connectionParams = new RedisConnectionParams('127.0.0.1', 6379, null, 'pass'); 84 | $sut = new RedisClient($redisMock, $connectionParams); 85 | $sut->executeCommand(['MY', 'command']); 86 | } 87 | 88 | public function testConnectionWithUseranameAndPassword(): void 89 | { 90 | $redisMock = $this->createMock(Redis::class); 91 | $redisMock->expects(self::once())->method('isConnected')->willReturn(false); 92 | $redisMock->expects(self::exactly(2)) 93 | ->method('rawCommand') 94 | ->withConsecutive( 95 | ['AUTH', 'username', 'pass'], 96 | ['MY', 'command'] 97 | ); 98 | $connectionParams = new RedisConnectionParams('127.0.0.1', 6379, 'username', 'pass'); 99 | $sut = new RedisClient($redisMock, $connectionParams); 100 | $sut->executeCommand(['MY', 'command']); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /tests/Unit/SampleTest.php: -------------------------------------------------------------------------------- 1 | getTimestampWithMs(); 16 | self::assertEquals(1483300866234, $ts); 17 | } 18 | 19 | public function testCreateFromTimestamp(): void 20 | { 21 | $sample = Sample::createFromTimestamp('a', 1, 1483300866234); 22 | $dateTime = $sample->getDateTime(); 23 | self::assertEquals(new DateTimeImmutable('2017-01-01T20.01.06.234'), $dateTime); 24 | } 25 | 26 | public function testCurrentTimestampReturnsStar(): void 27 | { 28 | $sample = new Sample('a', 1); 29 | $params = $sample->toRedisParams(); 30 | self::assertEquals(['a', '*', 1], $params); 31 | } 32 | 33 | public function testToRedisParams(): void 34 | { 35 | $sample = Sample::createFromTimestamp('a', 1, 1483300866234); 36 | $params = $sample->toRedisParams(); 37 | self::assertEquals(['a', 1483300866234, 1], $params); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/Unit/SampleWithLabelsTest.php: -------------------------------------------------------------------------------- 1 | getLabels()); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/Unit/TimeSeriesTest.php: -------------------------------------------------------------------------------- 1 | redisClientMock = $this->createMock(RedisClient::class); 34 | $this->sut = new TimeSeries($this->redisClientMock); 35 | } 36 | 37 | /** 38 | * @dataProvider createDataProvider 39 | */ 40 | public function testCreate(array $params, array $expectedParams): void 41 | { 42 | $this->redisClientMock 43 | ->expects(self::once()) 44 | ->method('executeCommand') 45 | ->with($expectedParams); 46 | $this->sut->create(...$params); 47 | } 48 | 49 | public function createDataProvider(): array 50 | { 51 | return [ 52 | 'full' => [ 53 | [ 54 | 'a', 55 | 10, 56 | [new Label('l1', 'v1'), new Label('l2', 'v2')], 57 | true, 58 | 10000, 59 | TimeSeries::DUPLICATE_POLICY_SUM 60 | ], 61 | [ 62 | 'TS.CREATE', 63 | 'a', 64 | 'RETENTION', 65 | 10, 66 | 'UNCOMPRESSED', 67 | 'CHUNK_SIZE', 68 | '10000', 69 | 'DUPLICATE_POLICY', 70 | 'SUM', 71 | 'LABELS', 72 | 'l1', 73 | 'v1', 74 | 'l2', 75 | 'v2' 76 | ], 77 | ], 78 | 'most common' => [ 79 | [ 80 | 'a', 81 | 10, 82 | [new Label('l1', 'v1'), new Label('l2', 'v2')] 83 | ], 84 | ['TS.CREATE', 'a', 'RETENTION', 10, 'LABELS', 'l1', 'v1', 'l2', 'v2'] 85 | ], 86 | 'no labels' => [['a', 10], ['TS.CREATE', 'a', 'RETENTION', 10]], 87 | 'minimal' => [['a'], ['TS.CREATE', 'a']] 88 | ]; 89 | } 90 | 91 | public function testAlter(): void 92 | { 93 | $this->redisClientMock 94 | ->expects(self::once()) 95 | ->method('executeCommand') 96 | ->with(['TS.ALTER', 'a', 'RETENTION', 10, 'LABELS', 'l1', 'v1', 'l2', 'v2']); 97 | $this->sut->alter( 98 | 'a', 99 | 10, 100 | [new Label('l1', 'v1'), new Label('l2', 'v2')] 101 | ); 102 | } 103 | 104 | /** 105 | * @dataProvider addDataProvider 106 | */ 107 | public function testAdd(array $params, array $expectedParams): void 108 | { 109 | $this->redisClientMock 110 | ->expects(self::once()) 111 | ->method('executeCommand') 112 | ->with($expectedParams) 113 | ->willReturn(1483300866234); 114 | $addedSample = $this->sut->add(...$params); 115 | $expectedSample = new Sample('a', 10.1, new DateTimeImmutable('2017-01-01T20.01.06.234')); 116 | self::assertEquals($expectedSample, $addedSample); 117 | } 118 | 119 | public function addDataProvider(): array 120 | { 121 | return [ 122 | 'full' => [ 123 | [ 124 | new Sample('a', 10.1, new DateTimeImmutable('2017-01-01T20.01.06.234')), 125 | 10, 126 | [new Label('l1', 'v1'), new Label('l2', 'v2')] 127 | ], 128 | ['TS.ADD', 'a', 1483300866234, 10.1, 'RETENTION', 10, 'LABELS', 'l1', 'v1', 'l2', 'v2'] 129 | ], 130 | 'no datetime' => [ 131 | [ 132 | new Sample('a', 10.1), 133 | 10, 134 | [new Label('l1', 'v1'), new Label('l2', 'v2')] 135 | ], 136 | ['TS.ADD', 'a', '*', 10.1, 'RETENTION', 10, 'LABELS', 'l1', 'v1', 'l2', 'v2'] 137 | ] 138 | ]; 139 | } 140 | 141 | public function testAddMany(): void 142 | { 143 | $this->redisClientMock 144 | ->expects(self::once()) 145 | ->method('executeCommand') 146 | ->with(['TS.MADD', 'a', '*', 10.1, 'b', 1483300866234, 1.0]) 147 | ->willReturn([1483300866233, 1483300866234]); 148 | $addedSamples = $this->sut->addMany([ 149 | new Sample('a', 10.1), 150 | new Sample('b', 1, new DateTimeImmutable('2017-01-01T20.01.06.234')) 151 | ]); 152 | $expectedSamples = [ 153 | new Sample('a', 10.1, new DateTimeImmutable('2017-01-01T20.01.06.233')), 154 | new Sample('b', 1.0, new DateTimeImmutable('2017-01-01T20.01.06.234')) 155 | ]; 156 | self::assertEquals($expectedSamples, $addedSamples); 157 | } 158 | 159 | public function testAddManyEmpty(): void 160 | { 161 | $this->redisClientMock 162 | ->expects(self::never()) 163 | ->method('executeCommand') 164 | ->willReturn([]); 165 | $addedSamples = $this->sut->addMany([]); 166 | self::assertEquals([], $addedSamples); 167 | } 168 | 169 | public function testIncrementBy(): void 170 | { 171 | $this->redisClientMock 172 | ->expects(self::once()) 173 | ->method('executeCommand') 174 | ->with(['TS.INCRBY', 'a', 10.1, 'RESET', 10, 'RETENTION', 20, 'LABELS', 'l1', 'v1', 'l2', 'v2']); 175 | $this->sut->incrementBy( 176 | new Sample('a', 10.1), 177 | 10, 178 | 20, 179 | [new Label('l1', 'v1'), new Label('l2', 'v2')] 180 | ); 181 | } 182 | 183 | public function testDecrementBy(): void 184 | { 185 | $this->redisClientMock 186 | ->expects(self::once()) 187 | ->method('executeCommand') 188 | ->with(['TS.DECRBY', 'a', 10.1, 'RESET', 10, 'TIMESTAMP', 1483300866234, 'RETENTION', 20, 'LABELS', 'l1', 'v1', 'l2', 'v2']) 189 | ->willReturn(1483300866234); 190 | $this->sut->decrementBy( 191 | new Sample('a', 10.1, new DateTimeImmutable('2017-01-01T20.01.06.234')), 192 | 10, 193 | 20, 194 | [new Label('l1', 'v1'), new Label('l2', 'v2')] 195 | ); 196 | } 197 | 198 | public function testCreateRule(): void 199 | { 200 | $this->redisClientMock 201 | ->expects(self::once()) 202 | ->method('executeCommand') 203 | ->with(['TS.CREATERULE', 'a', 'b', 'AGGREGATION', 'AVG', 100]) 204 | ->willReturn(1483300866234); 205 | $this->sut->createRule('a', 'b', new AggregationRule(AggregationRule::AGG_AVG, 100)); 206 | } 207 | 208 | public function testDeleteRule(): void 209 | { 210 | $this->redisClientMock 211 | ->expects(self::once()) 212 | ->method('executeCommand') 213 | ->with(['TS.DELETERULE', 'a', 'b']) 214 | ->willReturn(1483300866234); 215 | $this->sut->deleteRule('a', 'b'); 216 | } 217 | 218 | /** 219 | * @dataProvider rangeDataProvider 220 | */ 221 | public function testRange(array $params, array $expectedRedisParam): void 222 | { 223 | $this->redisClientMock 224 | ->expects(self::once()) 225 | ->method('executeCommand') 226 | ->with($expectedRedisParam) 227 | ->willReturn([[1483300866234, '9.1'], [1522923630234, '9.2']]); 228 | $returnedSamples = $this->sut->range(...$params); 229 | $expectedSamples = [ 230 | new Sample('a', 9.1, new DateTimeImmutable('2017-01-01T20.01.06.234')), 231 | new Sample('a', 9.2, new DateTimeImmutable('2018-04-05T10.20.30.234')) 232 | ]; 233 | 234 | self::assertEquals($expectedSamples, $returnedSamples); 235 | } 236 | 237 | public function rangeDataProvider(): array 238 | { 239 | return [ 240 | 'full data' => [[ 241 | 'a', 242 | new DateTimeImmutable('2017-01-01T20.01.06.234'), 243 | new DateTimeImmutable('2018-04-05T10.20.30.234'), 244 | 100, 245 | new AggregationRule(AggregationRule::AGG_LAST, 200) 246 | ], [ 247 | 'TS.RANGE', 'a', 1483300866234, 1522923630234, 'COUNT', 100, 'AGGREGATION', 'LAST', 200 248 | ]], 249 | 'missing from' => [[ 250 | 'a', 251 | null, 252 | new DateTimeImmutable('2018-04-05T10.20.30.234'), 253 | 100, 254 | new AggregationRule(AggregationRule::AGG_LAST, 200) 255 | ], [ 256 | 'TS.RANGE', 'a', '-', 1522923630234, 'COUNT', 100, 'AGGREGATION', 'LAST', 200 257 | ]], 258 | 'missing from and to' => [[ 259 | 'a', 260 | null, 261 | null, 262 | 100, 263 | new AggregationRule(AggregationRule::AGG_LAST, 200) 264 | ], [ 265 | 'TS.RANGE', 'a', '-', '+', 'COUNT', 100, 'AGGREGATION', 'LAST', 200 266 | ]], 267 | 'missing from, to and count' => [[ 268 | 'a', 269 | null, 270 | null, 271 | null, 272 | new AggregationRule(AggregationRule::AGG_LAST, 200) 273 | ], [ 274 | 'TS.RANGE', 'a', '-', '+', 'AGGREGATION', 'LAST', 200 275 | ]], 276 | 'minimal' => [['a'], ['TS.RANGE', 'a', '-', '+']] 277 | ]; 278 | } 279 | 280 | public function testInfo(): void 281 | { 282 | $this->redisClientMock 283 | ->expects(self::once()) 284 | ->method('executeCommand') 285 | ->with(['TS.INFO', 'a']) 286 | ->willReturn([ 287 | 'lastTimestamp', 288 | 1522923630234, 289 | 'retentionTime', 290 | 100, 291 | 'chunkCount', 292 | 10, 293 | 'maxSamplesPerChunk', 294 | 360, 295 | 'labels', 296 | [['a', 'a1'], ['b', 'b1']], 297 | 'sourceKey', 298 | null, 299 | 'rules', 300 | [['aa', 10, 'AVG']] 301 | ]); 302 | $returned = $this->sut->info('a'); 303 | $expected = new Metadata( 304 | new DateTimeImmutable('2018-04-05T10.20.30.234'), 305 | 100, 306 | 10, 307 | 360, 308 | [new Label('a', 'a1'), new Label('b', 'b1')], 309 | null, 310 | ['aa' => new AggregationRule(AggregationRule::AGG_AVG, 10)] 311 | ); 312 | 313 | self::assertEquals($expected, $returned); 314 | } 315 | 316 | public function testGetLastSample(): void 317 | { 318 | $this->redisClientMock 319 | ->expects(self::once()) 320 | ->method('executeCommand') 321 | ->with(['TS.GET', 'a']) 322 | ->willReturn([1483300866234, '7']); 323 | $response = $this->sut->getLastSample('a'); 324 | $expected = new Sample('a', 7.0, new DateTimeImmutable('2017-01-01T20.01.06.234')); 325 | self::assertEquals($expected, $response); 326 | } 327 | 328 | public function testGetLastSamples(): void 329 | { 330 | $this->redisClientMock 331 | ->expects(self::once()) 332 | ->method('executeCommand') 333 | ->with(['TS.MGET', 'FILTER', 'a=a1']) 334 | ->willReturn([ 335 | ['a', [['a', 'a1'], ['b', 'b1']], 1483300866234, '7'], 336 | ['b', [['a', 'a1'], ['c', 'c1']], 1522923630234, '7.1'], 337 | ]); 338 | $response = $this->sut->getLastSamples(new Filter('a', 'a1')); 339 | $expected = [ 340 | new Sample('a', 7.0, new DateTimeImmutable('2017-01-01T20.01.06.234')), 341 | new Sample('b', 7.1, new DateTimeImmutable('2018-04-05T10.20.30.234')), 342 | ]; 343 | self::assertEquals($expected, $response); 344 | } 345 | 346 | /** 347 | * @dataProvider multiRangeDataProvider 348 | */ 349 | public function testMultiRange(array $params, array $expectedRedisParams): void 350 | { 351 | $this->redisClientMock 352 | ->expects(self::once()) 353 | ->method('executeCommand') 354 | ->with($expectedRedisParams) 355 | ->willReturn([ 356 | ['a', [['a', 'a1']], [[1483300866234, '7']]], 357 | ['b', [['a', 'a1']], [[1522923630234, '7.1']]], 358 | ]); 359 | $response = $this->sut->multiRange(...$params); 360 | $expected = [ 361 | new Sample('a', 7.0, new DateTimeImmutable('2017-01-01T20.01.06.234')), 362 | new Sample('b', 7.1, new DateTimeImmutable('2018-04-05T10.20.30.234')), 363 | ]; 364 | self::assertEquals($expected, $response); 365 | } 366 | 367 | public function multiRangeDataProvider(): array 368 | { 369 | return [ 370 | 'full data' => [ 371 | [ 372 | new Filter('a', 'a1'), 373 | new DateTimeImmutable('2017-01-01T20.01.06.234'), 374 | new DateTimeImmutable('2018-04-05T10.20.30.234'), 375 | 100, 376 | new AggregationRule(AggregationRule::AGG_LAST, 200) 377 | ], 378 | ['TS.MRANGE', 1483300866234, 1522923630234, 'COUNT', 100, 'AGGREGATION', 'LAST', 200, 'FILTER', 'a=a1'] 379 | ], 380 | 'missing dates' => [ 381 | [ 382 | new Filter('a', 'a1'), 383 | null, 384 | null, 385 | 100, 386 | new AggregationRule(AggregationRule::AGG_LAST, 200) 387 | ], 388 | ['TS.MRANGE', '-', '+', 'COUNT', 100, 'AGGREGATION', 'LAST', 200, 'FILTER', 'a=a1'] 389 | ], 390 | 'missing dates and count' => [ 391 | [ 392 | new Filter('a', 'a1'), 393 | null, 394 | null, 395 | null, 396 | new AggregationRule(AggregationRule::AGG_LAST, 200) 397 | ], 398 | ['TS.MRANGE', '-', '+', 'AGGREGATION', 'LAST', 200, 'FILTER', 'a=a1'] 399 | ], 400 | 'minimal' => [ 401 | [new Filter('a', 'a1')], 402 | ['TS.MRANGE', '-', '+', 'FILTER', 'a=a1'] 403 | ] 404 | ]; 405 | } 406 | 407 | public function testGetKeysByFilter(): void 408 | { 409 | $keys = ['a', 'b']; 410 | $this->redisClientMock 411 | ->expects(self::once()) 412 | ->method('executeCommand') 413 | ->with(['TS.QUERYINDEX', 'a=a1']) 414 | ->willReturn($keys); 415 | $response = $this->sut->getKeysByFilter(new Filter('a', 'a1')); 416 | self::assertEquals($keys, $response); 417 | } 418 | } 419 | --------------------------------------------------------------------------------