Skip to content

Commit 8e0f9b5

Browse files
authored
Added Search index object (#2)
* Added Search index object * Updated CI with new PHP version * Removed verbose option * Fixed test cases
1 parent 92d983e commit 8e0f9b5

File tree

14 files changed

+1029
-4
lines changed

14 files changed

+1029
-4
lines changed

.github/workflows/tests.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ jobs:
2323
- name: Setup PHP with Composer and extensions
2424
uses: shivammathur/setup-php@v2
2525
with:
26-
php-version: '8.0'
26+
php-version: '8.1'
2727
coverage: xdebug
2828

2929
- name: Install Composer dependencies
@@ -32,7 +32,7 @@ jobs:
3232
dependency-versions: highest
3333

3434
- name: Run unit tests
35-
run: vendor/bin/phpunit --testsuite Unit --verbose --coverage-clover build/logs/clover.xml --coverage-filter ./src
35+
run: vendor/bin/phpunit --testsuite Unit --coverage-clover build/logs/clover.xml --coverage-filter ./src
3636

3737
- name: Upload codecov coverage
3838
uses: codecov/codecov-action@v3

composer.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
"type": "library",
55
"license": "MIT",
66
"require": {
7-
"php" : "^8.0",
7+
"php" : "^8.1",
88
"guzzlehttp/guzzle": "*"
99
},
1010
"require-dev": {
11-
"phpunit/phpunit": "*"
11+
"phpunit/phpunit": "*",
12+
"predis/predis": "^2.2.0",
13+
"mockery/mockery": "^1.6"
1214
},
1315
"autoload": {
1416
"psr-4": {

src/Enum/SearchField.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
namespace Vladvildanov\PredisVl\Enum;
4+
5+
use Predis\Command\Argument\Search\SchemaFields\NumericField;
6+
use Predis\Command\Argument\Search\SchemaFields\TagField;
7+
use Predis\Command\Argument\Search\SchemaFields\TextField;
8+
use Predis\Command\Argument\Search\SchemaFields\VectorField;
9+
use Vladvildanov\PredisVl\Enum\Traits\EnumNames;
10+
11+
enum SearchField
12+
{
13+
use EnumNames;
14+
15+
case tag;
16+
case text;
17+
case numeric;
18+
case vector;
19+
20+
/**
21+
* Returns field class corresponding to given case.
22+
*
23+
* @return string
24+
*/
25+
public function fieldMapping(): string
26+
{
27+
return match ($this) {
28+
self::tag => TagField::class,
29+
self::text => TextField::class,
30+
self::numeric => NumericField::class,
31+
self::vector => VectorField::class,
32+
};
33+
}
34+
}

src/Enum/StorageType.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
namespace Vladvildanov\PredisVl\Enum;
4+
5+
enum StorageType: string
6+
{
7+
case hash = 'HASH';
8+
case json = 'JSON';
9+
}

src/Enum/Traits/EnumNames.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
namespace Vladvildanov\PredisVl\Enum\Traits;
4+
5+
trait EnumNames
6+
{
7+
/**
8+
* @return array
9+
*/
10+
public static function names(): array
11+
{
12+
return array_map(static fn($enum) => $enum->name, static::cases());
13+
}
14+
15+
/**
16+
* @param string $name
17+
* @return self
18+
*/
19+
public static function fromName(string $name): self
20+
{
21+
return constant("self::$name");
22+
}
23+
}

src/Factory.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
namespace Vladvildanov\PredisVl;
4+
5+
use Predis\Command\Argument\Search\CreateArguments;
6+
7+
/**
8+
* Simple factory for general purpose objects creation.
9+
*/
10+
class Factory implements FactoryInterface
11+
{
12+
/**
13+
* @inheritDoc
14+
*/
15+
public function createIndexBuilder(): CreateArguments
16+
{
17+
return new CreateArguments();
18+
}
19+
}

src/FactoryInterface.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
namespace Vladvildanov\PredisVl;
4+
5+
use Predis\Command\Argument\Search\CreateArguments;
6+
7+
interface FactoryInterface
8+
{
9+
/**
10+
* Creates builder object for index command arguments.
11+
*
12+
* @return CreateArguments
13+
*/
14+
public function createIndexBuilder(): CreateArguments;
15+
}

src/Index/IndexInterface.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
namespace Vladvildanov\PredisVl\Index;
4+
5+
interface IndexInterface
6+
{
7+
/**
8+
* Returns schema configuration for index.
9+
*
10+
* @return array
11+
*/
12+
public function getSchema(): array;
13+
14+
/**
15+
* Creates index entity according to given schema.
16+
*
17+
* @param bool $isOverwrite
18+
* @return bool
19+
*/
20+
public function create(bool $isOverwrite): bool;
21+
22+
/**
23+
* Loads data into current index.
24+
*
25+
* @param string $key
26+
* @param mixed $values
27+
* @return bool
28+
*/
29+
public function load(string $key, mixed $values): bool;
30+
31+
/**
32+
* Fetch one of the previously loaded objects.
33+
*
34+
* @param string $id
35+
* @return mixed
36+
*/
37+
public function fetch(string $id): mixed;
38+
}

src/Index/SearchIndex.php

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
<?php
2+
3+
namespace Vladvildanov\PredisVl\Index;
4+
5+
use Exception;
6+
use Predis\Client;
7+
use Predis\Command\Argument\Search\SchemaFields\VectorField;
8+
use Predis\Response\ServerException;
9+
use Vladvildanov\PredisVl\Enum\SearchField;
10+
use Vladvildanov\PredisVl\Enum\StorageType;
11+
use Vladvildanov\PredisVl\Factory;
12+
use Vladvildanov\PredisVl\FactoryInterface;
13+
14+
class SearchIndex implements IndexInterface
15+
{
16+
/**
17+
* @var array
18+
*/
19+
protected array $schema;
20+
21+
/**
22+
* @var FactoryInterface
23+
*/
24+
protected FactoryInterface $factory;
25+
26+
public function __construct(protected Client $client, array $schema, FactoryInterface $factory = null)
27+
{
28+
$this->validateSchema($schema);
29+
$this->factory = $factory ?? new Factory();
30+
}
31+
32+
/**
33+
* @inheritDoc
34+
*/
35+
public function getSchema(): array
36+
{
37+
return $this->schema;
38+
}
39+
40+
/**
41+
* @inheritDoc
42+
*/
43+
public function create(bool $isOverwrite = false): bool
44+
{
45+
if ($isOverwrite) {
46+
try {
47+
$this->client->ftdropindex($this->schema['index']['name']);
48+
} catch (ServerException $exception) {
49+
// Do nothing on exception, there's no way to check if index already exists.
50+
}
51+
}
52+
53+
$createArguments = $this->factory->createIndexBuilder();
54+
55+
if (array_key_exists('storage_type', $this->schema['index'])) {
56+
$createArguments = $createArguments->on(
57+
StorageType::from(strtoupper($this->schema['index']['storage_type']))->value
58+
);
59+
} else {
60+
$createArguments = $createArguments->on();
61+
}
62+
63+
if (array_key_exists('prefix', $this->schema['index'])) {
64+
$createArguments = $createArguments->prefix([$this->schema['index']['prefix']]);
65+
}
66+
67+
$schema = [];
68+
69+
foreach ($this->schema['fields'] as $fieldName => $fieldData) {
70+
$fieldEnum = SearchField::fromName($fieldData['type']);
71+
72+
if (array_key_exists('alias', $fieldData)) {
73+
$alias = $fieldData['alias'];
74+
} else {
75+
$alias = '';
76+
}
77+
78+
if ($fieldEnum === SearchField::vector) {
79+
$schema[] = $this->createVectorField($fieldName, $alias, $fieldData);
80+
} else {
81+
$fieldClass = $fieldEnum->fieldMapping();
82+
$schema[] = new $fieldClass($fieldName, $alias);
83+
}
84+
}
85+
86+
$response = $this->client->ftcreate($this->schema['index']['name'], $schema, $createArguments);
87+
88+
return $response == 'OK';
89+
}
90+
91+
/**
92+
* Loads data into current index.
93+
* Accepts array for hashes and string for JSON type.
94+
*/
95+
public function load(string $key, mixed $values): bool
96+
{
97+
if (is_string($values)) {
98+
$response = $this->client->jsonset($key, '$', $values);
99+
} elseif (is_array($values)) {
100+
$response = $this->client->hmset($key, $values);
101+
}
102+
103+
return $response == 'OK';
104+
}
105+
106+
/**
107+
* @inheritDoc
108+
*/
109+
public function fetch(string $id): mixed
110+
{
111+
$key = (array_key_exists('prefix', $this->schema['index']))
112+
? $this->schema['index']['prefix'] . $id
113+
: $id;
114+
115+
if (
116+
array_key_exists('storage_type', $this->schema['index'])
117+
&& StorageType::from(strtoupper($this->schema['index']['storage_type'])) === StorageType::json
118+
) {
119+
return $this->client->jsonget($key);
120+
}
121+
122+
return $this->client->hgetall($key);
123+
}
124+
125+
/**
126+
* Validates schema array.
127+
*
128+
* @param array $schema
129+
* @return void
130+
* @throws Exception
131+
*/
132+
protected function validateSchema(array $schema): void
133+
{
134+
if (!array_key_exists('index', $schema)) {
135+
throw new Exception("Schema should contains 'index' entry.");
136+
}
137+
138+
if (!array_key_exists('name', $schema['index'])) {
139+
throw new Exception("Index name is required.");
140+
}
141+
142+
if (
143+
array_key_exists('storage_type', $schema['index']) &&
144+
null === StorageType::tryFrom(strtoupper($schema['index']['storage_type']))
145+
) {
146+
throw new Exception('Invalid storage type value.');
147+
}
148+
149+
if (!array_key_exists('fields', $schema)) {
150+
throw new Exception('Schema should contains at least one field.');
151+
}
152+
153+
foreach ($schema['fields'] as $fieldData) {
154+
if (!array_key_exists('type', $fieldData)) {
155+
throw new Exception('Field type should be specified for each field.');
156+
}
157+
158+
if (!in_array($fieldData['type'], SearchField::names(), true)) {
159+
throw new Exception('Invalid field type.');
160+
}
161+
}
162+
163+
$this->schema = $schema;
164+
}
165+
166+
/**
167+
* Creates a Vector field from given configuration.
168+
*
169+
* @param string $fieldName
170+
* @param string $alias
171+
* @param array $fieldData
172+
* @return VectorField
173+
* @throws Exception
174+
*/
175+
protected function createVectorField(string $fieldName, string $alias, array $fieldData): VectorField
176+
{
177+
$mandatoryKeys = ['datatype', 'dims', 'distance_metric', 'algorithm'];
178+
$intersections = array_intersect($mandatoryKeys, array_keys($fieldData));
179+
180+
if (count($intersections) !== count($mandatoryKeys)) {
181+
throw new Exception("datatype, dims, distance_metric and algorithm are mandatory parameters for vector field.");
182+
}
183+
184+
return new VectorField(
185+
$fieldName,
186+
strtoupper($fieldData['algorithm']),
187+
[
188+
'TYPE', strtoupper($fieldData['datatype']),
189+
'DIM', strtoupper($fieldData['dims']),
190+
'DISTANCE_METRIC', strtoupper($fieldData['distance_metric'])
191+
],
192+
$alias
193+
);
194+
}
195+
}

src/VectorHelper.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
namespace Vladvildanov\PredisVl;
4+
5+
class VectorHelper
6+
{
7+
/**
8+
* Convert vector represented as an array of floats into bytes string representation.
9+
*
10+
* @param float[] $vector
11+
* @return string
12+
*/
13+
public static function toBytes(array $vector): string
14+
{
15+
return pack('f*', ...$vector);
16+
}
17+
}

0 commit comments

Comments
 (0)