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)
+/* }}} */