diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aeacd8e..8e098019 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Changes + +* PHP: Add FT.* (Vector Search) commands: ftCreate, ftDropIndex, ftList, ftSearch, ftAggregate, ftInfo, ftAliasAdd, ftAliasDel, ftAliasUpdate, ftAliasList for standalone and cluster clients + ## 1.0.0 ### Changes (1.0.0) diff --git a/config.m4 b/config.m4 index d701c3cd..858d236d 100644 --- a/config.m4 +++ b/config.m4 @@ -97,7 +97,7 @@ if test "$PHP_VALKEY_GLIDE" != "no"; then esac PHP_NEW_EXTENSION(valkey_glide, - valkey_glide.c valkey_glide_cluster.c valkey_glide_pubsub_common.c valkey_glide_pubsub_introspection.c cluster_scan_cursor.c command_response.c logger.c valkey_glide_otel.c valkey_glide_commands.c valkey_glide_commands_2.c valkey_glide_commands_3.c valkey_glide_core_commands.c valkey_glide_core_common.c valkey_glide_expire_commands.c valkey_glide_geo_commands.c valkey_glide_geo_common.c valkey_glide_hash_common.c valkey_glide_list_common.c valkey_glide_s_common.c valkey_glide_str_commands.c valkey_glide_x_commands.c valkey_glide_x_common.c valkey_glide_z.c valkey_glide_z_common.c valkey_z_php_methods.c valkey_glide_script_commands.c valkey_glide_function_commands.c src/command_request.pb-c.c src/connection_request.pb-c.c src/response.pb-c.c src/client_constructor_mock.c, + valkey_glide.c valkey_glide_cluster.c valkey_glide_pubsub_common.c valkey_glide_pubsub_introspection.c cluster_scan_cursor.c command_response.c logger.c valkey_glide_otel.c valkey_glide_commands.c valkey_glide_commands_2.c valkey_glide_commands_3.c valkey_glide_core_commands.c valkey_glide_core_common.c valkey_glide_expire_commands.c valkey_glide_ft_commands.c valkey_glide_ft_common.c valkey_glide_geo_commands.c valkey_glide_geo_common.c valkey_glide_hash_common.c valkey_glide_list_common.c valkey_glide_s_common.c valkey_glide_str_commands.c valkey_glide_x_commands.c valkey_glide_x_common.c valkey_glide_z.c valkey_glide_z_common.c valkey_z_php_methods.c valkey_glide_script_commands.c valkey_glide_function_commands.c src/command_request.pb-c.c src/connection_request.pb-c.c src/response.pb-c.c src/client_constructor_mock.c, $ext_shared,, $VALKEY_GLIDE_SHARED_LIBADD) dnl Add FFI library only for macOS (keep Mac working as before) diff --git a/package.xml b/package.xml index 21979135..416f6554 100644 --- a/package.xml +++ b/package.xml @@ -68,6 +68,9 @@ Requirements: + + + diff --git a/tests/TestValkeyGlide.php b/tests/TestValkeyGlide.php index c47845c4..21782010 100644 --- a/tests/TestValkeyGlide.php +++ b/tests/TestValkeyGlide.php @@ -90,6 +90,7 @@ require_once __DIR__ . "/ValkeyGlideBatchTest.php"; require_once __DIR__ . "/ValkeyGlideClusterBatchTest.php"; require_once __DIR__ . "/UpdateConnectionPasswordTest.php"; +require_once __DIR__ . "/ValkeyGlideValkeySearchTest.php"; function getClassArray($classes) { @@ -118,7 +119,8 @@ function getTestClass($class) 'valkeyglideclusterfeatures' => 'ValkeyGlideClusterFeaturesTest', 'valkeyglideclientbatch' => 'ValkeyGlideBatchTest', 'valkeyglideclusterbatch' => 'ValkeyGlideClusterBatchTest', - 'updateconnectionpassword' => 'UpdateConnectionPasswordTest' + 'updateconnectionpassword' => 'UpdateConnectionPasswordTest', + 'ValkeyGlideValkeySearch' => 'ValkeyGlideValkeySearchTest' ]; /* Return early if the class is one of our built-in ones */ diff --git a/tests/ValkeyGlideValkeySearchTest.php b/tests/ValkeyGlideValkeySearchTest.php new file mode 100644 index 00000000..1d214a91 --- /dev/null +++ b/tests/ValkeyGlideValkeySearchTest.php @@ -0,0 +1,684 @@ +". +* +* THIS SOFTWARE IS PROVIDED BY THE PHP DEVELOPMENT TEAM ``AS IS'' AND +* ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +* THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +* PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE PHP +* DEVELOPMENT TEAM OR ITS CONTRIBUTORS BE LIABLE FOR ANY DIRECT, +* INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +* HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED +* OF THE POSSIBILITY OF SUCH DAMAGE. +* +* -------------------------------------------------------------------- +* +* This software consists of voluntary contributions made by many +* individuals on behalf of the PHP Group. +* +* The PHP Group can be contacted via Email at group@php.net. +* +* For more information on the PHP Group and the PHP project, +* please see . +* +* PHP includes the Zend Engine, freely available at +* . +*/ + +require_once __DIR__ . "/ValkeyGlideBaseTest.php"; + +/** + * Integration tests for FT.* (Valkey Search) commands. + * Requires a Valkey server with the Search module loaded. + */ +class ValkeyGlideValkeySearchTest extends ValkeyGlideBaseTest +{ + private ?ValkeyGlide $client = null; + + public function __construct($host, $port, $auth, $tls) + { + parent::__construct($host, $port, $auth, $tls); + } + + protected function getClient(): ValkeyGlide + { + if ($this->client === null) { + $this->client = $this->newInstance(); + } + return $this->client; + } + + protected function skipIfModuleNotAvailable(): void + { + try { + $this->getClient()->ftList(); + } catch (\Throwable $e) { + $msg = $e->getMessage(); + if ( + stripos($msg, 'unknown command') !== false || + stripos($msg, 'ERR unknown') !== false || + stripos($msg, 'module') !== false + ) { + throw new TestSkippedException("Valkey Search module not available."); + } + } + } + + /* ── FT.CREATE ──────────────────────────────────────────────────── */ + + public function testFtCreateSimpleHnswVector() + { + $this->skipIfModuleNotAvailable(); + $idx = uniqid('phptest_'); + $result = $this->getClient()->ftCreate($idx, [ + ['name' => 'vec', 'type' => 'VECTOR', 'algorithm' => 'HNSW', + 'dim' => 2, 'metric' => 'L2'], + ]); + $this->assertTrue($result); + $this->getClient()->ftDropIndex($idx); + } + + public function testFtCreateJsonFlatVector() + { + $this->skipIfModuleNotAvailable(); + $idx = uniqid('phptest_'); + $result = $this->getClient()->ftCreate($idx, [ + ['name' => '$.vec', 'alias' => 'VEC', 'type' => 'VECTOR', + 'algorithm' => 'FLAT', 'dim' => 6, 'metric' => 'L2'], + ], ['ON' => 'JSON', 'PREFIX' => ['json:']]); + $this->assertTrue($result); + $this->getClient()->ftDropIndex($idx); + } + + public function testFtCreateHnswWithExtraParams() + { + $this->skipIfModuleNotAvailable(); + $idx = uniqid('phptest_'); + $result = $this->getClient()->ftCreate($idx, [ + ['name' => 'doc_embedding', 'type' => 'VECTOR', 'algorithm' => 'HNSW', + 'dim' => 1536, 'metric' => 'COSINE', + 'initial_cap' => 1000, 'm' => 40, + 'ef_construction' => 250, 'ef_runtime' => 40], + ], ['ON' => 'HASH', 'PREFIX' => ['docs:']]); + $this->assertTrue($result); + $this->getClient()->ftDropIndex($idx); + } + + public function testFtCreateMultipleFields() + { + $this->skipIfModuleNotAvailable(); + $idx = uniqid('phptest_'); + $result = $this->getClient()->ftCreate($idx, [ + ['name' => 'title', 'type' => 'TEXT'], + ['name' => 'published_at', 'type' => 'NUMERIC'], + ['name' => 'category', 'type' => 'TAG'], + ], ['ON' => 'HASH', 'PREFIX' => ['blog:post:']]); + $this->assertTrue($result); + $this->getClient()->ftDropIndex($idx); + } + + public function testFtCreateDuplicateThrows() + { + $this->skipIfModuleNotAvailable(); + $idx = uniqid('phptest_'); + $this->getClient()->ftCreate($idx, [ + ['name' => 'title', 'type' => 'TEXT'], + ]); + $threw = false; + try { + $this->getClient()->ftCreate($idx, [ + ['name' => 'title', 'type' => 'TEXT'], + ]); + } catch (\Throwable $e) { + $threw = true; + } + $this->assertTrue($threw); + $this->getClient()->ftDropIndex($idx); + } + + /* ── FT.DROPINDEX + FT._LIST ───────────────────────────────────── */ + + public function testFtDropIndexAndList() + { + $this->skipIfModuleNotAvailable(); + $idx = uniqid('phptest_'); + $this->getClient()->ftCreate($idx, [ + ['name' => 'vec', 'type' => 'VECTOR', 'algorithm' => 'HNSW', + 'dim' => 2, 'metric' => 'L2'], + ]); + $before = $this->getClient()->ftList(); + $this->assertTrue(in_array($idx, $before, true)); + $this->getClient()->ftDropIndex($idx); + $after = $this->getClient()->ftList(); + $this->assertFalse(in_array($idx, $after, true)); + } + + public function testFtDropIndexNonExistentThrows() + { + $this->skipIfModuleNotAvailable(); + $threw = false; + try { + $this->getClient()->ftDropIndex('nonexistent_' . uniqid()); + } catch (\Throwable $e) { + $threw = true; + } + $this->assertTrue($threw); + } + + /* ── FT.SEARCH ─────────────────────────────────────────────────── */ + + public function testFtSearchBasic() + { + $this->skipIfModuleNotAvailable(); + $prefix = '{' . uniqid() . '}:'; + $idx = $prefix . 'index'; + $this->getClient()->ftCreate($idx, [ + ['name' => 'vec', 'alias' => 'VEC', 'type' => 'VECTOR', + 'algorithm' => 'HNSW', 'dim' => 2, 'metric' => 'L2'], + ], ['ON' => 'HASH', 'PREFIX' => [$prefix]]); + + $vec0 = str_repeat("\x00", 8); + $vec1 = "\x00\x00\x00\x00\x00\x00\x80\xBF"; + $this->getClient()->hSet($prefix . '0', 'vec', $vec0); + $this->getClient()->hSet($prefix . '1', 'vec', $vec1); + usleep(1500000); + + $result = $this->getClient()->ftSearch( + $idx, + '*=>[KNN 2 @VEC $query_vec]', + [ + 'PARAMS' => ['query_vec' => $vec0], + 'RETURN' => ['vec'], + ] + ); + $this->assertIsArray($result); + + $this->getClient()->ftDropIndex($idx); + } + + public function testFtSearchNonExistentThrows() + { + $this->skipIfModuleNotAvailable(); + $threw = false; + try { + $this->getClient()->ftSearch('nonexistent_' . uniqid(), '*'); + } catch (\Throwable $e) { + $threw = true; + } + $this->assertTrue($threw); + } + + /* ── FT.INFO ───────────────────────────────────────────────────── */ + + public function testFtInfoExistingIndex() + { + $this->skipIfModuleNotAvailable(); + $idx = uniqid('phptest_'); + $this->getClient()->ftCreate($idx, [ + ['name' => '$.vec', 'alias' => 'VEC', 'type' => 'VECTOR', + 'algorithm' => 'HNSW', 'dim' => 42, 'metric' => 'COSINE'], + ['name' => '$.name', 'type' => 'TEXT'], + ], ['ON' => 'JSON', 'PREFIX' => ['123']]); + $info = $this->getClient()->ftInfo($idx); + $this->assertIsArray($info); + $this->getClient()->ftDropIndex($idx); + } + + public function testFtInfoNonExistentThrows() + { + $this->skipIfModuleNotAvailable(); + $threw = false; + try { + $this->getClient()->ftInfo('nonexistent_' . uniqid()); + } catch (\Throwable $e) { + $threw = true; + } + $this->assertTrue($threw); + } + + /* ── FT.ALIAS* ─────────────────────────────────────────────────── */ + + public function testFtAliasOperations() + { + $this->skipIfModuleNotAvailable(); + $alias1 = 'alias1-' . uniqid(); + $alias2 = 'alias2-' . uniqid(); + $idx = uniqid() . '-index'; + $this->getClient()->ftCreate($idx, [ + ['name' => 'vec', 'type' => 'VECTOR', 'algorithm' => 'FLAT', + 'dim' => 2, 'metric' => 'L2'], + ]); + + $r = $this->getClient()->ftAliasAdd($alias1, $idx); + $this->assertTrue($r); + + $aliases = $this->getClient()->ftAliasList(); + $this->assertIsArray($aliases); + $this->assertEquals($idx, $aliases[$alias1] ?? null); + + // Duplicate alias -> error + $threw = false; + try { + $this->getClient()->ftAliasAdd($alias1, $idx); + } catch (\Throwable $e) { + $threw = true; + } + $this->assertTrue($threw); + + $r = $this->getClient()->ftAliasUpdate($alias2, $idx); + $this->assertTrue($r); + + $aliases = $this->getClient()->ftAliasList(); + $this->assertEquals($idx, $aliases[$alias1] ?? null); + $this->assertEquals($idx, $aliases[$alias2] ?? null); + + $this->getClient()->ftAliasDel($alias2); + $this->getClient()->ftAliasDel($alias1); + + // Delete non-existent -> error + $threw = false; + try { + $this->getClient()->ftAliasDel($alias2); + } catch (\Throwable $e) { + $threw = true; + } + $this->assertTrue($threw); + + $this->getClient()->ftDropIndex($idx); + } + + /* ── FT.AGGREGATE (stays flat - pipeline ordering) ─────────────── */ + + public function testFtAggregateBicycles() + { + $this->skipIfModuleNotAvailable(); + $prefix = '{bicycles' . uniqid() . '}:'; + $idx = $prefix . 'idx'; + $this->getClient()->ftCreate($idx, [ + ['name' => 'model', 'type' => 'TEXT'], + ['name' => 'price', 'type' => 'NUMERIC'], + ['name' => 'condition', 'type' => 'TAG', 'separator' => ','], + ], ['ON' => 'HASH', 'PREFIX' => [$prefix]]); + + $conditions = ['new', 'used', 'used', 'used', 'used', 'new', 'new', 'new', 'new', 'refurbished']; + for ($i = 0; $i < count($conditions); $i++) { + $this->getClient()->hSet($prefix . $i, ['model' => 'bike' . $i, 'price' => (string)(100 + $i * 10), 'condition' => $conditions[$i]]); + } + usleep(1500000); + + $result = $this->getClient()->ftAggregate( + $idx, + '*', + ['LOAD', '1', '__key', 'GROUPBY', '1', '@condition', 'REDUCE', 'COUNT', '0', 'AS', 'bicycles'] + ); + $this->assertIsArray($result); + + $this->getClient()->ftDropIndex($idx); + } + + /* ── FT.CREATE index-level options ─────────────────────────────── */ + + public function testFtCreateWithScoreLanguageSkipInitialScan() + { + $this->skipIfModuleNotAvailable(); + $prefix = '{' . uniqid() . '}:'; + $idx = $prefix . 'idx'; + $result = $this->getClient()->ftCreate($idx, [ + ['name' => 'title', 'type' => 'TEXT'], + ], [ + 'ON' => 'HASH', + 'PREFIX' => [$prefix], + 'SCORE' => 1, + 'LANGUAGE' => 'english', + 'SKIPINITIALSCAN' => true, + ]); + $this->assertTrue($result); + $this->getClient()->ftDropIndex($idx); + } + + public function testFtCreateNoStopWords() + { + $this->skipIfModuleNotAvailable(); + $prefix = '{' . uniqid() . '}:'; + $idx = $prefix . 'idx'; + $this->getClient()->ftCreate($idx, [ + ['name' => 'title', 'type' => 'TEXT'], + ], [ + 'ON' => 'HASH', + 'PREFIX' => [$prefix], + 'NOSTOPWORDS' => true, + ]); + $this->getClient()->hSet($prefix . '1', 'title', 'the quick fox'); + usleep(1500000); + $result = $this->getClient()->ftSearch($idx, 'the'); + $this->assertIsArray($result); + $this->getClient()->ftDropIndex($idx); + } + + public function testFtCreateMinStemSize() + { + $this->skipIfModuleNotAvailable(); + $prefix = '{' . uniqid() . '}:'; + $idx = $prefix . 'idx'; + $this->getClient()->ftCreate($idx, [ + ['name' => 'title', 'type' => 'TEXT'], + ], [ + 'ON' => 'HASH', + 'PREFIX' => [$prefix], + 'MINSTEMSIZE' => 6, + ]); + $this->getClient()->hSet($prefix . '1', 'title', 'running'); + $this->getClient()->hSet($prefix . '2', 'title', 'plays'); + usleep(1500000); + $r = $this->getClient()->ftSearch($idx, 'run'); + $this->assertIsArray($r); + $r = $this->getClient()->ftSearch($idx, 'play'); + $this->assertIsArray($r); + $this->getClient()->ftDropIndex($idx); + } + + public function testFtCreateNoOffsets() + { + $this->skipIfModuleNotAvailable(); + $prefix = '{' . uniqid() . '}:'; + $idx = $prefix . 'idx'; + $this->getClient()->ftCreate($idx, [ + ['name' => 'title', 'type' => 'TEXT'], + ], [ + 'ON' => 'HASH', + 'PREFIX' => [$prefix], + 'NOOFFSETS' => true, + ]); + $this->getClient()->hSet($prefix . '1', 'title', 'hello'); + usleep(1500000); + $r = $this->getClient()->ftSearch($idx, 'hello'); + $this->assertIsArray($r); + // SLOP requires offsets - should fail + $threw = false; + try { + $this->getClient()->ftSearch($idx, 'hello', ['SLOP' => 1]); + } catch (\Throwable $e) { + $threw = true; + } + $this->assertTrue($threw); + $this->getClient()->ftDropIndex($idx); + } + + /* ── FT.CREATE field options ───────────────────────────────────── */ + + public function testFtCreateFieldOptionsNoStemSortableWeight() + { + $this->skipIfModuleNotAvailable(); + $prefix = '{' . uniqid() . '}:'; + $idx = $prefix . 'idx'; + $this->getClient()->ftCreate($idx, [ + ['name' => 'title', 'type' => 'TEXT', 'nostem' => true, 'weight' => 1, 'sortable' => true], + ['name' => 'price', 'type' => 'NUMERIC', 'sortable' => true], + ['name' => 'tag', 'type' => 'TAG', 'separator' => ',', 'sortable' => true], + ], ['ON' => 'HASH', 'PREFIX' => [$prefix]]); + $this->getClient()->hSet($prefix . '1', ['title' => 'hello', 'price' => '10', 'tag' => 'a,b']); + usleep(1500000); + + $r = $this->getClient()->ftSearch($idx, '@price:[1 +inf]', [ + 'SORTBY' => ['price', 'ASC'], + ]); + $this->assertIsArray($r); + + $r = $this->getClient()->ftSearch($idx, 'hello'); + $this->assertIsArray($r); + $r = $this->getClient()->ftSearch($idx, 'hellos'); + $this->assertIsArray($r); + + $this->getClient()->ftDropIndex($idx); + } + + public function testFtCreateWithSuffixTrie() + { + $this->skipIfModuleNotAvailable(); + $prefix = '{' . uniqid() . '}:'; + $idx = $prefix . 'idx'; + $this->getClient()->ftCreate($idx, [ + ['name' => 'title', 'type' => 'TEXT', 'withsuffixtrie' => true], + ], ['ON' => 'HASH', 'PREFIX' => [$prefix]]); + $this->getClient()->hSet($prefix . '1', 'title', 'hello world'); + usleep(1500000); + $r = $this->getClient()->ftSearch($idx, '*orld'); + $this->assertIsArray($r); + $this->getClient()->ftDropIndex($idx); + } + + public function testFtCreateNoSuffixTrie() + { + $this->skipIfModuleNotAvailable(); + $prefix = '{' . uniqid() . '}:'; + $idx = $prefix . 'idx'; + $this->getClient()->ftCreate($idx, [ + ['name' => 'title', 'type' => 'TEXT', 'nosuffixtrie' => true], + ], ['ON' => 'HASH', 'PREFIX' => [$prefix]]); + $this->getClient()->hSet($prefix . '1', 'title', 'hello world'); + usleep(1500000); + $threw = false; + try { + $this->getClient()->ftSearch($idx, '*orld'); + } catch (\Throwable $e) { + $threw = true; + } + $this->assertTrue($threw); + $this->getClient()->ftDropIndex($idx); + } + + /* ── FT.SEARCH cluster-mode options ────────────────────────────── */ + + public function testFtSearchShardScopeAndConsistency() + { + // Just testing that it works with these configurations + $this->skipIfModuleNotAvailable(); + $prefix = '{' . uniqid() . '}:'; + $idx = $prefix . 'idx'; + $this->getClient()->ftCreate($idx, [ + ['name' => 'tag', 'type' => 'TAG'], + ['name' => 'score', 'type' => 'NUMERIC'], + ], ['ON' => 'HASH', 'PREFIX' => [$prefix]]); + + $this->getClient()->hSet($prefix . '1', 'tag', 'test', 'score', '1'); + $this->getClient()->hSet($prefix . '2', 'tag', 'test', 'score', '2'); + usleep(1500000); + + // SOMESHARDS + INCONSISTENT + $r = $this->getClient()->ftSearch($idx, '@tag:{test}', [ + 'SOMESHARDS' => true, + 'INCONSISTENT' => true, + ]); + $this->assertIsArray($r); + + // ALLSHARDS + CONSISTENT + $r = $this->getClient()->ftSearch($idx, '@tag:{test}', [ + 'ALLSHARDS' => true, + 'CONSISTENT' => true, + ]); + $this->assertIsArray($r); + + $this->getClient()->ftDropIndex($idx); + } + + /* ── FT.INFO cluster-mode options ──────────────────────────────── */ + + public function testFtInfoWithLocalScopeAndShardFlags() + { + $this->skipIfModuleNotAvailable(); + $prefix = '{' . uniqid() . '}:'; + $idx = $prefix . 'idx'; + $this->getClient()->ftCreate($idx, [ + ['name' => 'title', 'type' => 'TEXT'], + ], ['ON' => 'HASH', 'PREFIX' => [$prefix]]); + $this->getClient()->hSet($prefix . '1', 'title', 'hello world'); + usleep(1500000); + + // LOCAL scope + $info = $this->getClient()->ftInfo($idx, ['scope' => 'LOCAL']); + $this->assertIsArray($info); + + // LOCAL + ALLSHARDS + CONSISTENT + $info = $this->getClient()->ftInfo($idx, [ + 'scope' => 'LOCAL', + 'ALLSHARDS' => true, + 'CONSISTENT' => true, + ]); + $this->assertIsArray($info); + + // LOCAL + SOMESHARDS + INCONSISTENT + $info = $this->getClient()->ftInfo($idx, [ + 'scope' => 'LOCAL', + 'SOMESHARDS' => true, + 'INCONSISTENT' => true, + ]); + $this->assertIsArray($info); + + $this->getClient()->ftDropIndex($idx); + } + + public function testFtInfoPrimaryAndClusterScope() + { + $this->skipIfModuleNotAvailable(); + $prefix = '{' . uniqid() . '}:'; + $idx = $prefix . 'idx'; + $this->getClient()->ftCreate($idx, [ + ['name' => 'title', 'type' => 'TEXT'], + ], ['ON' => 'HASH', 'PREFIX' => [$prefix]]); + $this->getClient()->hSet($prefix . '1', 'title', 'hello world'); + usleep(1500000); + + // PRIMARY scope — works if coordinator is enabled, otherwise rejected + try { + $info = $this->getClient()->ftInfo($idx, ['scope' => 'PRIMARY']); + $this->assertIsArray($info); + } catch (\Throwable $e) { + // Expected on servers without coordinator + $this->assertTrue( + stripos($e->getMessage(), 'PRIMARY') !== false || + stripos($e->getMessage(), 'not valid') !== false || + stripos($e->getMessage(), 'ERR') !== false + ); + } + + // CLUSTER scope — works if coordinator is enabled, otherwise rejected + try { + $info = $this->getClient()->ftInfo($idx, ['scope' => 'CLUSTER']); + $this->assertIsArray($info); + } catch (\Throwable $e) { + $this->assertTrue( + stripos($e->getMessage(), 'CLUSTER') !== false || + stripos($e->getMessage(), 'not valid') !== false || + stripos($e->getMessage(), 'ERR') !== false + ); + } + + $this->getClient()->ftDropIndex($idx); + } + + public function testFtSearchReturnWithAliases() + { + $this->skipIfModuleNotAvailable(); + $prefix = '{' . uniqid() . '}:'; + $idx = $prefix . 'index'; + $this->getClient()->ftCreate($idx, [ + ['name' => 'title', 'type' => 'TEXT'], + ['name' => 'price', 'type' => 'NUMERIC'], + ['name' => 'category', 'type' => 'TAG'], + ], ['ON' => 'HASH', 'PREFIX' => [$prefix]]); + + $this->getClient()->hSet($prefix . '1', ['title' => 'Widget', 'price' => '9.99', 'category' => 'tools']); + $this->getClient()->hSet($prefix . '2', ['title' => 'Gadget', 'price' => '19.99', 'category' => 'electronics']); + usleep(1500000); + + // Simple RETURN without aliases — returned fields should use original names + $result = $this->getClient()->ftSearch( + $idx, + '@category:{tools|electronics}', + ['RETURN' => ['title', 'price'], 'SORTBY' => ['price', 'ASC']] + ); + $this->assertIsArray($result); + $this->assertEquals(2, $result[0]); + $this->assertIsArray($result[1]); + // First result's fields (index 1 in the results sub-array) + $fields1 = $result[1][1]; + $this->assertContains('title', $fields1); + $this->assertContains('price', $fields1); + + // RETURN with AS aliases — returned fields should use alias names + $result = $this->getClient()->ftSearch( + $idx, + '@category:{tools|electronics}', + ['RETURN' => ['title' => 't', 'price' => 'p'], 'SORTBY' => ['price', 'ASC']] + ); + $this->assertIsArray($result); + $this->assertEquals(2, $result[0]); + $fields1 = $result[1][1]; + $this->assertContains('t', $fields1); + $this->assertContains('p', $fields1); + // Original names should NOT appear when aliased + $this->assertFalse(in_array('title', $fields1)); + $this->assertFalse(in_array('price', $fields1)); + + // Mixed: 'title' aliased to 't', 'price' unaliased + $result = $this->getClient()->ftSearch( + $idx, + '@category:{tools|electronics}', + ['RETURN' => ['title' => 't', 'price'], 'SORTBY' => ['price', 'ASC']] + ); + $this->assertIsArray($result); + $this->assertEquals(2, $result[0]); + $fields1 = $result[1][1]; + $this->assertContains('t', $fields1); + $this->assertContains('price', $fields1); + + $this->getClient()->ftDropIndex($idx); + } +} diff --git a/valkey_glide.stub.php b/valkey_glide.stub.php index 56a97da3..962411b2 100644 --- a/valkey_glide.stub.php +++ b/valkey_glide.stub.php @@ -4382,6 +4382,305 @@ public function zunion(array $keys, ?array $weights = null, ?array $options = nu * $valkey_glide->zUnionStore('dst', ['zs1', 'zs2', 'zs3']); */ public function zunionstore(string $dst, array $keys, ?array $weights = null, ?string $aggregate = null): ValkeyGlide|int|false; + + /** + * Create a new search index with the given schema. + * + * @param string $index The name of the index to create. + * @param array $schema Array of field definitions. Each field is an associative array: + * + * $schema = [ + * # TEXT field + * ['name' => 'title', 'type' => 'TEXT', + * 'alias' => 'ttl', # optional alias + * 'nostem' => true, # disable stemming + * 'weight' => 1.0, # field weight (only 1.0 supported) + * 'withsuffixtrie' => true, # enable suffix queries (default) + * 'nosuffixtrie' => true, # disable suffix queries + * 'sortable' => true, # allow SORTBY + * ], + * + * # TAG field + * ['name' => 'category', 'type' => 'TAG', + * 'separator' => ',', # tag separator (default ",") + * 'casesensitive' => true, # preserve letter case + * 'sortable' => true, + * ], + * + * # NUMERIC field + * ['name' => 'price', 'type' => 'NUMERIC', + * 'sortable' => true, + * ], + * + * # VECTOR field (HNSW or FLAT) + * ['name' => 'vec', 'type' => 'VECTOR', + * 'algorithm' => 'HNSW', # HNSW or FLAT (required) + * 'dim' => 1536, # dimensions (required) + * 'metric' => 'COSINE', # L2, IP, or COSINE (required) + * 'initial_cap' => 1000, # initial capacity (HNSW & FLAT) + * 'm' => 40, # HNSW only: max outgoing edges per node + * 'ef_construction' => 200, # HNSW only: vectors examined during build + * 'ef_runtime' => 10, # HNSW only: vectors examined during query + * ], + * ]; + * + * @param array $options Optional associative array of index-level options. + * + * $options = [ + * 'ON' => 'HASH', # or 'JSON' (default HASH) + * 'PREFIX' => ['docs:'], # key prefixes to index + * 'SCORE' => 1.0, # default score (only 1.0 supported) + * 'LANGUAGE' => 'english', # default stemming language + * 'SKIPINITIALSCAN' => true, # skip scanning existing keys + * 'MINSTEMSIZE' => 6, # minimum word length to stem (default 4) + * 'WITHOFFSETS' => true, # store term offsets (default) + * 'NOOFFSETS' => true, # don't store term offsets + * 'NOSTOPWORDS' => true, # disable stop-word filtering + * 'STOPWORDS' => ['the', 'a'], # custom stop-word list + * 'PUNCTUATION' => ',.<>{}[]"':;!@#$%^&\*()-+=~/\|?', # custom word separator characters + * ]; + * + * + * @return ValkeyGlide|string|false "OK" on success, false on failure. + * + * @see https://valkey.io/commands/ft.create/ + * + * @example + * // Text + numeric index on HASH keys + * $client->ftCreate('myindex', [ + * ['name' => 'title', 'type' => 'TEXT', 'sortable' => true], + * ['name' => 'price', 'type' => 'NUMERIC', 'sortable' => true], + * ], ['ON' => 'HASH', 'PREFIX' => ['product:']]); + * + * @example + * // HNSW vector index + * $client->ftCreate('vecindex', [ + * ['name' => 'embedding', 'type' => 'VECTOR', 'algorithm' => 'HNSW', + * 'dim' => 1536, 'metric' => 'COSINE', 'm' => 40], + * ], ['ON' => 'HASH', 'PREFIX' => ['docs:']]); + * + * @example + * // JSON index with aliased fields + * $client->ftCreate('jsonindex', [ + * ['name' => '$.vec', 'alias' => 'VEC', 'type' => 'VECTOR', + * 'algorithm' => 'FLAT', 'dim' => 6, 'metric' => 'L2'], + * ['name' => '$.name', 'type' => 'TEXT'], + * ], ['ON' => 'JSON', 'PREFIX' => ['json:']]); + */ + public function ftCreate(string $index, array $schema, ?array $options = null): ValkeyGlide|string|bool; + + /** + * Drop an existing search index. + * + * @param string $index The name of the index to drop. + * + * @return ValkeyGlide|string|false "OK" on success, false on failure. + * + * @see https://valkey.io/commands/ft.dropindex/ + */ + public function ftDropIndex(string $index): ValkeyGlide|string|bool; + + /** + * Return a list of all existing search index names. + * + * @return ValkeyGlide|array|false Array of index name strings, or false on failure. + * + * @see https://valkey.io/commands/ft._list/ + */ + public function ftList(): ValkeyGlide|array|false; + + /** + * Execute a search query against an index. + * + * @param string $index The name of the index to search. + * @param string $query The search query string (e.g. "*", "@title:hello", + * "*=>[KNN 2 @VEC $query_vec]"). + * @param array $options Optional associative array of search options. + * + * $options = [ + * 'NOCONTENT' => true, # return keys only, no field data + * 'VERBATIM' => true, # disable stemming in the query + * 'INORDER' => true, # require proximity terms in order + * 'SLOP' => 2, # max distance between proximity terms + * 'LIMIT' => [0, 10], # [offset, count] pagination + * 'SORTBY' => ['price', 'ASC'], # sort by a SORTABLE field + * 'WITHSORTKEYS' => true, # include sort key in each result + * 'RETURN' => ['title', 'price'],# return only these fields + * // RETURN with aliases (string key => alias): + * // 'RETURN' => ['title' => 't', 'price' => 'p'], + * // Mixed (some aliased, some not): + * // 'RETURN' => ['title' => 't', 'price'], + * 'TIMEOUT' => 5000, # override module timeout (ms) + * 'PARAMS' => ['k' => 'v'], # query parameters (key => value) + * 'DIALECT' => 2, # query dialect version + * 'ALLSHARDS' => true, # query all shards (cluster mode) + * 'SOMESHARDS' => true, # query subset of shards (cluster mode) + * 'CONSISTENT' => true, # require consistent results (cluster mode) + * 'INCONSISTENT' => true, # allow inconsistent results (cluster mode) + * ]; + * + * + * @return ValkeyGlide|array|false The search result, or false on failure. + * + * @see https://valkey.io/commands/ft.search/ + * + * @example + * // Simple text query with pagination + * $client->ftSearch('myindex', '@title:hello', ['LIMIT' => [0, 10]]); + * + * @example + * // KNN vector search with params + * $vec = str_repeat("\x00", 8); + * $client->ftSearch('vecindex', '*=>[KNN 2 @VEC $query_vec]', [ + * 'PARAMS' => ['query_vec' => $vec], + * 'RETURN' => ['vec'], + * ]); + * + * @example + * // Sorted search, keys only + * $client->ftSearch('myindex', '@price:[1 +inf]', [ + * 'SORTBY' => ['price', 'ASC'], + * 'NOCONTENT' => true, + * ]); + * + * @example + * // RETURN with field aliases + * $client->ftSearch('myindex', '@category:{tools}', [ + * 'RETURN' => ['title' => 't', 'price' => 'p'], + * ]); + */ + public function ftSearch(string $index, string $query, ?array $options = null): ValkeyGlide|array|false; + + /** + * Run an aggregation pipeline against an index. + * + * The $options array is a flat list of tokens passed directly to the server. + * Pipeline clauses (GROUPBY, SORTBY, APPLY, FILTER, LIMIT) are applied in the + * order they appear in the array, so ordering matters. + * + * @param string $index The name of the index to aggregate. + * @param string $query The filter query string (e.g. "*", "@price:[100 +inf]"). + * @param array $options Optional flat array of aggregation option tokens. + * + * $options = [ + * 'VERBATIM', # Disable stemming + * 'LOAD', '*', # Load all fields + * 'LOAD', '', '@field', ..., # Load specific fields + * 'TIMEOUT', '', # Override module timeout + * 'PARAMS', '', 'k', 'v', ..., # Query parameters + * 'DIALECT', '', # Query dialect version + * + * # Pipeline clauses (repeatable, order matters): + * 'GROUPBY', '', '@field', ..., # Group by fields + * 'REDUCE', '', '', # Reducer (COUNT, SUM, AVG, TOLIST, ...) + * [args...], # Reducer arguments + * 'AS', '', # Output property name + * 'SORTBY', '', # Sort by fields + * '@field', 'ASC|DESC', ..., # count = 2 * number of fields + * 'MAX', '', # Optional: only sort top N + * 'APPLY', '', 'AS', '', # Compute a new property + * 'FILTER', '', # Filter rows + * 'LIMIT', '', '', # Paginate + * ]; + * + * + * @return ValkeyGlide|array|false Array of result rows, or false on failure. + * + * @see https://valkey.io/commands/ft.aggregate/ + * + * @example + * // Group by condition, count per group + * $client->ftAggregate('idx', '*', [ + * 'LOAD', '1', '__key', + * 'GROUPBY', '1', '@condition', + * 'REDUCE', 'COUNT', '0', 'AS', 'total' + * ]); + * + * @example + * // APPLY + GROUPBY with multiple reducers + SORTBY + * $client->ftAggregate('movies', '*', [ + * 'LOAD', '*', + * 'APPLY', 'ceil(@rating)', 'AS', 'r_rating', + * 'GROUPBY', '1', '@genre', + * 'REDUCE', 'COUNT', '0', 'AS', 'nb_of_movies', + * 'REDUCE', 'SUM', '1', 'votes', 'AS', 'nb_of_votes', + * 'REDUCE', 'AVG', '1', 'r_rating', 'AS', 'avg_rating', + * 'SORTBY', '4', '@avg_rating', 'DESC', '@nb_of_votes', 'DESC' + * ]); + */ + public function ftAggregate(string $index, string $query, ?array $options = null): ValkeyGlide|array|false; + + /** + * Return information and statistics about an index. + * + * @param string $index The name of the index to inspect. + * @param array $options Optional associative array of info options. + * + * $options = [ + * 'scope' => 'LOCAL', # 'LOCAL', 'PRIMARY', or 'CLUSTER' + * 'ALLSHARDS' => true, # query all shards (cluster mode) + * 'SOMESHARDS' => true, # query subset of shards (cluster mode) + * 'CONSISTENT' => true, # require consistent results (cluster mode) + * 'INCONSISTENT' => true, # allow inconsistent results (cluster mode) + * ]; + * + * + * @return ValkeyGlide|array|false Associative array of index info, or false on failure. + * + * @see https://valkey.io/commands/ft.info/ + * + * @example + * $info = $client->ftInfo('myindex'); + * echo $info['index_name']; + * + * @example + * $info = $client->ftInfo('myindex', ['scope' => 'LOCAL']); + */ + public function ftInfo(string $index, ?array $options = null): ValkeyGlide|array|false; + + /** + * Add an alias to an existing index. + * + * @param string $alias The alias name to add. + * @param string $index The index to associate the alias with. + * + * @return ValkeyGlide|string|false "OK" on success, false on failure. + * + * @see https://valkey.io/commands/ft.aliasadd/ + */ + public function ftAliasAdd(string $alias, string $index): ValkeyGlide|string|bool; + + /** + * Remove an alias from an index. + * + * @param string $alias The alias name to remove. + * + * @return ValkeyGlide|string|false "OK" on success, false on failure. + * + * @see https://valkey.io/commands/ft.aliasdel/ + */ + public function ftAliasDel(string $alias): ValkeyGlide|string|bool; + + /** + * Update an existing alias to point to a different index. + * + * @param string $alias The alias name to update. + * @param string $index The new index to associate the alias with. + * + * @return ValkeyGlide|string|false "OK" on success, false on failure. + * + * @see https://valkey.io/commands/ft.aliasupdate/ + */ + public function ftAliasUpdate(string $alias, string $index): ValkeyGlide|string|bool; + + /** + * Return a map of all aliases to their associated index names. + * + * @return ValkeyGlide|array|false Associative array of alias => index, or false on failure. + * + * @see https://valkey.io/commands/ft._aliaslist/ + */ + public function ftAliasList(): ValkeyGlide|array|false; } class ValkeyGlideException extends RuntimeException diff --git a/valkey_glide_cluster.c b/valkey_glide_cluster.c index a9eb2fce..590fefdb 100644 --- a/valkey_glide_cluster.c +++ b/valkey_glide_cluster.c @@ -15,6 +15,7 @@ #include "logger.h" #include "valkey_glide_commands_common.h" #include "valkey_glide_core_common.h" +#include "valkey_glide_ft_common.h" #include "valkey_glide_geo_common.h" #include "valkey_glide_hash_common.h" /* Include hash command framework */ #include "valkey_glide_list_common.h" @@ -1204,5 +1205,50 @@ SETOPTION_METHOD_IMPL(ValkeyGlideCluster) GETOPTION_METHOD_IMPL(ValkeyGlideCluster) /* }}} */ +/* {{{ proto string|false ValkeyGlideCluster::ftCreate(string index, array schema, ?array options = + * null) + */ +FT_CREATE_METHOD_IMPL(ValkeyGlideCluster) +/* }}} */ + +/* {{{ proto string|false ValkeyGlideCluster::ftDropIndex(string index) */ +FT_DROPINDEX_METHOD_IMPL(ValkeyGlideCluster) +/* }}} */ + +/* {{{ proto array|false ValkeyGlideCluster::ftList() */ +FT_LIST_METHOD_IMPL(ValkeyGlideCluster) +/* }}} */ + +/* {{{ proto array|false ValkeyGlideCluster::ftSearch(string index, string query, ?array options = + * null) */ +FT_SEARCH_METHOD_IMPL(ValkeyGlideCluster) +/* }}} */ + +/* {{{ proto array|false ValkeyGlideCluster::ftAggregate(string index, string query, ?array options + * = null) + */ +FT_AGGREGATE_METHOD_IMPL(ValkeyGlideCluster) +/* }}} */ + +/* {{{ proto array|false ValkeyGlideCluster::ftInfo(string index, ?array options = null) */ +FT_INFO_METHOD_IMPL(ValkeyGlideCluster) +/* }}} */ + +/* {{{ proto string|false ValkeyGlideCluster::ftAliasAdd(string alias, string index) */ +FT_ALIASADD_METHOD_IMPL(ValkeyGlideCluster) +/* }}} */ + +/* {{{ proto string|false ValkeyGlideCluster::ftAliasDel(string alias) */ +FT_ALIASDEL_METHOD_IMPL(ValkeyGlideCluster) +/* }}} */ + +/* {{{ proto string|false ValkeyGlideCluster::ftAliasUpdate(string alias, string index) */ +FT_ALIASUPDATE_METHOD_IMPL(ValkeyGlideCluster) +/* }}} */ + +/* {{{ proto array|false ValkeyGlideCluster::ftAliasList() */ +FT_ALIASLIST_METHOD_IMPL(ValkeyGlideCluster) +/* }}} */ + #endif /* PHP_REDIS_CLUSTER_C */ /* vim: set tabstop=4 softtabstop=4 expandtab shiftwidth=4: */ diff --git a/valkey_glide_cluster.stub.php b/valkey_glide_cluster.stub.php index d7422f64..34f305c4 100644 --- a/valkey_glide_cluster.stub.php +++ b/valkey_glide_cluster.stub.php @@ -1472,4 +1472,54 @@ public function functionStats(): ValkeyGlideCluster|array|false; * @see ValkeyGlide::function */ public function function(string $operation, mixed ...$args): mixed; + + /** + * @see ValkeyGlide::ftCreate + */ + public function ftCreate(string $index, array $schema, ?array $options = null): ValkeyGlideCluster|string|bool; + + /** + * @see ValkeyGlide::ftDropIndex + */ + public function ftDropIndex(string $index): ValkeyGlideCluster|string|bool; + + /** + * @see ValkeyGlide::ftList + */ + public function ftList(): ValkeyGlideCluster|array|false; + + /** + * @see ValkeyGlide::ftSearch + */ + public function ftSearch(string $index, string $query, ?array $options = null): ValkeyGlideCluster|array|false; + + /** + * @see ValkeyGlide::ftAggregate + */ + public function ftAggregate(string $index, string $query, ?array $options = null): ValkeyGlideCluster|array|false; + + /** + * @see ValkeyGlide::ftInfo + */ + public function ftInfo(string $index, ?array $options = null): ValkeyGlideCluster|array|false; + + /** + * @see ValkeyGlide::ftAliasAdd + */ + public function ftAliasAdd(string $alias, string $index): ValkeyGlideCluster|string|bool; + + /** + * @see ValkeyGlide::ftAliasDel + */ + public function ftAliasDel(string $alias): ValkeyGlideCluster|string|bool; + + /** + * @see ValkeyGlide::ftAliasUpdate + */ + public function ftAliasUpdate(string $alias, string $index): ValkeyGlideCluster|string|bool; + + /** + * @see ValkeyGlide::ftAliasList + */ + public function ftAliasList(): ValkeyGlideCluster|array|false; } diff --git a/valkey_glide_ft_commands.c b/valkey_glide_ft_commands.c new file mode 100644 index 00000000..a5287f37 --- /dev/null +++ b/valkey_glide_ft_commands.c @@ -0,0 +1,434 @@ +/** Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0 */ + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include +#include +#include + +#include "valkey_glide_ft_common.h" + +/* ==================================================================== + * FT.CREATE + * Usage: $client->ftCreate(string $index, array $schema, ?array $options = null) + * + * $schema is an array of associative arrays, each describing a field: + * [['name' => 'title', 'type' => 'TEXT', 'sortable' => true], ...] + * + * $options is an associative array: + * ['ON' => 'HASH', 'PREFIX' => ['docs:'], ...] + * ==================================================================== */ +int execute_ft_create_command(zval* object, int argc, zval* return_value, zend_class_entry* ce) { + valkey_glide_object* valkey_glide; + char* index_name = NULL; + size_t index_name_len = 0; + zval* schema_arr = NULL; + zval* options_arr = NULL; + + if (zend_parse_method_parameters(argc, + object, + "Osa|a", + &object, + ce, + &index_name, + &index_name_len, + &schema_arr, + &options_arr) == FAILURE) { + return 0; + } + + valkey_glide = VALKEY_GLIDE_PHP_ZVAL_GET_OBJECT(valkey_glide_object, object); + VALIDATE_FT_CLIENT(valkey_glide && valkey_glide->glide_client); + + const char** args = NULL; + size_t* lens = NULL; + int count = 0; + char** allocated = NULL; + int alloc_n = 0; + + HashTable* opts_ht = + (options_arr && Z_TYPE_P(options_arr) == IS_ARRAY) ? Z_ARRVAL_P(options_arr) : NULL; + + if (!build_ft_create_args(index_name, + index_name_len, + Z_ARRVAL_P(schema_arr), + opts_ht, + &args, + &lens, + &count, + &allocated, + &alloc_n)) { + return 1; /* Exception already thrown by builder */ + } + + int status = execute_ft_command_internal(valkey_glide->glide_client, + FtCreate, + args, + lens, + count, + return_value, + COMMAND_RESPONSE_NOT_ASSOSIATIVE); + + free_ft_collected(args, lens, allocated, alloc_n); + return status; +} + +/* ==================================================================== + * FT.DROPINDEX + * Usage: $client->ftDropIndex(string $index) + * ==================================================================== */ +int execute_ft_dropindex_command(zval* object, int argc, zval* return_value, zend_class_entry* ce) { + valkey_glide_object* valkey_glide; + char* index_name = NULL; + size_t index_name_len = 0; + + if (zend_parse_method_parameters( + argc, object, "Os", &object, ce, &index_name, &index_name_len) == FAILURE) { + return 0; + } + + valkey_glide = VALKEY_GLIDE_PHP_ZVAL_GET_OBJECT(valkey_glide_object, object); + VALIDATE_FT_CLIENT(valkey_glide && valkey_glide->glide_client); + + const char* args[] = {index_name}; + size_t lens[] = {index_name_len}; + + return execute_ft_command_internal(valkey_glide->glide_client, + FtDropIndex, + args, + lens, + 1, + return_value, + COMMAND_RESPONSE_NOT_ASSOSIATIVE); +} + +/* ==================================================================== + * FT._LIST + * Usage: $client->ftList() + * ==================================================================== */ +int execute_ft_list_command(zval* object, int argc, zval* return_value, zend_class_entry* ce) { + valkey_glide_object* valkey_glide; + + if (zend_parse_method_parameters(argc, object, "O", &object, ce) == FAILURE) { + return 0; + } + + valkey_glide = VALKEY_GLIDE_PHP_ZVAL_GET_OBJECT(valkey_glide_object, object); + VALIDATE_FT_CLIENT(valkey_glide && valkey_glide->glide_client); + + return execute_ft_command_internal(valkey_glide->glide_client, + FtList, + NULL, + NULL, + 0, + return_value, + COMMAND_RESPONSE_NOT_ASSOSIATIVE); +} + +/* ==================================================================== + * FT.SEARCH + * Usage: $client->ftSearch(string $index, string $query, ?array $options = null) + * + * $options is an associative array: + * ['NOCONTENT' => true, 'LIMIT' => [0, 10], 'PARAMS' => ['k' => 'v'], ...] + * ==================================================================== */ +int execute_ft_search_command(zval* object, int argc, zval* return_value, zend_class_entry* ce) { + valkey_glide_object* valkey_glide; + char* index_name = NULL; + size_t index_name_len = 0; + char* query = NULL; + size_t query_len = 0; + zval* options_arr = NULL; + + if (zend_parse_method_parameters(argc, + object, + "Oss|a", + &object, + ce, + &index_name, + &index_name_len, + &query, + &query_len, + &options_arr) == FAILURE) { + return 0; + } + + valkey_glide = VALKEY_GLIDE_PHP_ZVAL_GET_OBJECT(valkey_glide_object, object); + VALIDATE_FT_CLIENT(valkey_glide && valkey_glide->glide_client); + + const char** args = NULL; + size_t* lens = NULL; + int count = 0; + char** allocated = NULL; + int alloc_n = 0; + + HashTable* opts_ht = + (options_arr && Z_TYPE_P(options_arr) == IS_ARRAY) ? Z_ARRVAL_P(options_arr) : NULL; + + if (!build_ft_search_args(index_name, + index_name_len, + query, + query_len, + opts_ht, + &args, + &lens, + &count, + &allocated, + &alloc_n)) { + return 1; + } + + int status = execute_ft_command_internal(valkey_glide->glide_client, + FtSearch, + args, + lens, + count, + return_value, + COMMAND_RESPONSE_NOT_ASSOSIATIVE); + + free_ft_collected(args, lens, allocated, alloc_n); + return status; +} + +/* ==================================================================== + * FT.AGGREGATE + * Usage: $client->ftAggregate(string $index, string $query, ?array $options = null) + * ==================================================================== */ +int execute_ft_aggregate_command(zval* object, int argc, zval* return_value, zend_class_entry* ce) { + valkey_glide_object* valkey_glide; + char* index_name = NULL; + size_t index_name_len = 0; + char* query = NULL; + size_t query_len = 0; + zval* options_arr = NULL; + + if (zend_parse_method_parameters(argc, + object, + "Oss|a", + &object, + ce, + &index_name, + &index_name_len, + &query, + &query_len, + &options_arr) == FAILURE) { + return 0; + } + + valkey_glide = VALKEY_GLIDE_PHP_ZVAL_GET_OBJECT(valkey_glide_object, object); + VALIDATE_FT_CLIENT(valkey_glide && valkey_glide->glide_client); + + const char** args = NULL; + size_t* lens = NULL; + int count = 0; + char** allocated = NULL; + int alloc_n = 0; + + HashTable* opts_ht = + (options_arr && Z_TYPE_P(options_arr) == IS_ARRAY) ? Z_ARRVAL_P(options_arr) : NULL; + + if (!build_ft_aggregate_args(index_name, + index_name_len, + query, + query_len, + opts_ht, + &args, + &lens, + &count, + &allocated, + &alloc_n)) { + return 1; + } + + int status = execute_ft_command_internal(valkey_glide->glide_client, + FtAggregate, + args, + lens, + count, + return_value, + COMMAND_RESPONSE_NOT_ASSOSIATIVE); + + free_ft_collected(args, lens, allocated, alloc_n); + return status; +} + +/* ==================================================================== + * FT.INFO + * Usage: $client->ftInfo(string $index, ?array $options = null) + * + * $options is an associative array: + * ['scope' => 'LOCAL'] + * ==================================================================== */ +int execute_ft_info_command(zval* object, int argc, zval* return_value, zend_class_entry* ce) { + valkey_glide_object* valkey_glide; + char* index_name = NULL; + size_t index_name_len = 0; + zval* options_arr = NULL; + + if (zend_parse_method_parameters( + argc, object, "Os|a", &object, ce, &index_name, &index_name_len, &options_arr) == + FAILURE) { + return 0; + } + + valkey_glide = VALKEY_GLIDE_PHP_ZVAL_GET_OBJECT(valkey_glide_object, object); + VALIDATE_FT_CLIENT(valkey_glide && valkey_glide->glide_client); + + const char** args = NULL; + size_t* lens = NULL; + int count = 0; + char** allocated = NULL; + int alloc_n = 0; + + HashTable* opts_ht = + (options_arr && Z_TYPE_P(options_arr) == IS_ARRAY) ? Z_ARRVAL_P(options_arr) : NULL; + + if (!build_ft_info_args( + index_name, index_name_len, opts_ht, &args, &lens, &count, &allocated, &alloc_n)) { + return 1; + } + + int status = execute_ft_command_internal(valkey_glide->glide_client, + FtInfo, + args, + lens, + count, + return_value, + COMMAND_RESPONSE_ASSOSIATIVE_ARRAY_MAP); + + free_ft_collected(args, lens, allocated, alloc_n); + return status; +} + +/* ==================================================================== + * FT.ALIASADD + * Usage: $client->ftAliasAdd(string $alias, string $index) + * ==================================================================== */ +int execute_ft_aliasadd_command(zval* object, int argc, zval* return_value, zend_class_entry* ce) { + valkey_glide_object* valkey_glide; + char* alias_name = NULL; + size_t alias_name_len = 0; + char* index_name = NULL; + size_t index_name_len = 0; + + if (zend_parse_method_parameters(argc, + object, + "Oss", + &object, + ce, + &alias_name, + &alias_name_len, + &index_name, + &index_name_len) == FAILURE) { + return 0; + } + + valkey_glide = VALKEY_GLIDE_PHP_ZVAL_GET_OBJECT(valkey_glide_object, object); + VALIDATE_FT_CLIENT(valkey_glide && valkey_glide->glide_client); + + const char* args[] = {alias_name, index_name}; + size_t lens[] = {alias_name_len, index_name_len}; + + return execute_ft_command_internal(valkey_glide->glide_client, + FtAliasAdd, + args, + lens, + 2, + return_value, + COMMAND_RESPONSE_NOT_ASSOSIATIVE); +} + +/* ==================================================================== + * FT.ALIASDEL + * Usage: $client->ftAliasDel(string $alias) + * ==================================================================== */ +int execute_ft_aliasdel_command(zval* object, int argc, zval* return_value, zend_class_entry* ce) { + valkey_glide_object* valkey_glide; + char* alias_name = NULL; + size_t alias_name_len = 0; + + if (zend_parse_method_parameters( + argc, object, "Os", &object, ce, &alias_name, &alias_name_len) == FAILURE) { + return 0; + } + + valkey_glide = VALKEY_GLIDE_PHP_ZVAL_GET_OBJECT(valkey_glide_object, object); + VALIDATE_FT_CLIENT(valkey_glide && valkey_glide->glide_client); + + const char* args[] = {alias_name}; + size_t lens[] = {alias_name_len}; + + return execute_ft_command_internal(valkey_glide->glide_client, + FtAliasDel, + args, + lens, + 1, + return_value, + COMMAND_RESPONSE_NOT_ASSOSIATIVE); +} + +/* ==================================================================== + * FT.ALIASUPDATE + * Usage: $client->ftAliasUpdate(string $alias, string $index) + * ==================================================================== */ +int execute_ft_aliasupdate_command(zval* object, + int argc, + zval* return_value, + zend_class_entry* ce) { + valkey_glide_object* valkey_glide; + char* alias_name = NULL; + size_t alias_name_len = 0; + char* index_name = NULL; + size_t index_name_len = 0; + + if (zend_parse_method_parameters(argc, + object, + "Oss", + &object, + ce, + &alias_name, + &alias_name_len, + &index_name, + &index_name_len) == FAILURE) { + return 0; + } + + valkey_glide = VALKEY_GLIDE_PHP_ZVAL_GET_OBJECT(valkey_glide_object, object); + VALIDATE_FT_CLIENT(valkey_glide && valkey_glide->glide_client); + + const char* args[] = {alias_name, index_name}; + size_t lens[] = {alias_name_len, index_name_len}; + + return execute_ft_command_internal(valkey_glide->glide_client, + FtAliasUpdate, + args, + lens, + 2, + return_value, + COMMAND_RESPONSE_NOT_ASSOSIATIVE); +} + +/* ==================================================================== + * FT._ALIASLIST + * Usage: $client->ftAliasList() + * ==================================================================== */ +int execute_ft_aliaslist_command(zval* object, int argc, zval* return_value, zend_class_entry* ce) { + valkey_glide_object* valkey_glide; + + if (zend_parse_method_parameters(argc, object, "O", &object, ce) == FAILURE) { + return 0; + } + + valkey_glide = VALKEY_GLIDE_PHP_ZVAL_GET_OBJECT(valkey_glide_object, object); + VALIDATE_FT_CLIENT(valkey_glide && valkey_glide->glide_client); + + return execute_ft_command_internal(valkey_glide->glide_client, + FtAliasList, + NULL, + NULL, + 0, + return_value, + COMMAND_RESPONSE_ASSOSIATIVE_ARRAY_MAP); +} diff --git a/valkey_glide_ft_common.c b/valkey_glide_ft_common.c new file mode 100644 index 00000000..09dbd7be --- /dev/null +++ b/valkey_glide_ft_common.c @@ -0,0 +1,771 @@ +/** Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0 */ + +/** + * Shared utilities and argument builders for FT.* commands. + * + * Contains the core FFI dispatch wrapper, array collection helpers, + * and structured argument builders that convert associative PHP arrays + * into the flat string token lists the FFI layer expects. + */ + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include +#include +#include + +#include "valkey_glide_ft_common.h" + +/* ==================================================================== + * CORE FRAMEWORK FUNCTIONS + * ==================================================================== */ + +int execute_ft_command_internal(const void* glide_client, + enum RequestType cmd_type, + const char** strings, + size_t* lengths, + int count, + zval* return_value, + int assoc_flag) { + uintptr_t* cmd_args = NULL; + unsigned long* args_len = NULL; + + if (count > 0) { + cmd_args = (uintptr_t*) emalloc(count * sizeof(uintptr_t)); + args_len = (unsigned long*) emalloc(count * sizeof(unsigned long)); + + for (int i = 0; i < count; i++) { + cmd_args[i] = (uintptr_t) strings[i]; + args_len[i] = (unsigned long) lengths[i]; + } + } + + CommandResult* result = execute_command(glide_client, cmd_type, count, cmd_args, args_len); + + if (cmd_args) + efree(cmd_args); + if (args_len) + efree(args_len); + + if (!result) { + return 0; + } + if (result->command_error) { + zend_throw_exception( + get_valkey_glide_exception_ce(), result->command_error->command_error_message, 0); + free_command_result(result); + return 1; /* Exception thrown, caller should return */ + } + if (!result->response) { + free_command_result(result); + return 0; + } + + int status = command_response_to_zval(result->response, return_value, assoc_flag, false); + free_command_result(result); + return status; +} + +/* ==================================================================== + * ARGUMENT COLLECTION UTILITIES + * ==================================================================== */ + +int collect_ft_array_strings(HashTable* ht, + const char*** out_strings, + size_t** out_lengths, + int* out_count, + char*** allocated, + int* allocated_count) { + int total = zend_hash_num_elements(ht); + int idx = 0; + int alloc = 0; + zval* entry; + + const char** strings = (const char**) emalloc(total * sizeof(const char*)); + size_t* lengths = (size_t*) emalloc(total * sizeof(size_t)); + char** allocs = (char**) emalloc(total * sizeof(char*)); + + ZEND_HASH_FOREACH_VAL(ht, entry) { + if (Z_TYPE_P(entry) == IS_STRING) { + strings[idx] = Z_STRVAL_P(entry); + lengths[idx] = Z_STRLEN_P(entry); + } else { + zval copy; + ZVAL_DUP(©, entry); + convert_to_string(©); + char* str = emalloc(Z_STRLEN(copy) + 1); + memcpy(str, Z_STRVAL(copy), Z_STRLEN(copy)); + str[Z_STRLEN(copy)] = '\0'; + strings[idx] = str; + lengths[idx] = Z_STRLEN(copy); + allocs[alloc++] = str; + zval_dtor(©); + } + idx++; + } + ZEND_HASH_FOREACH_END(); + + *out_strings = strings; + *out_lengths = lengths; + *out_count = idx; + *allocated = allocs; + *allocated_count = alloc; + return 1; +} + +void free_ft_collected(const char** strings, + size_t* lengths, + char** allocated, + int allocated_count) { + for (int i = 0; i < allocated_count; i++) { + efree(allocated[i]); + } + efree((void*) strings); + efree(lengths); + efree(allocated); +} + +/* ==================================================================== + * DYNAMIC STRING BUFFER + * ==================================================================== */ + +#define FT_BUF_INIT_CAP 32 + +typedef struct { + const char** strings; + size_t* lengths; + char** allocated; + int count; + int alloc_count; + int capacity; +} ft_arg_buf_t; + +static void ft_buf_init(ft_arg_buf_t* buf) { + buf->capacity = FT_BUF_INIT_CAP; + buf->count = 0; + buf->alloc_count = 0; + buf->strings = emalloc(buf->capacity * sizeof(const char*)); + buf->lengths = emalloc(buf->capacity * sizeof(size_t)); + buf->allocated = emalloc(buf->capacity * sizeof(char*)); +} + +static void ft_buf_grow(ft_arg_buf_t* buf) { + buf->capacity *= 2; + buf->strings = erealloc(buf->strings, buf->capacity * sizeof(const char*)); + buf->lengths = erealloc(buf->lengths, buf->capacity * sizeof(size_t)); + buf->allocated = erealloc(buf->allocated, buf->capacity * sizeof(char*)); +} + +/* Caller must ensure str outlives the buffer (no copy made) */ +static void ft_buf_add(ft_arg_buf_t* buf, const char* str, size_t len) { + if (buf->count >= buf->capacity) { + ft_buf_grow(buf); + } + buf->strings[buf->count] = str; + buf->lengths[buf->count] = len; + buf->count++; +} + +#define FT_BUF_LIT(buf, literal) ft_buf_add((buf), (literal), sizeof(literal) - 1) + +static void ft_buf_add_copy(ft_arg_buf_t* buf, const char* str, size_t len) { + char* copy = emalloc(len + 1); + memcpy(copy, str, len); + copy[len] = '\0'; + + if (buf->count >= buf->capacity) { + ft_buf_grow(buf); + } + buf->strings[buf->count] = copy; + buf->lengths[buf->count] = len; + buf->count++; + buf->allocated[buf->alloc_count++] = copy; +} + +static void ft_buf_add_long(ft_arg_buf_t* buf, zend_long val) { + char tmp[24]; + int len = snprintf(tmp, sizeof(tmp), ZEND_LONG_FMT, val); + ft_buf_add_copy(buf, tmp, len); +} + +static void ft_buf_add_double(ft_arg_buf_t* buf, double val) { + char tmp[64]; + int len = snprintf(tmp, sizeof(tmp), "%.6g", val); + ft_buf_add_copy(buf, tmp, len); +} + +static void ft_buf_add_zval(ft_arg_buf_t* buf, zval* z) { + if (Z_TYPE_P(z) == IS_STRING) { + ft_buf_add(buf, Z_STRVAL_P(z), Z_STRLEN_P(z)); + } else if (Z_TYPE_P(z) == IS_LONG) { + ft_buf_add_long(buf, Z_LVAL_P(z)); + } else if (Z_TYPE_P(z) == IS_DOUBLE) { + ft_buf_add_double(buf, Z_DVAL_P(z)); + } else { + zval copy; + ZVAL_DUP(©, z); + convert_to_string(©); + ft_buf_add_copy(buf, Z_STRVAL(copy), Z_STRLEN(copy)); + zval_dtor(©); + } +} + +static void ft_buf_destroy(ft_arg_buf_t* buf) { + for (int i = 0; i < buf->alloc_count; i++) { + efree(buf->allocated[i]); + } + efree(buf->strings); + efree(buf->lengths); + efree(buf->allocated); +} + +/* ==================================================================== + * HELPER: find a key in a HashTable (case-insensitive for convenience) + * ==================================================================== */ + +static zval* ft_find(HashTable* ht, const char* key) { + return zend_hash_str_find(ht, key, strlen(key)); +} + +/* ==================================================================== + * FT.CREATE SCHEMA BUILDER + * + * Each element of $schema is an associative array: + * ['name' => 'title', 'type' => 'TEXT', 'sortable' => true, ...] + * ==================================================================== */ + +static int ft_build_field(ft_arg_buf_t* buf, HashTable* field) { + zval* z; + + /* name (required) */ + z = ft_find(field, "name"); + if (!z || Z_TYPE_P(z) != IS_STRING) { + zend_throw_exception(get_valkey_glide_exception_ce(), + "ftCreate: each schema field must have a 'name' key", + 0); + return 0; + } + ft_buf_add(buf, Z_STRVAL_P(z), Z_STRLEN_P(z)); + + /* AS alias (optional) */ + z = ft_find(field, "alias"); + if (z && Z_TYPE_P(z) == IS_STRING) { + FT_BUF_LIT(buf, "AS"); + ft_buf_add(buf, Z_STRVAL_P(z), Z_STRLEN_P(z)); + } + + /* type (required) */ + z = ft_find(field, "type"); + if (!z || Z_TYPE_P(z) != IS_STRING) { + zend_throw_exception(get_valkey_glide_exception_ce(), + "ftCreate: each schema field must have a 'type' key", + 0); + return 0; + } + const char* type = Z_STRVAL_P(z); + ft_buf_add(buf, Z_STRVAL_P(z), Z_STRLEN_P(z)); + + /* --- TEXT options --- */ + if (strcasecmp(type, "TEXT") == 0) { + if ((z = ft_find(field, "nostem")) && zend_is_true(z)) { + FT_BUF_LIT(buf, "NOSTEM"); + } + if ((z = ft_find(field, "weight"))) { + FT_BUF_LIT(buf, "WEIGHT"); + ft_buf_add_zval(buf, z); + } + if ((z = ft_find(field, "withsuffixtrie")) && zend_is_true(z)) { + FT_BUF_LIT(buf, "WITHSUFFIXTRIE"); + } else if ((z = ft_find(field, "nosuffixtrie")) && zend_is_true(z)) { + FT_BUF_LIT(buf, "NOSUFFIXTRIE"); + } + } + + /* --- TAG options --- */ + if (strcasecmp(type, "TAG") == 0) { + if ((z = ft_find(field, "separator")) && Z_TYPE_P(z) == IS_STRING) { + FT_BUF_LIT(buf, "SEPARATOR"); + ft_buf_add(buf, Z_STRVAL_P(z), Z_STRLEN_P(z)); + } + if ((z = ft_find(field, "casesensitive")) && zend_is_true(z)) { + FT_BUF_LIT(buf, "CASESENSITIVE"); + } + } + + /* --- VECTOR options --- */ + if (strcasecmp(type, "VECTOR") == 0) { + /* algorithm (required) */ + z = ft_find(field, "algorithm"); + if (!z || Z_TYPE_P(z) != IS_STRING) { + zend_throw_exception(get_valkey_glide_exception_ce(), + "ftCreate: VECTOR field must have 'algorithm' (FLAT or HNSW)", + 0); + return 0; + } + const char* algorithm = Z_STRVAL_P(z); + ft_buf_add(buf, algorithm, Z_STRLEN_P(z)); + + int is_hnsw = (strcasecmp(algorithm, "HNSW") == 0); + + /* Build the attribute list in a temp buffer to get the count */ + ft_arg_buf_t attrs; + ft_buf_init(&attrs); + + z = ft_find(field, "dim"); + if (z) { + FT_BUF_LIT(&attrs, "DIM"); + ft_buf_add_zval(&attrs, z); + } + z = ft_find(field, "metric"); + if (z) { + FT_BUF_LIT(&attrs, "DISTANCE_METRIC"); + ft_buf_add_zval(&attrs, z); + } + /* TYPE defaults to FLOAT32 */ + z = ft_find(field, "datatype"); + if (z) { + FT_BUF_LIT(&attrs, "TYPE"); + ft_buf_add_zval(&attrs, z); + } else { + FT_BUF_LIT(&attrs, "TYPE"); + FT_BUF_LIT(&attrs, "FLOAT32"); + } + if ((z = ft_find(field, "initial_cap"))) { + FT_BUF_LIT(&attrs, "INITIAL_CAP"); + ft_buf_add_zval(&attrs, z); + } + /* M, EF_CONSTRUCTION, EF_RUNTIME are HNSW-only */ + if (is_hnsw) { + if ((z = ft_find(field, "m"))) { + FT_BUF_LIT(&attrs, "M"); + ft_buf_add_zval(&attrs, z); + } + if ((z = ft_find(field, "ef_construction"))) { + FT_BUF_LIT(&attrs, "EF_CONSTRUCTION"); + ft_buf_add_zval(&attrs, z); + } + if ((z = ft_find(field, "ef_runtime"))) { + FT_BUF_LIT(&attrs, "EF_RUNTIME"); + ft_buf_add_zval(&attrs, z); + } + } + + /* Emit count then attributes (must come before the attributes) */ + ft_buf_add_long(buf, attrs.count); + for (int i = 0; i < attrs.count; i++) { + ft_buf_add_copy(buf, attrs.strings[i], attrs.lengths[i]); + } + ft_buf_destroy(&attrs); + } + + /* SORTABLE (common to TEXT, TAG, NUMERIC) */ + if ((z = ft_find(field, "sortable")) && zend_is_true(z)) { + FT_BUF_LIT(buf, "SORTABLE"); + } + + return 1; +} + +/* ==================================================================== + * FT.CREATE OPTIONS BUILDER + * + * $options = [ + * 'ON' => 'HASH' | 'JSON', + * 'PREFIX' => ['docs:', 'blog:'], + * 'SCORE' => 1.0, + * 'LANGUAGE' => 'english', + * 'SKIPINITIALSCAN' => true, + * 'MINSTEMSIZE' => 6, + * 'WITHOFFSETS' => true (default), + * 'NOOFFSETS' => true, + * 'NOSTOPWORDS' => true, + * 'STOPWORDS' => ['the', 'a'], + * 'PUNCTUATION' => ',.<>{}[]"':;!@#$%^&\*()-+=~/\|?', + * ]; + * ==================================================================== */ + +static void ft_build_create_options(ft_arg_buf_t* buf, HashTable* opts) { + zval* z; + + if ((z = ft_find(opts, "ON")) && Z_TYPE_P(z) == IS_STRING) { + FT_BUF_LIT(buf, "ON"); + ft_buf_add(buf, Z_STRVAL_P(z), Z_STRLEN_P(z)); + } + + if ((z = ft_find(opts, "PREFIX")) && Z_TYPE_P(z) == IS_ARRAY) { + HashTable* prefixes = Z_ARRVAL_P(z); + FT_BUF_LIT(buf, "PREFIX"); + ft_buf_add_long(buf, zend_hash_num_elements(prefixes)); + zval* pfx; + ZEND_HASH_FOREACH_VAL(prefixes, pfx) { + ft_buf_add_zval(buf, pfx); + } + ZEND_HASH_FOREACH_END(); + } + + if ((z = ft_find(opts, "SCORE"))) { + FT_BUF_LIT(buf, "SCORE"); + ft_buf_add_zval(buf, z); + } + + if ((z = ft_find(opts, "LANGUAGE")) && Z_TYPE_P(z) == IS_STRING) { + FT_BUF_LIT(buf, "LANGUAGE"); + ft_buf_add(buf, Z_STRVAL_P(z), Z_STRLEN_P(z)); + } + + if ((z = ft_find(opts, "SKIPINITIALSCAN")) && zend_is_true(z)) { + FT_BUF_LIT(buf, "SKIPINITIALSCAN"); + } + + if ((z = ft_find(opts, "MINSTEMSIZE"))) { + FT_BUF_LIT(buf, "MINSTEMSIZE"); + ft_buf_add_zval(buf, z); + } + + // Mutually exclusive + if ((z = ft_find(opts, "WITHOFFSETS")) && zend_is_true(z)) { + FT_BUF_LIT(buf, "WITHOFFSETS"); + } else if ((z = ft_find(opts, "NOOFFSETS")) && zend_is_true(z)) { + FT_BUF_LIT(buf, "NOOFFSETS"); + } + + // Mutually exclusive + if ((z = ft_find(opts, "NOSTOPWORDS")) && zend_is_true(z)) { + FT_BUF_LIT(buf, "NOSTOPWORDS"); + } else if ((z = ft_find(opts, "STOPWORDS")) && Z_TYPE_P(z) == IS_ARRAY) { + HashTable* words = Z_ARRVAL_P(z); + FT_BUF_LIT(buf, "STOPWORDS"); + ft_buf_add_long(buf, zend_hash_num_elements(words)); + zval* w; + ZEND_HASH_FOREACH_VAL(words, w) { + ft_buf_add_zval(buf, w); + } + ZEND_HASH_FOREACH_END(); + } + + if ((z = ft_find(opts, "PUNCTUATION")) && Z_TYPE_P(z) == IS_STRING) { + FT_BUF_LIT(buf, "PUNCTUATION"); + ft_buf_add(buf, Z_STRVAL_P(z), Z_STRLEN_P(z)); + } +} + +/* ==================================================================== + * FT.SEARCH OPTIONS BUILDER + * + * $options = [ + * 'NOCONTENT' => true, + * 'VERBATIM' => true, + * 'INORDER' => true, + * 'SLOP' => 2, + * 'LIMIT' => [0, 10], + * 'SORTBY' => ['price', 'ASC'], + * 'WITHSORTKEYS' => true, + * 'RETURN' => ['title', 'price'], + * 'TIMEOUT' => 5000, + * 'PARAMS' => ['query_vec' => $vec], + * 'DIALECT' => 2, + * 'ALLSHARDS' => true, // or 'SOMESHARDS' => true + * 'CONSISTENT' => true, // or 'INCONSISTENT' => true + * ]; + * ==================================================================== */ + +static void ft_build_search_options(ft_arg_buf_t* buf, HashTable* opts) { + zval* z; + + if ((z = ft_find(opts, "NOCONTENT")) && zend_is_true(z)) { + FT_BUF_LIT(buf, "NOCONTENT"); + } + + if ((z = ft_find(opts, "VERBATIM")) && zend_is_true(z)) { + FT_BUF_LIT(buf, "VERBATIM"); + } + + if ((z = ft_find(opts, "INORDER")) && zend_is_true(z)) { + FT_BUF_LIT(buf, "INORDER"); + } + + if ((z = ft_find(opts, "SLOP"))) { + FT_BUF_LIT(buf, "SLOP"); + ft_buf_add_zval(buf, z); + } + + if ((z = ft_find(opts, "RETURN")) && Z_TYPE_P(z) == IS_ARRAY) { + HashTable* fields = Z_ARRVAL_P(z); + /* Count total tokens: string key entries emit 3 (field AS alias), + * numeric key entries emit 1 (field) */ + int token_count = 0; + zend_string* key; + zval* val; + ZEND_HASH_FOREACH_STR_KEY_VAL(fields, key, val) { + token_count += key ? 3 : 1; + } + ZEND_HASH_FOREACH_END(); + + FT_BUF_LIT(buf, "RETURN"); + ft_buf_add_long(buf, token_count); + + ZEND_HASH_FOREACH_STR_KEY_VAL(fields, key, val) { + if (key) { + /* String key => field name, value => alias */ + ft_buf_add(buf, ZSTR_VAL(key), ZSTR_LEN(key)); + FT_BUF_LIT(buf, "AS"); + ft_buf_add_zval(buf, val); + } else { + /* Numeric key => value is the field name */ + ft_buf_add_zval(buf, val); + } + } + ZEND_HASH_FOREACH_END(); + } + + if ((z = ft_find(opts, "SORTBY")) && Z_TYPE_P(z) == IS_ARRAY) { + HashTable* sortby = Z_ARRVAL_P(z); + FT_BUF_LIT(buf, "SORTBY"); + zval* s; + ZEND_HASH_FOREACH_VAL(sortby, s) { + ft_buf_add_zval(buf, s); + } + ZEND_HASH_FOREACH_END(); + } + + if ((z = ft_find(opts, "WITHSORTKEYS")) && zend_is_true(z)) { + FT_BUF_LIT(buf, "WITHSORTKEYS"); + } + + if ((z = ft_find(opts, "TIMEOUT"))) { + FT_BUF_LIT(buf, "TIMEOUT"); + ft_buf_add_zval(buf, z); + } + + if ((z = ft_find(opts, "PARAMS")) && Z_TYPE_P(z) == IS_ARRAY) { + HashTable* params = Z_ARRVAL_P(z); + int nparams = zend_hash_num_elements(params); + FT_BUF_LIT(buf, "PARAMS"); + ft_buf_add_long(buf, nparams * 2); + zend_string* key; + zval* val; + ZEND_HASH_FOREACH_STR_KEY_VAL(params, key, val) { + if (key) { + ft_buf_add(buf, ZSTR_VAL(key), ZSTR_LEN(key)); + } + ft_buf_add_zval(buf, val); + } + ZEND_HASH_FOREACH_END(); + } + + if ((z = ft_find(opts, "LIMIT")) && Z_TYPE_P(z) == IS_ARRAY) { + HashTable* limit = Z_ARRVAL_P(z); + FT_BUF_LIT(buf, "LIMIT"); + zval* v; + ZEND_HASH_FOREACH_VAL(limit, v) { + ft_buf_add_zval(buf, v); + } + ZEND_HASH_FOREACH_END(); + } + + if ((z = ft_find(opts, "ALLSHARDS")) && zend_is_true(z)) { + FT_BUF_LIT(buf, "ALLSHARDS"); + } else if ((z = ft_find(opts, "SOMESHARDS")) && zend_is_true(z)) { + FT_BUF_LIT(buf, "SOMESHARDS"); + } + + if ((z = ft_find(opts, "CONSISTENT")) && zend_is_true(z)) { + FT_BUF_LIT(buf, "CONSISTENT"); + } else if ((z = ft_find(opts, "INCONSISTENT")) && zend_is_true(z)) { + FT_BUF_LIT(buf, "INCONSISTENT"); + } + + if ((z = ft_find(opts, "DIALECT"))) { + FT_BUF_LIT(buf, "DIALECT"); + ft_buf_add_zval(buf, z); + } +} + +/* ==================================================================== + * FT.INFO OPTIONS BUILDER + * + * $options = [ + * 'scope' => 'LOCAL', // 'LOCAL', 'PRIMARY', or 'CLUSTER' + * 'ALLSHARDS' => true, // or 'SOMESHARDS' => true + * 'CONSISTENT' => true, // or 'INCONSISTENT' => true + * ] + * ==================================================================== */ + +static void ft_build_info_options(ft_arg_buf_t* buf, HashTable* opts) { + zval* z; + + if ((z = ft_find(opts, "scope")) && Z_TYPE_P(z) == IS_STRING) { + ft_buf_add(buf, Z_STRVAL_P(z), Z_STRLEN_P(z)); + } + + if ((z = ft_find(opts, "ALLSHARDS")) && zend_is_true(z)) { + FT_BUF_LIT(buf, "ALLSHARDS"); + } else if ((z = ft_find(opts, "SOMESHARDS")) && zend_is_true(z)) { + FT_BUF_LIT(buf, "SOMESHARDS"); + } + + if ((z = ft_find(opts, "CONSISTENT")) && zend_is_true(z)) { + FT_BUF_LIT(buf, "CONSISTENT"); + } else if ((z = ft_find(opts, "INCONSISTENT")) && zend_is_true(z)) { + FT_BUF_LIT(buf, "INCONSISTENT"); + } +} + +/* ==================================================================== + * PUBLIC: build_ft_create_args + * + * Builds the full argument list for FT.CREATE from structured PHP + * arrays and writes the result into the provided output pointers. + * Returns 1 on success, 0 on failure (exception thrown). + * ==================================================================== */ + +int build_ft_create_args(const char* index_name, + size_t index_name_len, + HashTable* schema_ht, + HashTable* options_ht, + const char*** out_strings, + size_t** out_lengths, + int* out_count, + char*** out_allocated, + int* out_alloc_count) { + ft_arg_buf_t buf; + ft_buf_init(&buf); + + /* index name */ + ft_buf_add(&buf, index_name, index_name_len); + + /* options (before SCHEMA) */ + if (options_ht) { + ft_build_create_options(&buf, options_ht); + } + + /* SCHEMA keyword */ + FT_BUF_LIT(&buf, "SCHEMA"); + + /* schema fields */ + zval* field_zv; + ZEND_HASH_FOREACH_VAL(schema_ht, field_zv) { + if (Z_TYPE_P(field_zv) != IS_ARRAY) { + zend_throw_exception(get_valkey_glide_exception_ce(), + "ftCreate: each schema element must be an array", + 0); + ft_buf_destroy(&buf); + return 0; + } + if (!ft_build_field(&buf, Z_ARRVAL_P(field_zv))) { + ft_buf_destroy(&buf); + return 0; + } + } + ZEND_HASH_FOREACH_END(); + + /* Transfer ownership to caller */ + *out_strings = buf.strings; + *out_lengths = buf.lengths; + *out_count = buf.count; + *out_allocated = buf.allocated; + *out_alloc_count = buf.alloc_count; + return 1; +} + +/* ==================================================================== + * PUBLIC: build_ft_search_args + * ==================================================================== */ + +int build_ft_search_args(const char* index_name, + size_t index_name_len, + const char* query, + size_t query_len, + HashTable* options_ht, + const char*** out_strings, + size_t** out_lengths, + int* out_count, + char*** out_allocated, + int* out_alloc_count) { + ft_arg_buf_t buf; + ft_buf_init(&buf); + + ft_buf_add(&buf, index_name, index_name_len); + ft_buf_add(&buf, query, query_len); + + if (options_ht) { + ft_build_search_options(&buf, options_ht); + } + + *out_strings = buf.strings; + *out_lengths = buf.lengths; + *out_count = buf.count; + *out_allocated = buf.allocated; + *out_alloc_count = buf.alloc_count; + return 1; +} + +/* ==================================================================== + * PUBLIC: build_ft_aggregate_args + * + * Builds the argument list for FT.AGGREGATE: index, query, then + * the flat options array appended as-is. + * ==================================================================== */ + +int build_ft_aggregate_args(const char* index_name, + size_t index_name_len, + const char* query, + size_t query_len, + HashTable* options_ht, + const char*** out_strings, + size_t** out_lengths, + int* out_count, + char*** out_allocated, + int* out_alloc_count) { + ft_arg_buf_t buf; + ft_buf_init(&buf); + + ft_buf_add(&buf, index_name, index_name_len); + ft_buf_add(&buf, query, query_len); + + if (options_ht) { + zval* entry; + ZEND_HASH_FOREACH_VAL(options_ht, entry) { + ft_buf_add_zval(&buf, entry); + } + ZEND_HASH_FOREACH_END(); + } + + *out_strings = buf.strings; + *out_lengths = buf.lengths; + *out_count = buf.count; + *out_allocated = buf.allocated; + *out_alloc_count = buf.alloc_count; + return 1; +} + +/* ==================================================================== + * PUBLIC: build_ft_info_args + * ==================================================================== */ + +int build_ft_info_args(const char* index_name, + size_t index_name_len, + HashTable* options_ht, + const char*** out_strings, + size_t** out_lengths, + int* out_count, + char*** out_allocated, + int* out_alloc_count) { + ft_arg_buf_t buf; + ft_buf_init(&buf); + + ft_buf_add(&buf, index_name, index_name_len); + + if (options_ht) { + ft_build_info_options(&buf, options_ht); + } + + *out_strings = buf.strings; + *out_lengths = buf.lengths; + *out_count = buf.count; + *out_allocated = buf.allocated; + *out_alloc_count = buf.alloc_count; + return 1; +} diff --git a/valkey_glide_ft_common.h b/valkey_glide_ft_common.h new file mode 100644 index 00000000..7fd98cc3 --- /dev/null +++ b/valkey_glide_ft_common.h @@ -0,0 +1,308 @@ +/** Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0 */ + +#ifndef VALKEY_GLIDE_FT_COMMON_H +#define VALKEY_GLIDE_FT_COMMON_H + +#include +#include +#include + +#include "command_response.h" +#include "include/glide_bindings.h" +#include "valkey_glide_commands_common.h" + +/* ==================================================================== + * CORE FRAMEWORK FUNCTIONS + * ==================================================================== */ + +/** + * Execute an FT.* command with a dynamic string argument list. + * + * Builds the FFI argument arrays from a C string array, calls execute_command(), + * and converts the response to a PHP zval. + * + * @param glide_client The FFI client pointer. + * @param cmd_type The RequestType enum value (e.g. FtCreate, FtSearch). + * @param strings Array of C strings (arguments). + * @param lengths Array of string lengths. + * @param count Number of arguments. + * @param return_value PHP return value zval. + * @param assoc_flag Associative array flag for response conversion. + * @return 1 on success, 0 on failure. + */ +int execute_ft_command_internal(const void* glide_client, + enum RequestType cmd_type, + const char** strings, + size_t* lengths, + int count, + zval* return_value, + int assoc_flag); + +/* ==================================================================== + * ARGUMENT COLLECTION UTILITIES + * ==================================================================== */ + +/** + * Collect string arguments from a PHP HashTable into C arrays. + * Each element is converted to string. Caller must free via free_ft_collected(). + * + * @param ht The PHP HashTable to iterate. + * @param out_strings Output: array of C string pointers (emalloc'd). + * @param out_lengths Output: array of string lengths (emalloc'd). + * @param out_count Output: number of collected strings. + * @param allocated Output: array of strings that need efree (emalloc'd). + * @param allocated_count Output: number of allocated strings. + * @return 1 on success, 0 on failure. + */ +int collect_ft_array_strings(HashTable* ht, + const char*** out_strings, + size_t** out_lengths, + int* out_count, + char*** allocated, + int* allocated_count); + +/** + * Free resources allocated by collect_ft_array_strings. + */ +void free_ft_collected(const char** strings, + size_t* lengths, + char** allocated, + int allocated_count); + +/* ==================================================================== + * STRUCTURED ARGUMENT BUILDERS + * + * Convert associative PHP arrays into flat string token lists. + * Defined in valkey_glide_ft_common.c. + * ==================================================================== */ + +int build_ft_create_args(const char* index_name, + size_t index_name_len, + HashTable* schema_ht, + HashTable* options_ht, + const char*** out_strings, + size_t** out_lengths, + int* out_count, + char*** out_allocated, + int* out_alloc_count); + +int build_ft_search_args(const char* index_name, + size_t index_name_len, + const char* query, + size_t query_len, + HashTable* options_ht, + const char*** out_strings, + size_t** out_lengths, + int* out_count, + char*** out_allocated, + int* out_alloc_count); + +int build_ft_aggregate_args(const char* index_name, + size_t index_name_len, + const char* query, + size_t query_len, + HashTable* options_ht, + const char*** out_strings, + size_t** out_lengths, + int* out_count, + char*** out_allocated, + int* out_alloc_count); + +int build_ft_info_args(const char* index_name, + size_t index_name_len, + HashTable* options_ht, + const char*** out_strings, + size_t** out_lengths, + int* out_count, + char*** out_allocated, + int* out_alloc_count); + +/* ==================================================================== + * COMMAND EXECUTION FUNCTIONS + * ==================================================================== */ + +int execute_ft_create_command(zval* object, int argc, zval* return_value, zend_class_entry* ce); +int execute_ft_dropindex_command(zval* object, int argc, zval* return_value, zend_class_entry* ce); +int execute_ft_list_command(zval* object, int argc, zval* return_value, zend_class_entry* ce); +int execute_ft_search_command(zval* object, int argc, zval* return_value, zend_class_entry* ce); +int execute_ft_aggregate_command(zval* object, int argc, zval* return_value, zend_class_entry* ce); +int execute_ft_info_command(zval* object, int argc, zval* return_value, zend_class_entry* ce); +int execute_ft_aliasadd_command(zval* object, int argc, zval* return_value, zend_class_entry* ce); +int execute_ft_aliasdel_command(zval* object, int argc, zval* return_value, zend_class_entry* ce); +int execute_ft_aliasupdate_command(zval* object, + int argc, + zval* return_value, + zend_class_entry* ce); +int execute_ft_aliaslist_command(zval* object, int argc, zval* return_value, zend_class_entry* ce); + +/* ==================================================================== + * CONVENIENCE MACROS + * ==================================================================== */ + +/** + * Validate FT client and key + */ +#define VALIDATE_FT_CLIENT(client) \ + if (!(client)) { \ + return 0; \ + } + +/** + * Cleanup collected FT strings if non-NULL + */ +#define CLEANUP_FT_COLLECTED(strings, lengths, allocated, alloc_count) \ + do { \ + if (strings) \ + free_ft_collected(strings, lengths, allocated, alloc_count); \ + } while (0) + +/* ==================================================================== + * FT COMMAND METHOD IMPLEMENTATION MACROS + * ==================================================================== */ + +#define FT_CREATE_METHOD_IMPL(class_name) \ + PHP_METHOD(class_name, ftCreate) { \ + if (execute_ft_create_command(getThis(), \ + ZEND_NUM_ARGS(), \ + return_value, \ + strcmp(#class_name, "ValkeyGlideCluster") == 0 \ + ? get_valkey_glide_cluster_ce() \ + : get_valkey_glide_ce())) { \ + APPLY_REPLY_LITERAL(return_value); \ + return; \ + } \ + zval_dtor(return_value); \ + RETURN_FALSE; \ + } + +#define FT_DROPINDEX_METHOD_IMPL(class_name) \ + PHP_METHOD(class_name, ftDropIndex) { \ + if (execute_ft_dropindex_command(getThis(), \ + ZEND_NUM_ARGS(), \ + return_value, \ + strcmp(#class_name, "ValkeyGlideCluster") == 0 \ + ? get_valkey_glide_cluster_ce() \ + : get_valkey_glide_ce())) { \ + APPLY_REPLY_LITERAL(return_value); \ + return; \ + } \ + zval_dtor(return_value); \ + RETURN_FALSE; \ + } + +#define FT_LIST_METHOD_IMPL(class_name) \ + PHP_METHOD(class_name, ftList) { \ + if (execute_ft_list_command(getThis(), \ + ZEND_NUM_ARGS(), \ + return_value, \ + strcmp(#class_name, "ValkeyGlideCluster") == 0 \ + ? get_valkey_glide_cluster_ce() \ + : get_valkey_glide_ce())) { \ + return; \ + } \ + zval_dtor(return_value); \ + RETURN_FALSE; \ + } + +#define FT_SEARCH_METHOD_IMPL(class_name) \ + PHP_METHOD(class_name, ftSearch) { \ + if (execute_ft_search_command(getThis(), \ + ZEND_NUM_ARGS(), \ + return_value, \ + strcmp(#class_name, "ValkeyGlideCluster") == 0 \ + ? get_valkey_glide_cluster_ce() \ + : get_valkey_glide_ce())) { \ + return; \ + } \ + zval_dtor(return_value); \ + RETURN_FALSE; \ + } + +#define FT_AGGREGATE_METHOD_IMPL(class_name) \ + PHP_METHOD(class_name, ftAggregate) { \ + if (execute_ft_aggregate_command(getThis(), \ + ZEND_NUM_ARGS(), \ + return_value, \ + strcmp(#class_name, "ValkeyGlideCluster") == 0 \ + ? get_valkey_glide_cluster_ce() \ + : get_valkey_glide_ce())) { \ + return; \ + } \ + zval_dtor(return_value); \ + RETURN_FALSE; \ + } + +#define FT_INFO_METHOD_IMPL(class_name) \ + PHP_METHOD(class_name, ftInfo) { \ + if (execute_ft_info_command(getThis(), \ + ZEND_NUM_ARGS(), \ + return_value, \ + strcmp(#class_name, "ValkeyGlideCluster") == 0 \ + ? get_valkey_glide_cluster_ce() \ + : get_valkey_glide_ce())) { \ + return; \ + } \ + zval_dtor(return_value); \ + RETURN_FALSE; \ + } + +#define FT_ALIASADD_METHOD_IMPL(class_name) \ + PHP_METHOD(class_name, ftAliasAdd) { \ + if (execute_ft_aliasadd_command(getThis(), \ + ZEND_NUM_ARGS(), \ + return_value, \ + strcmp(#class_name, "ValkeyGlideCluster") == 0 \ + ? get_valkey_glide_cluster_ce() \ + : get_valkey_glide_ce())) { \ + APPLY_REPLY_LITERAL(return_value); \ + return; \ + } \ + zval_dtor(return_value); \ + RETURN_FALSE; \ + } + +#define FT_ALIASDEL_METHOD_IMPL(class_name) \ + PHP_METHOD(class_name, ftAliasDel) { \ + if (execute_ft_aliasdel_command(getThis(), \ + ZEND_NUM_ARGS(), \ + return_value, \ + strcmp(#class_name, "ValkeyGlideCluster") == 0 \ + ? get_valkey_glide_cluster_ce() \ + : get_valkey_glide_ce())) { \ + APPLY_REPLY_LITERAL(return_value); \ + return; \ + } \ + zval_dtor(return_value); \ + RETURN_FALSE; \ + } + +#define FT_ALIASUPDATE_METHOD_IMPL(class_name) \ + PHP_METHOD(class_name, ftAliasUpdate) { \ + if (execute_ft_aliasupdate_command(getThis(), \ + ZEND_NUM_ARGS(), \ + return_value, \ + strcmp(#class_name, "ValkeyGlideCluster") == 0 \ + ? get_valkey_glide_cluster_ce() \ + : get_valkey_glide_ce())) { \ + APPLY_REPLY_LITERAL(return_value); \ + return; \ + } \ + zval_dtor(return_value); \ + RETURN_FALSE; \ + } + +#define FT_ALIASLIST_METHOD_IMPL(class_name) \ + PHP_METHOD(class_name, ftAliasList) { \ + if (execute_ft_aliaslist_command(getThis(), \ + ZEND_NUM_ARGS(), \ + return_value, \ + strcmp(#class_name, "ValkeyGlideCluster") == 0 \ + ? get_valkey_glide_cluster_ce() \ + : get_valkey_glide_ce())) { \ + return; \ + } \ + zval_dtor(return_value); \ + RETURN_FALSE; \ + } + +#endif /* VALKEY_GLIDE_FT_COMMON_H */ diff --git a/valkey_z_php_methods.c b/valkey_z_php_methods.c index f3b74feb..3e76472a 100644 --- a/valkey_z_php_methods.c +++ b/valkey_z_php_methods.c @@ -14,6 +14,7 @@ #include "command_response.h" /* Include command_response.h for string conversion functions */ #include "valkey_glide_commands_common.h" +#include "valkey_glide_ft_common.h" #include "valkey_glide_geo_common.h" #include "valkey_glide_hash_common.h" /* Include hash command framework */ #include "valkey_glide_list_common.h" @@ -851,3 +852,45 @@ SETOPTION_METHOD_IMPL(ValkeyGlide) /* {{{ proto mixed ValkeyGlide::getOption(int option) */ GETOPTION_METHOD_IMPL(ValkeyGlide) /* }}} */ + +/* {{{ proto string|false ValkeyGlide::ftCreate(string index, array schema, ?array options = null) + */ +FT_CREATE_METHOD_IMPL(ValkeyGlide) +/* }}} */ + +/* {{{ proto string|false ValkeyGlide::ftDropIndex(string index) */ +FT_DROPINDEX_METHOD_IMPL(ValkeyGlide) +/* }}} */ + +/* {{{ proto array|false ValkeyGlide::ftList() */ +FT_LIST_METHOD_IMPL(ValkeyGlide) +/* }}} */ + +/* {{{ proto array|false ValkeyGlide::ftSearch(string index, string query, ?array options = null) */ +FT_SEARCH_METHOD_IMPL(ValkeyGlide) +/* }}} */ + +/* {{{ proto array|false ValkeyGlide::ftAggregate(string index, string query, ?array options = null) + */ +FT_AGGREGATE_METHOD_IMPL(ValkeyGlide) +/* }}} */ + +/* {{{ proto array|false ValkeyGlide::ftInfo(string index, ?array options = null) */ +FT_INFO_METHOD_IMPL(ValkeyGlide) +/* }}} */ + +/* {{{ proto string|false ValkeyGlide::ftAliasAdd(string alias, string index) */ +FT_ALIASADD_METHOD_IMPL(ValkeyGlide) +/* }}} */ + +/* {{{ proto string|false ValkeyGlide::ftAliasDel(string alias) */ +FT_ALIASDEL_METHOD_IMPL(ValkeyGlide) +/* }}} */ + +/* {{{ proto string|false ValkeyGlide::ftAliasUpdate(string alias, string index) */ +FT_ALIASUPDATE_METHOD_IMPL(ValkeyGlide) +/* }}} */ + +/* {{{ proto array|false ValkeyGlide::ftAliasList() */ +FT_ALIASLIST_METHOD_IMPL(ValkeyGlide) +/* }}} */