Skip to content

Commit 751db3d

Browse files
committed
RediSearch 2.4.2 compatibility
1 parent 7fb7d1d commit 751db3d

18 files changed

+1158
-3
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
- (dev) Add `make clean` to remove all generated files
1515
- `LOAD ALL` option on Aggregate command (RediSearch `2.0.13`)
1616
- Profile command (RediSearch `2.2.0`) ([Issue#4])
17+
- Vector feature (RediSearch `2.4.0` / `2.4.2`)
1718

1819
### Fixed
1920

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* Copyright MacFJA
7+
*
8+
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
9+
* documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
10+
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
11+
* permit persons to whom the Software is furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
14+
* Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
17+
* WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18+
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
19+
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
20+
*/
21+
22+
namespace MacFJA\RediSearch\Query\Builder;
23+
24+
use function array_key_exists;
25+
use function count;
26+
use function gettype;
27+
use InvalidArgumentException;
28+
use function is_int;
29+
use function is_string;
30+
use function strlen;
31+
32+
class QueryElementVector implements QueryElementDecorator
33+
{
34+
private const PARAMETERS = [
35+
'EF_RUNTIME' => 'int',
36+
];
37+
38+
/** @var QueryElement */
39+
private $element;
40+
41+
/** @var int|string */
42+
private $number;
43+
44+
/** @var string */
45+
private $field;
46+
47+
/** @var string */
48+
private $blob;
49+
50+
/** @var null|string */
51+
private $scoreAlias;
52+
53+
/** @var array<string,int|string> */
54+
private $parameters = [];
55+
56+
/**
57+
* @param int|scalar|string $number
58+
*/
59+
public function __construct(QueryElement $element, $number, string $field, string $blob, ?string $scoreAlias = null)
60+
{
61+
if (!is_string($number) && !is_int($number)) {
62+
throw new InvalidArgumentException();
63+
}
64+
$this->element = $element;
65+
$this->number = $number;
66+
$this->field = $field;
67+
$this->blob = $blob;
68+
$this->scoreAlias = $scoreAlias;
69+
}
70+
71+
public function render(?callable $escaper = null): string
72+
{
73+
return sprintf(
74+
'(%s)=>[KNN %s @%s $%s%s%s]',
75+
$this->element->render($escaper),
76+
$this->getRenderedNumber(),
77+
$this->getRenderedField(),
78+
$this->getRenderedBlob(),
79+
$this->getRenderedAttributes(),
80+
$this->getRenderedAlias()
81+
);
82+
}
83+
84+
/**
85+
* @param int|string $value
86+
*
87+
* @return $this
88+
*/
89+
public function addParameter(string $name, $value): self
90+
{
91+
if (!array_key_exists($name, self::PARAMETERS)) {
92+
throw new InvalidArgumentException();
93+
}
94+
if (
95+
!(gettype($value) === self::PARAMETERS[$name])
96+
&& (is_string($value) && (strlen($value) < 2 || !(0 === strpos($value, '$'))))
97+
) {
98+
throw new InvalidArgumentException();
99+
}
100+
$this->parameters[$name] = $value;
101+
102+
return $this;
103+
}
104+
105+
public function priority(): int
106+
{
107+
return self::PRIORITY_BEFORE;
108+
}
109+
110+
public function includeSpace(): bool
111+
{
112+
return false;
113+
}
114+
115+
private function getRenderedNumber(): string
116+
{
117+
return is_string($this->number) ? ('$'.ltrim($this->number, '$')) : ((string) $this->number);
118+
}
119+
120+
private function getRenderedField(): string
121+
{
122+
return ltrim($this->field, '@');
123+
}
124+
125+
private function getRenderedBlob(): string
126+
{
127+
return ltrim($this->blob, '$');
128+
}
129+
130+
private function getRenderedAlias(): string
131+
{
132+
if (!is_string($this->scoreAlias)) {
133+
return '';
134+
}
135+
136+
return ' AS '.$this->scoreAlias;
137+
}
138+
139+
private function getRenderedAttributes(): string
140+
{
141+
if (0 === count($this->parameters)) {
142+
return '';
143+
}
144+
145+
return ' '.implode(' ', array_map(static function ($key, $value) {
146+
return $key.' '.$value;
147+
}, array_keys($this->parameters), array_values($this->parameters)));
148+
}
149+
}

src/Redis/Command/AbstractCommand.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
*/
3434
abstract class AbstractCommand implements Command
3535
{
36-
public const MAX_IMPLEMENTED_VERSION = '2.2.0';
36+
public const MAX_IMPLEMENTED_VERSION = '2.4.0';
3737
public const MIN_IMPLEMENTED_VERSION = '2.0.0';
3838

3939
/**

src/Redis/Command/AddFieldOptionTrait.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
use MacFJA\RediSearch\Redis\Command\CreateCommand\NumericFieldOption;
3131
use MacFJA\RediSearch\Redis\Command\CreateCommand\TagFieldOption;
3232
use MacFJA\RediSearch\Redis\Command\CreateCommand\TextFieldOption;
33+
use MacFJA\RediSearch\Redis\Command\CreateCommand\VectorFieldOption;
3334

3435
trait AddFieldOptionTrait
3536
{
@@ -77,6 +78,17 @@ public function addTagField(string $name, ?string $separator = null, bool $sorta
7778
);
7879
}
7980

81+
public function addVectorField(string $name, string $algorithm, string $type, int $dimension, string $distanceMetric, bool $noIndex = false): self
82+
{
83+
return $this->addField(
84+
(new VectorFieldOption())
85+
->setField($name)
86+
->setAlgorithm($algorithm)
87+
->addAttribute($type, $dimension, $distanceMetric)
88+
->setNoIndex($noIndex)
89+
);
90+
}
91+
8092
public function addJSONTextField(string $path, string $attribute, bool $noStem = false, ?float $weight = null, ?string $phonetic = null, bool $sortable = false, bool $noIndex = false): self
8193
{
8294
return $this->addJSONField(
@@ -125,6 +137,18 @@ public function addJSONTagField(string $path, string $attribute, ?string $separa
125137
);
126138
}
127139

140+
public function addJSONVectorField(string $path, string $attribute, string $algorithm, string $type, int $dimension, string $distanceMetric, bool $noIndex = false): self
141+
{
142+
return $this->addJSONField(
143+
$path,
144+
(new VectorFieldOption())
145+
->setField($attribute)
146+
->setAlgorithm($algorithm)
147+
->addAttribute($type, $dimension, $distanceMetric)
148+
->setNoIndex($noIndex)
149+
);
150+
}
151+
128152
public function addField(CreateCommandFieldOption $option): self
129153
{
130154
if (!($this instanceof AbstractCommand)) {
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* Copyright MacFJA
7+
*
8+
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
9+
* documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
10+
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
11+
* permit persons to whom the Software is furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
14+
* Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
17+
* WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18+
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
19+
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
20+
*/
21+
22+
namespace MacFJA\RediSearch\Redis\Command\CreateCommand;
23+
24+
use function in_array;
25+
use InvalidArgumentException;
26+
use function is_int;
27+
use MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption;
28+
use MacFJA\RediSearch\Redis\Command\Option\GroupedOption;
29+
use MacFJA\RediSearch\Redis\Command\Option\NamedOption;
30+
use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
31+
use MacFJA\RediSearch\Redis\Command\Option\OptionListOption;
32+
use MacFJA\RediSearch\Redis\Command\Option\WithPublicGroupedSetterTrait;
33+
use OutOfRangeException;
34+
use RuntimeException;
35+
36+
/**
37+
* @method VectorFieldOption setField(string $name)
38+
* @method VectorFieldOption setNoIndex(bool $active)
39+
* @method VectorFieldOption setAlgorithm(string $algorithm)
40+
*/
41+
class VectorFieldOption extends GroupedOption implements CreateCommandFieldOption
42+
{
43+
use BaseCreateFieldOptionTrait;
44+
use WithPublicGroupedSetterTrait;
45+
46+
public const ALGORITHM_FLAT = 'FLAT';
47+
public const ALGORITHM_HNSW = 'HNSW';
48+
public const TYPE_FLOAT32 = 'FLOAT32';
49+
public const DISTANCE_METRIC_L2 = 'L2';
50+
public const DISTANCE_METRIC_IP = 'IP';
51+
public const DISTANCE_METRIC_COSINE = 'COSINE';
52+
private const TYPES = [self::TYPE_FLOAT32];
53+
private const DISTANCE_METRICS = [self::DISTANCE_METRIC_IP, self::DISTANCE_METRIC_COSINE, self::DISTANCE_METRIC_L2];
54+
55+
public function __construct()
56+
{
57+
parent::__construct($this->getConstructorOptions('VECTOR', [
58+
'algorithm' => CustomValidatorOption::allowedValues(new NamelessOption(null, '>=2.4.0'), [self::ALGORITHM_FLAT, self::ALGORITHM_HNSW]),
59+
'count' => CustomValidatorOption::isNumeric(new NamelessOption(null, '>=2.4.0')),
60+
'attributes' => new OptionListOption('>=2.4.0'),
61+
]), ['field', 'algorithm', 'count', 'type'], ['type'], '>=2.4.0');
62+
}
63+
64+
public function getFieldName(): string
65+
{
66+
return $this->getDataOfOption('field');
67+
}
68+
69+
public function addFlatAttribute(string $type, int $dimension, string $distanceMetric, ?int $initialCapacity = null, ?int $blockSize = null): self
70+
{
71+
if (!(self::ALGORITHM_FLAT === $this->getDataOfOption('algorithm'))) {
72+
throw new RuntimeException();
73+
}
74+
75+
return $this->addAttribute($type, $dimension, $distanceMetric, $initialCapacity, $blockSize);
76+
}
77+
78+
public function addHnswAttribute(string $type, int $dimension, string $distanceMetric, ?int $initialCapacity = null, ?int $maxEdge = null, ?int $efConstruction = null, ?int $efRuntime = null): self
79+
{
80+
if (!(self::ALGORITHM_HNSW === $this->getDataOfOption('algorithm'))) {
81+
throw new RuntimeException();
82+
}
83+
84+
return $this->addAttribute($type, $dimension, $distanceMetric, $initialCapacity, null, $maxEdge, $efConstruction, $efRuntime);
85+
}
86+
87+
public function addAttribute(string $type, int $dimension, string $distanceMetric, ?int $initialCapacity = null, ?int $blockSize = null, ?int $maxEdge = null, ?int $efConstruction = null, ?int $efRuntime = null): self
88+
{
89+
$algorithm = $this->getDataOfOption('algorithm');
90+
91+
if (self::ALGORITHM_FLAT === $algorithm) {
92+
$efRuntime = null;
93+
$efConstruction = null;
94+
$maxEdge = null;
95+
}
96+
if (self::ALGORITHM_HNSW === $algorithm) {
97+
$blockSize = null;
98+
}
99+
100+
$this->validateArguments($type, $dimension, $distanceMetric, $initialCapacity, $blockSize, $maxEdge, $efConstruction, $efRuntime);
101+
102+
$attribute = new GroupedOption([
103+
'type' => new NamedOption('TYPE', $type, '>=2.4.0'),
104+
'dim' => new NamedOption('DIM', $dimension, '>=2.4.0'),
105+
'distance_metric' => new NamedOption('DISTANCE_METRIC', $distanceMetric, '>=2.4.0'),
106+
'initial_cap' => new NamedOption('INITIAL_CAP', $initialCapacity, '>=2.4.0'),
107+
'block_size' => new NamedOption('BLOCK_SIZE', $blockSize, '>=2.4.0'),
108+
'm' => new NamedOption('M', $maxEdge, '>=2.4.0'),
109+
'ef_construction' => new NamedOption('EF_CONSTRUCTION', $efConstruction, '>=2.4.0'),
110+
'ef_runtime' => new NamedOption('EF_RUNTIME', $efRuntime, '>=2.4.0'),
111+
], ['type', 'dim', 'distance_metric'], ['type', 'dim', 'distance_metric', 'initial_cap', 'block_size', 'm', 'ef_construction', 'ef_runtime']);
112+
113+
/** @var array<GroupedOption> $options */
114+
$options = $this->getDataOfOption('attributes');
115+
$options[] = $attribute;
116+
$this->setDataOfOption('attributes', ...$options);
117+
118+
$countAddition = 6
119+
+ (is_int($initialCapacity) ? 2 : 0)
120+
+ (is_int($blockSize) ? 2 : 0)
121+
+ (is_int($maxEdge) ? 2 : 0)
122+
+ (is_int($efConstruction) ? 2 : 0)
123+
+ (is_int($efRuntime) ? 2 : 0)
124+
;
125+
126+
/** @var int $count */
127+
$count = $this->getDataOfOption('count') ?? 0;
128+
$count += $countAddition;
129+
$this->setDataOfOption('count', $count);
130+
131+
return $this;
132+
}
133+
134+
/**
135+
* @return string[]
136+
*/
137+
protected function publicSetter(): array
138+
{
139+
return ['field', 'algorithm', 'no_index'];
140+
}
141+
142+
/**
143+
* @SuppressWarnings(PHPMD.UnusedFormalParameter) -- Dynamic variable name
144+
*/
145+
private function validateArguments(string $type, int $dimension, string $distanceMetric, ?int $initialCapacity = null, ?int $blockSize = null, ?int $maxEdge = null, ?int $efConstruction = null, ?int $efRuntime = null): void
146+
{
147+
$atLeastOne = ['dimension', 'initialCapacity', 'blockSize', 'efConstruction', 'efRuntime'];
148+
149+
if (!in_array($type, self::TYPES, true)) {
150+
throw new InvalidArgumentException(sprintf(
151+
'"%s" is not a known type (expect one of %s)',
152+
$type,
153+
implode(', ', self::TYPES)
154+
));
155+
}
156+
157+
foreach ($atLeastOne as $varName) {
158+
if (is_int(${$varName}) && ${$varName} < 1) {
159+
throw new OutOfRangeException(sprintf('$%s must be greater than 0', $varName));
160+
}
161+
}
162+
163+
if (!in_array($distanceMetric, self::DISTANCE_METRICS, true)) {
164+
throw new InvalidArgumentException(sprintf(
165+
'"%s" is not a known distance metric (expect one of %s)',
166+
$distanceMetric,
167+
implode(', ', self::DISTANCE_METRICS)
168+
));
169+
}
170+
171+
if (is_int($maxEdge) && $maxEdge < 0) {
172+
throw new OutOfRangeException('$maxEdge must be a positive number');
173+
}
174+
}
175+
}

0 commit comments

Comments
 (0)