Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `getErrorFile()`, `getErrorLine()`, `getErrorFunction()` provide source location of FFI errors
- Helps debug which operation caused the failure

- **TEST-003: Query Methods — Missing Coverage** (#101)
- Added 8 `.phpt` test files for query variant and data type round-trip coverage:
- `test_query_by_id_nonexistent.phpt` — queryById ZVecException for non-existent/empty docId and fieldName
- `test_vector_int16_roundtrip.phpt` — VECTOR_INT8 insert, fetch, and verification (INT8 used instead of INT16 due to upstream support)
- `test_uint64_roundtrip.phpt` — UINT64 scalar insert, fetch, query, and verification (0, 100, 9999999999, PHP_INT_MAX)
- `test_vector_binary32_roundtrip.phpt` — VECTOR_INT8 insert, fetch, and verification (alternative pattern; INT8 used instead of BINARY32)
- `test_double_roundtrip.phpt` — DOUBLE scalar insert, fetch, query, and verification (π, -2.5e100, etc.)
- `test_query_fp16.phpt` — queryFp16(), queryById, filter, includeVector with half-precision vectors
- `test_query_vector_object.phpt` — queryVector() with setTopk, setIncludeVector, setFilter, setOutputFields, setHnswParams, setRadius
- `test_query_fp64.phpt` — XFAIL: VECTOR_FP64 query operations (upstream limitation in zvec v0.4.0)
- Fixed `queryById()` to also check for FP16 vector fields when resolving query vectors
- Each test uses `try-finally` with `uniqid()` temp directory and cleanup
- Tests skip when native zvec extension is loaded (FFI-only methods)

### Deprecated

- **SMELL-013: Global namespace class names are deprecated** (#94)
Expand Down
16 changes: 16 additions & 0 deletions src/ZVec.php
Original file line number Diff line number Diff line change
Expand Up @@ -2228,10 +2228,20 @@ public function queryById(

$vector = $docs[0]->getVectorFp32($fieldName);
$isFp64 = false;
$isFp16 = false;
$isInt8 = false;
if ($vector === null) {
$vector = $docs[0]->getVectorFp64($fieldName);
$isFp64 = $vector !== null;
}
if ($vector === null) {
$vector = $docs[0]->getVectorFp16($fieldName);
$isFp16 = $vector !== null;
}
if ($vector === null) {
$vector = $docs[0]->getVectorInt8($fieldName);
$isInt8 = $vector !== null;
}
if ($vector === null) {
throw new ZVecException("Vector field '$fieldName' not found in document: $docId");
}
Expand All @@ -2244,6 +2254,12 @@ public function queryById(
);
}

if ($isFp16) {
return $this->queryFp16(
$fieldName, $vector, $topk, $includeVector, $filter
);
}

return $this->query(
$fieldName,
$vector,
Expand Down
104 changes: 104 additions & 0 deletions tests/test_double_roundtrip.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
--TEST--
DOUBLE scalar round-trip: insert, fetch, and query with double-precision floats
--SKIPIF--
<?php
if (extension_loaded('zvec')) die('skip Native zvec extension loaded (use FFI)');
if (!extension_loaded('ffi')) die('skip FFI extension not available');
?>
--FILE--
<?php
require_once __DIR__ . '/../src/ZVec.php';
ZVec::init(logType: ZVec::LOG_CONSOLE, logLevel: ZVec::LOG_WARN);

$path = __DIR__ . '/../test_dbs/double_' . uniqid();
try {
$schema = new ZVecSchema('double_test');
$schema->addInt64('id', nullable: false);
$schema->addDouble('d', nullable: true);
$schema->addVectorFp32('v', dimension: 2, metricType: ZVecSchema::METRIC_COSINE);

$c = ZVec::create($path, $schema);
$c->createHnswIndex('v');

$eps = 1e-10;

// Insert docs with various DOUBLE values
$docs = [
['doc1', 1, 0.0],
['doc2', 2, 3.14159265358979],
['doc3', 3, -2.5e100],
['doc4', 4, 1.23456789],
];

foreach ($docs as [$pk, $id, $dval]) {
$doc = new ZVecDoc($pk);
$doc->setInt64('id', $id)->setDouble('d', $dval)->setVectorFp32('v', [(float)$id, (float)$id * 2]);
$c->insert($doc);
}

$c->flush();
$c->optimize();
echo "Inserted 4 DOUBLE docs\n";

// Helper: fetch doc by PK
$f = function($pk) use ($c) { return $c->fetch($pk)[0]; };

// Test 1: Fetch and verify each doc by PK
$d1 = $f('doc1');
assert(abs($d1->getDouble('d') - 0.0) < $eps, "Expected doc1 d=0.0");
$d2 = $f('doc2');
assert(abs($d2->getDouble('d') - 3.14159265358979) < $eps, "Expected doc2 d=3.14159");
$d3 = $f('doc3');
assert(abs($d3->getDouble('d') - (-2.5e100)) < 1e90, "Expected doc3 d=-2.5e100");
$d4 = $f('doc4');
assert(abs($d4->getDouble('d') - 1.23456789) < $eps, "Expected doc4 d=1.23456789");
echo "Fetched DOUBLE values OK\n";

// Test 2: getFloat returns null for DOUBLE field
assert($d1->getFloat('d') === null, 'Expected null from getFloat on DOUBLE field');
echo "getFloat returns null for DOUBLE field OK\n";

// Test 3: Query and verify results contain correct DOUBLE values
$results = $c->query('v', [1.0, 2.0], topk: 5);
assert(count($results) >= 4, 'Expected at least 4 results');
foreach ($results as $r) {
if ($r->getPk() === 'doc2') {
assert(abs($r->getDouble('d') - 3.14159265358979) < $eps, "Expected doc2 d=3.14159");
}
}
echo "Query results contain correct DOUBLE values OK\n";

// Test 4: hasField and fieldNames includes DOUBLE
assert($d1->hasField('d'), 'Expected hasField true for d');
$fieldNames = $d1->fieldNames();
assert(in_array('d', $fieldNames), 'Expected d in fieldNames');
echo "hasField/fieldNames with DOUBLE OK\n";

// Test 5: Unset DOUBLE field returns null via getter
$docNull = new ZVecDoc('null_test');
$docNull->setInt64('id', 99)->setVectorFp32('v', [99.0, 199.0]);
$c->insert($docNull);
$c->flush();
$dn = $c->fetch('null_test')[0];
assert($dn->getDouble('d') === null, 'Expected null for unset DOUBLE field');
echo "Unset DOUBLE field returns null OK\n";

// Test 6: getDouble on non-existent field returns null
assert($d1->getDouble('nonexistent') === null, 'Expected null for non-existent field');
echo "getDouble on non-existent field returns null OK\n";

echo "ALL TESTS PASSED\n";
} finally {
if (isset($c)) { try { $c->destroy(); } catch (Exception $e) {} }
exec("rm -rf " . escapeshellarg($path));
}
?>
--EXPECT--
Inserted 4 DOUBLE docs
Fetched DOUBLE values OK
getFloat returns null for DOUBLE field OK
Query results contain correct DOUBLE values OK
hasField/fieldNames with DOUBLE OK
Unset DOUBLE field returns null OK
getDouble on non-existent field returns null OK
ALL TESTS PASSED
65 changes: 65 additions & 0 deletions tests/test_query_by_id_nonexistent.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
--TEST--
Query operations: queryById with non-existent document throws ZVecException
--SKIPIF--
<?php
if (extension_loaded('zvec')) die('skip Native zvec extension loaded (use FFI)');
if (!extension_loaded('ffi')) die('skip FFI extension not available');
?>
--FILE--
<?php
require_once __DIR__ . '/../src/ZVec.php';
ZVec::init(logType: ZVec::LOG_CONSOLE, logLevel: ZVec::LOG_WARN);

$path = __DIR__ . '/../test_dbs/qbid_nonexist_' . uniqid();
try {
$schema = new ZVecSchema('test');
$schema->addVectorFp32('v', dimension: 2, metricType: ZVecSchema::METRIC_IP);
$schema->addInt64('id');
$c = ZVec::create($path, $schema);
$c->createHnswIndex('v');

$doc = new ZVecDoc('existing');
$doc->setVectorFp32('v', [1.0, 0.0])->setInt64('id', 1);
$c->insert($doc);
$c->optimize();

// Test 1: queryById with existing doc works
$results = $c->queryById('v', 'existing', topk: 3);
echo 'Existing doc query: ' . count($results) . " results\n";

// Test 2: queryById with non-existent doc throws
try {
$c->queryById('v', 'nonexistent_doc', topk: 3);
echo "FAIL: Expected ZVecException for non-existent doc\n";
} catch (ZVecException $e) {
echo "Non-existent doc throws: " . $e->getMessage() . "\n";
}

// Test 3: queryById with empty docId throws
try {
$c->queryById('v', '', topk: 3);
echo "FAIL: Expected ZVecException for empty docId\n";
} catch (ZVecException $e) {
echo "Empty docId throws: " . $e->getMessage() . "\n";
}

// Test 4: queryById with empty fieldName throws
try {
$c->queryById('', 'existing', topk: 3);
echo "FAIL: Expected ZVecException for empty field name\n";
} catch (ZVecException $e) {
echo "Empty field name throws: " . $e->getMessage() . "\n";
}

echo "DONE\n";
} finally {
if (isset($c)) { try { $c->destroy(); } catch (Exception $e) {} }
exec("rm -rf " . escapeshellarg($path));
}
?>
--EXPECT--
Existing doc query: 1 results
Non-existent doc throws: Document not found: nonexistent_doc
Empty docId throws: Document ID must not be empty
Empty field name throws: Field name must not be empty
DONE
100 changes: 100 additions & 0 deletions tests/test_query_fp16.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
--TEST--
FP16 vector query: queryFp16(), queryById, filter, includeVector with half-precision vectors
--SKIPIF--
<?php
if (extension_loaded('zvec')) die('skip Native zvec extension loaded (use FFI)');
if (!extension_loaded('ffi')) die('skip FFI extension not available');
?>
--FILE--
<?php
require_once __DIR__ . '/../src/ZVec.php';
ZVec::init(logType: ZVec::LOG_CONSOLE, logLevel: ZVec::LOG_WARN);

$path = __DIR__ . '/../test_dbs/fp16_q_' . uniqid();
try {
$schema = new ZVecSchema('fp16_test');
$schema->addVectorFp16('v', dimension: 4, metricType: ZVecSchema::METRIC_IP);
$schema->addInt64('id', nullable: false);
$schema->addString('cat', nullable: true);

$c = ZVec::create($path, $schema);
$c->createHnswIndex('v');

// Insert docs with FP16 vectors (hex values represent half-precision floats)
// 0x3C00=1.0, 0x4000=2.0, 0x4200=3.0, 0x4400=4.0, 0x4800=8.0, 0x4C00=16.0
$doc1 = new ZVecDoc('doc1');
$doc1->setInt64('id', 1)->setString('cat', 'A');
$doc1->setVectorFp16('v', [0x3C00, 0x4000, 0x4200, 0x4400]); // [1.0, 2.0, 3.0, 4.0]

$doc2 = new ZVecDoc('doc2');
$doc2->setInt64('id', 2)->setString('cat', 'B');
$doc2->setVectorFp16('v', [0x3800, 0x3C00, 0x4000, 0x4200]); // [0.5, 1.0, 2.0, 3.0]

$doc3 = new ZVecDoc('doc3');
$doc3->setInt64('id', 3)->setString('cat', 'A');
$doc3->setVectorFp16('v', [0x4800, 0x4C00, 0x3C00, 0x4000]); // [8.0, 16.0, 1.0, 2.0]

$c->insert($doc1, $doc2, $doc3);
$c->flush();
$c->optimize();
echo "Inserted 3 FP16 docs\n";

// Helper: fetch PK
$fetch = function($pk) use ($c) { return $c->fetch($pk)[0]; };

// Test 1: queryFp16() — verify doc1 (identical vector) appears
$results = $c->queryFp16('v', [0x3C00, 0x4000, 0x4200, 0x4400], topk: 3);
assert(count($results) === 3, 'Expected 3 results');
$pks = array_map(fn($d) => $d->getPk(), $results);
assert(in_array('doc1', $pks), 'Expected doc1 in results');
echo "queryFp16 returned results OK\n";

// Test 2: queryFp16 with includeVector
$results = $c->queryFp16('v', [0x3C00, 0x4000, 0x4200, 0x4400], topk: 1, includeVector: true);
assert(count($results) === 1, 'Expected 1 result');
$v = $results[0]->getVectorFp16('v');
assert($v !== null, 'Expected FP16 vector with includeVector');
echo "queryFp16 with includeVector OK\n";

// Test 3: queryFp16 with filter
$results = $c->queryFp16('v', [0x3C00, 0x4000, 0x4200, 0x4400], topk: 3, filter: "cat = 'A'");
assert(count($results) === 2, 'Expected 2 results filtered by cat=A');
foreach ($results as $r) {
assert($r->getString('cat') === 'A', 'All results should have cat=A');
}
echo "queryFp16 with filter OK\n";

// Test 4: queryById with FP16
$results = $c->queryById('v', 'doc1', topk: 3);
assert(count($results) >= 1, 'Expected at least 1 result');
echo "queryById with FP16 OK\n";

// Test 5: Fetch and verify FP16 vectors
$d1 = $fetch('doc1');
$v1 = $d1->getVectorFp16('v');
assert($v1 !== null, 'Expected FP16 vector on fetched');
assert(count($v1) === 4, 'Expected dimension 4');
assert($v1[0] === 0x3C00, "Expected 0x3C00, got {$v1[0]}");
echo "Fetched FP16 vectors OK\n";

// Test 6: hasVector/vectorNames with FP16
assert($d1->hasVector('v'), 'Expected hasVector true');
$vecNames = $d1->vectorNames();
assert(in_array('v', $vecNames), 'Expected v in vectorNames');
echo "hasVector/vectorNames with FP16 OK\n";

echo "ALL TESTS PASSED\n";
} finally {
if (isset($c)) { try { $c->destroy(); } catch (Exception $e) {} }
exec("rm -rf " . escapeshellarg($path));
}
?>
--EXPECT--
Inserted 3 FP16 docs
queryFp16 returned results OK
queryFp16 with includeVector OK
queryFp16 with filter OK
queryById with FP16 OK
Fetched FP16 vectors OK
hasVector/vectorNames with FP16 OK
ALL TESTS PASSED
Loading
Loading