From 75027056f63a32b2080a28fce0a168b25a85aaf6 Mon Sep 17 00:00:00 2001 From: junaid farooq Date: Fri, 12 Sep 2025 12:47:28 +0530 Subject: [PATCH 1/3] feat(store): Integrate supabase into the store package - Adds support for supabase --- examples/.env | 44 +--- examples/rag/supabase.php | 99 +++++++ src/store/src/Bridge/Supabase/Store.php | 157 ++++++++++++ src/store/tests/Bridge/Supabase/StoreTest.php | 241 ++++++++++++++++++ 4 files changed, 503 insertions(+), 38 deletions(-) create mode 100644 examples/rag/supabase.php create mode 100644 src/store/src/Bridge/Supabase/Store.php create mode 100644 src/store/tests/Bridge/Supabase/StoreTest.php diff --git a/examples/.env b/examples/.env index e789184a8..da0fe1824 100644 --- a/examples/.env +++ b/examples/.env @@ -112,41 +112,9 @@ LMSTUDIO_HOST_URL=http://127.0.0.1:1234 QDRANT_HOST=http://127.0.0.1:6333 QDRANT_SERVICE_API_KEY=changeMe -# SurrealDB (store) -SURREALDB_HOST=http://127.0.0.1:8000 -SURREALDB_USER=symfony -SURREALDB_PASS=symfony - -# Neo4J (store) -NEO4J_HOST=http://127.0.0.1:7474 -NEO4J_DATABASE=neo4j -NEO4J_USERNAME=neo4j -NEO4J_PASSWORD=symfonyai - -# Typesense (store) -TYPESENSE_HOST=http://127.0.0.1:8108 -TYPESENSE_API_KEY=changeMe - -# Milvus (store) -MILVUS_HOST=http://127.0.0.1:19530 -MILVUS_API_KEY=root:Milvus -MILVUS_DATABASE=symfony - -# Cloudflare (store) -CLOUDFLARE_ACCOUNT_ID= -CLOUDFLARE_API_KEY= - -# Cerebras -CEREBRAS_API_KEY= - -CHROMADB_HOST=http://127.0.0.1 -CHROMADB_PORT=8001 - -# For using Clickhouse (store) -CLICKHOUSE_HOST=http://symfony:symfony@127.0.0.1:8123 -CLICKHOUSE_DATABASE=symfony -CLICKHOUSE_TABLE=symfony - -# Weaviate (store) -WEAVIATE_HOST=http://127.0.0.1:8080 -WEAVIATE_API_KEY=symfony +SUPABASE_URL= +SUPABASE_API_KEY= +SUPABASE_TABLE= +SUPABASE_VECTOR_FIELD= +SUPABASE_VECTOR_DIMENSION= +SUPABASE_MATCH_FUNCTION= diff --git a/examples/rag/supabase.php b/examples/rag/supabase.php new file mode 100644 index 000000000..9f974e043 --- /dev/null +++ b/examples/rag/supabase.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Agent\Agent; +use Symfony\AI\Agent\Toolbox\AgentProcessor; +use Symfony\AI\Agent\Toolbox\Tool\SimilaritySearch; +use Symfony\AI\Agent\Toolbox\Toolbox; +use Symfony\AI\Fixtures\Movies; +use Symfony\AI\Platform\Bridge\Ollama\Ollama; +use Symfony\AI\Platform\Bridge\Ollama\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Store\Bridge\Supabase\Store; +use Symfony\AI\Store\Document\Loader\InMemoryLoader; +use Symfony\AI\Store\Document\Metadata; +use Symfony\AI\Store\Document\TextDocument; +use Symfony\AI\Store\Document\Vectorizer; +use Symfony\AI\Store\Indexer; +use Symfony\Component\Uid\Uuid; + +require_once dirname(__DIR__).'/bootstrap.php'; + +echo "Make sure you've run the SQL setup from SUPABASE_SETUP.md first!\n\n"; + +$store = new Store( + http: http_client(), + url: env('SUPABASE_URL'), + apiKey: env('SUPABASE_API_KEY'), + table: env('SUPABASE_TABLE'), + vectorFieldName: env('SUPABASE_VECTOR_FIELD'), + vectorDimension: (int) env('SUPABASE_VECTOR_DIMENSION'), + functionName: env('SUPABASE_MATCH_FUNCTION') +); + +$documents = []; + +foreach (Movies::all() as $movie) { + $documents[] = new TextDocument( + id: Uuid::v4(), + content: 'Title: '.$movie['title'].\PHP_EOL.'Director: '.$movie['director'].\PHP_EOL.'Description: '.$movie['description'], + metadata: new Metadata($movie), + ); +} + +$platform = PlatformFactory::create( + env('OLLAMA_HOST_URL') ?? 'http://localhost:11434', + http_client() +); + +$embeddingModel = new Ollama('mxbai-embed-large'); +$vectorizer = new Vectorizer($platform, $embeddingModel); +$loader = new InMemoryLoader($documents); +$indexer = new Indexer($loader, $vectorizer, $store, logger: logger()); +$indexer->index(); + +$chatModel = new Ollama('llama3.2:3b'); + +$similaritySearch = new SimilaritySearch($vectorizer, $store); +$toolbox = new Toolbox([$similaritySearch], logger: logger()); +$processor = new AgentProcessor($toolbox); +$agent = new Agent($platform, $chatModel, [$processor], [$processor], logger: logger()); + +$messages = new MessageBag( + Message::forSystem('Please answer all user questions only using SimilaritySearch function.'), + Message::ofUser('Which movie fits the theme of technology?') +); + +echo "Query: Which movie fits the theme of technology?\n"; +echo "Processing...\n"; + +try { + $result = $agent->call($messages); + echo '✅ Response: '.$result->getContent()."\n\n"; +} catch (Exception $e) { + echo '❌ Error: '.$e->getMessage()."\n\n"; +} + +$messages2 = new MessageBag( + Message::forSystem('Please answer all user questions only using SimilaritySearch function.'), + Message::ofUser('What are some good action movies?') +); + +echo "Query: What are some good action movies?\n"; +echo "Processing...\n"; + +try { + $result2 = $agent->call($messages2); + echo '✅ Response: '.$result2->getContent()."\n\n"; +} catch (Exception $e) { + echo '❌ Error: '.$e->getMessage()."\n\n"; +} diff --git a/src/store/src/Bridge/Supabase/Store.php b/src/store/src/Bridge/Supabase/Store.php new file mode 100644 index 000000000..bfd0012c0 --- /dev/null +++ b/src/store/src/Bridge/Supabase/Store.php @@ -0,0 +1,157 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Store\Bridge\Supabase; + +use Symfony\AI\Platform\Vector\Vector; +use Symfony\AI\Store\Document\Metadata; +use Symfony\AI\Store\Document\VectorDocument; +use Symfony\AI\Store\Exception\InvalidArgumentException; +use Symfony\AI\Store\Exception\RuntimeException; +use Symfony\AI\Store\StoreInterface; +use Symfony\Component\Uid\Uuid; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Junaid Farooq + * + * Requires pgvector extension to be enabled and a pre-configured table/function. + * + * @see https://github.com/pgvector/pgvector + * @see https://supabase.com/docs/guides/ai/vector-columns + * + * Supabase store using REST & RPC. + * + * Note: Unlike Postgres Store, this store requires manual setup of: + * 1. pgvector extension + * 2. Table with vector column + * 3. RPC function for similarity search + * + * This is because Supabase doesn't allow arbitrary SQL execution via REST API. + */ +final readonly class Store implements StoreInterface +{ + public function __construct( + private HttpClientInterface $http, + private string $url, + private string $apiKey, + private string $table = 'documents', + private string $vectorFieldName = 'embedding', + private int $vectorDimension = 1536, + private string $functionName = 'match_documents', + ) { + } + + public function add(VectorDocument ...$documents): void + { + if (0 === \count($documents)) { + return; + } + + $rows = []; + + foreach ($documents as $document) { + if (\count($document->vector->getData()) !== $this->vectorDimension) { + continue; + } + + $rows[] = [ + 'id' => $document->id->toRfc4122(), + $this->vectorFieldName => $document->vector->getData(), + 'metadata' => $document->metadata->getArrayCopy(), + ]; + } + + $chunkSize = 200; + + foreach (array_chunk($rows, $chunkSize) as $chunk) { + $response = $this->http->request( + 'POST', + \sprintf('%s/rest/v1/%s', $this->url, $this->table), + [ + 'headers' => [ + 'apikey' => $this->apiKey, + 'Authorization' => 'Bearer '.$this->apiKey, + 'Content-Type' => 'application/json', + 'Prefer' => 'resolution=merge-duplicates', + ], + 'json' => $chunk, + ] + ); + + if ($response->getStatusCode() >= 400) { + throw new RuntimeException('Supabase insert failed: '.$response->getContent(false)); + } + } + } + + /** + * @param array{ + * max_items?: int, + * limit?: int, + * min_score?: float + * } $options + */ + public function query(Vector $vector, array $options = []): array + { + if (\count($vector->getData()) !== $this->vectorDimension) { + throw new InvalidArgumentException("Vector dimension mismatch: expected {$this->vectorDimension}"); + } + + $matchCount = $options['max_items'] ?? ($options['limit'] ?? 10); + $threshold = $options['min_score'] ?? 0.0; + + $response = $this->http->request( + 'POST', + \sprintf('%s/rest/v1/rpc/%s', $this->url, $this->functionName), + [ + 'headers' => [ + 'apikey' => $this->apiKey, + 'Authorization' => 'Bearer '.$this->apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'query_embedding' => $vector->getData(), + 'match_count' => $matchCount, + 'match_threshold' => $threshold, + ], + ] + ); + + if ($response->getStatusCode() >= 400) { + throw new RuntimeException('Supabase query failed: '.$response->getContent(false)); + } + + $records = json_decode($response->getContent(), true, 512, \JSON_THROW_ON_ERROR); + $documents = []; + + foreach ($records as $record) { + if ( + !isset($record['id'], $record[$this->vectorFieldName], $record['metadata'], $record['score']) + || !\is_string($record['id']) + ) { + continue; + } + + $embedding = \is_array($record[$this->vectorFieldName]) ? $record[$this->vectorFieldName] : json_decode($record[$this->vectorFieldName] ?? '{}', true, 512, \JSON_THROW_ON_ERROR); + $metadata = \is_array($record['metadata']) ? $record['metadata'] : json_decode($record['metadata'] ?? '{}', true, 512, \JSON_THROW_ON_ERROR); + + $documents[] = new VectorDocument( + id: Uuid::fromString($record['id']), + vector: new Vector($embedding), + metadata: new Metadata($metadata), + score: (float) $record['score'], + ); + } + + return $documents; + } +} diff --git a/src/store/tests/Bridge/Supabase/StoreTest.php b/src/store/tests/Bridge/Supabase/StoreTest.php new file mode 100644 index 000000000..8db3c7a60 --- /dev/null +++ b/src/store/tests/Bridge/Supabase/StoreTest.php @@ -0,0 +1,241 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Store\Tests\Bridge\Supabase; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Vector\Vector; +use Symfony\AI\Store\Bridge\Supabase\Store as SupabaseStore; +use Symfony\AI\Store\Document\Metadata; +use Symfony\AI\Store\Document\VectorDocument; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; +use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Component\Uid\Uuid; + +#[CoversClass(SupabaseStore::class)] +class StoreTest extends TestCase +{ + public function testAddThrowsExceptionOnHttpError(): void + { + $httpClient = new MockHttpClient(new MockResponse('Error message', ['http_code' => 400])); + $store = $this->createStore($httpClient); + $doc = new VectorDocument(Uuid::v4(), new Vector([0.1, 0.2]), new Metadata([])); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Supabase insert failed: Error message'); + $store->add($doc); + } + + public function testAddEmptyDocumentsDoesNothing(): void + { + $httpClient = new MockHttpClient(); + $store = $this->createStore($httpClient); + + $store->add(); + + $this->assertSame(0, $httpClient->getRequestsCount()); + } + + public function testAddSingleDocument(): void + { + $httpClient = new MockHttpClient(new MockResponse('', ['http_code' => 201])); + $store = $this->createStore($httpClient, 3); + $doc = new VectorDocument( + Uuid::v4(), + new Vector([0.1, 0.2, 0.3]), + new Metadata(['foo' => 'bar']) + ); + + $store->add($doc); + + $this->assertSame(1, $httpClient->getRequestsCount()); + } + + public function testAddMultipleDocuments(): void + { + $httpClient = new MockHttpClient(new MockResponse('', ['http_code' => 201])); + $store = $this->createStore($httpClient); + $doc1 = new VectorDocument(Uuid::v4(), new Vector([0.1, 0.2]), new Metadata(['a' => '1'])); + $doc2 = new VectorDocument(Uuid::v4(), new Vector([0.3, 0.4]), new Metadata(['b' => '2'])); + + $store->add($doc1, $doc2); + + $this->assertSame(1, $httpClient->getRequestsCount()); + } + + public function testAddSkipsDocumentsWithWrongDimension(): void + { + $httpClient = new MockHttpClient(new MockResponse('', ['http_code' => 201])); + $store = $this->createStore($httpClient); + $validDoc = new VectorDocument(Uuid::v4(), new Vector([0.1, 0.2]), new Metadata(['valid' => true])); + $invalidDoc = new VectorDocument(Uuid::v4(), new Vector([0.1]), new Metadata(['invalid' => true])); + + $store->add($validDoc, $invalidDoc); + + $this->assertSame(1, $httpClient->getRequestsCount()); + } + + public function testQueryThrowsExceptionOnHttpError(): void + { + $httpClient = new MockHttpClient(new MockResponse('Query failed', ['http_code' => 500])); + $store = $this->createStore($httpClient); + $queryVector = new Vector([1.0, 2.0]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Supabase query failed: Query failed'); + $store->query($queryVector); + } + + public function testQueryWithDefaultOptions(): void + { + $httpClient = new MockHttpClient(new JsonMockResponse([])); + $store = $this->createStore($httpClient, 2); + $queryVector = new Vector([1.0, 2.0]); + $store = $this->createStore($httpClient); + $result = $store->query($queryVector); + + $this->assertSame([], $result); + $this->assertSame(1, $httpClient->getRequestsCount()); + } + + public function testQueryHandlesLimitOption(): void + { + $httpClient = new MockHttpClient(new JsonMockResponse([])); + $store = $this->createStore($httpClient); + $queryVector = new Vector([1.0, 2.0]); + $store = $this->createStore($httpClient); + $result = $store->query($queryVector, ['limit' => 1]); + + $this->assertSame([], $result); + $this->assertSame(1, $httpClient->getRequestsCount()); + } + + public function testQueryThrowsExceptionForWrongVectorDimension(): void + { + $httpClient = new MockHttpClient(new JsonMockResponse([])); + $store = $this->createStore($httpClient); + $wrongDimensionVector = new Vector([1.0]); + $store = $this->createStore($httpClient); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Vector dimension mismatch: expected 2'); + $store->query($wrongDimensionVector); + } + + public function testQuerySuccess(): void + { + $uuid = Uuid::v4(); + $expectedResponse = [ + [ + 'id' => $uuid->toRfc4122(), + 'embedding' => '[0.5, 0.6, 0.7]', + 'metadata' => '{"category": "test"}', + 'score' => 0.85, + ], + ]; + $httpClient = new MockHttpClient(new JsonMockResponse($expectedResponse)); + $store = $this->createStore($httpClient, 3); + $queryVector = new Vector([1.0, 2.0, 3.0]); + $result = $store->query($queryVector, ['max_items' => 5, 'min_score' => 0.7]); + + $this->assertCount(1, $result); + $this->assertInstanceOf(VectorDocument::class, $result[0]); + $this->assertTrue($uuid->equals($result[0]->id)); + $this->assertSame([0.5, 0.6, 0.7], $result[0]->vector->getData()); + $this->assertSame(['category' => 'test'], $result[0]->metadata->getArrayCopy()); + $this->assertSame(0.85, $result[0]->score); + $this->assertSame(1, $httpClient->getRequestsCount()); + } + + public function testQueryHandlesMultipleResultsAndMultipleOptions(): void + { + $uuid1 = Uuid::v4(); + $uuid2 = Uuid::v4(); + $expectedResponse = [ + [ + 'id' => $uuid1->toRfc4122(), + 'embedding' => '[0.1, 0.2]', + 'metadata' => '{"type": "first"}', + 'score' => 0.95, + ], + [ + 'id' => $uuid2->toRfc4122(), + 'embedding' => '[0.3, 0.4]', + 'metadata' => '{"type": "second"}', + 'score' => 0.85, + ], + ]; + $httpClient = new MockHttpClient(new JsonMockResponse($expectedResponse)); + $store = $this->createStore($httpClient, 2); + $queryVector = new Vector([1.0, 2.0]); + + $result = $store->query($queryVector, ['max_items' => 2, 'min_score' => 0.8]); + + $this->assertCount(2, $result); + $this->assertInstanceOf(VectorDocument::class, $result[0]); + $this->assertTrue($uuid1->equals($result[0]->id)); + $this->assertSame([0.1, 0.2], $result[0]->vector->getData()); + $this->assertSame(0.95, $result[0]->score); + $this->assertSame(['type' => 'first'], $result[0]->metadata->getArrayCopy()); + $this->assertInstanceOf(VectorDocument::class, $result[1]); + $this->assertTrue($uuid2->equals($result[1]->id)); + $this->assertSame([0.3, 0.4], $result[1]->vector->getData()); + $this->assertSame(0.85, $result[1]->score); + $this->assertSame(['type' => 'second'], $result[1]->metadata->getArrayCopy()); + + $this->assertSame(1, $httpClient->getRequestsCount()); + } + + public function testQueryParsesComplexMetadata(): void + { + $uuid = Uuid::v4(); + $expectedResponse = [ + [ + 'id' => $uuid->toRfc4122(), + 'embedding' => '[0.1, 0.2, 0.3, 0.4]', + 'metadata' => '{"title": "Test Document", "tags": ["ai", "test"], "score": 0.92}', + 'score' => 0.92, + ], + ]; + $httpClient = new MockHttpClient(new JsonMockResponse($expectedResponse)); + $store = $this->createStore($httpClient, 3); + $queryVector = new Vector([1.0, 2.0, 3.0]); + + $result = $store->query($queryVector); + + $document = $result[0]; + $metadata = $document->metadata->getArrayCopy(); + $this->assertCount(1, $result); + $this->assertInstanceOf(VectorDocument::class, $document); + $this->assertTrue($uuid->equals($document->id)); + $this->assertSame([0.1, 0.2, 0.3, 0.4], $document->vector->getData()); + $this->assertSame(0.92, $document->score); + $this->assertSame('Test Document', $metadata['title']); + $this->assertSame(['ai', 'test'], $metadata['tags']); + $this->assertSame(0.92, $metadata['score']); + $this->assertSame(1, $httpClient->getRequestsCount()); + } + + private function createStore(?MockHttpClient $httpClient = null, ?int $vectorDimension = 2): SupabaseStore + { + return new SupabaseStore( + $httpClient, + 'https://test.supabase.co', + 'test-api-key', + 'documents', + 'embedding', + $vectorDimension, + 'match_documents' + ); + } +} From 474313a4eee37b7a8af3a6784a1039d6e38c57c3 Mon Sep 17 00:00:00 2001 From: junaid farooq Date: Fri, 12 Sep 2025 17:01:45 +0530 Subject: [PATCH 2/3] feat(store): Integrate supabase into the store package - Adds bundle integration - Adds an entry into the stores factory for supabase --- examples/commands/stores.php | 10 ++++++ src/ai-bundle/config/options.php | 13 +++++++ src/ai-bundle/src/AiBundle.php | 35 +++++++++++++++++++ .../DependencyInjection/AiBundleTest.php | 10 ++++++ 4 files changed, 68 insertions(+) diff --git a/examples/commands/stores.php b/examples/commands/stores.php index 90480d21a..65f3d2a34 100644 --- a/examples/commands/stores.php +++ b/examples/commands/stores.php @@ -24,6 +24,7 @@ use Symfony\AI\Store\Bridge\Neo4j\Store as Neo4jStore; use Symfony\AI\Store\Bridge\Postgres\Store as PostgresStore; use Symfony\AI\Store\Bridge\Qdrant\Store as QdrantStore; +use Symfony\AI\Store\Bridge\Supabase\Store as SupabaseStore; use Symfony\AI\Store\Bridge\SurrealDb\Store as SurrealDbStore; use Symfony\AI\Store\Bridge\Typesense\Store as TypesenseStore; use Symfony\AI\Store\Bridge\Weaviate\Store as WeaviateStore; @@ -88,6 +89,15 @@ env('QDRANT_SERVICE_API_KEY'), 'symfony', ), + 'supabase' => static fn (): SupabaseStore => new SupabaseStore( + http_client(), + env('SUPABASE_URL'), + env('SUPABASE_API_KEY'), + env('SUPABASE_TABLE'), + env('SUPABASE_VECTOR_FIELD'), + env('SUPABASE_VECTOR_DIMENSION'), + env('SUPABASE_MATCH_FUNCTION'), + ), 'surrealdb' => static fn (): SurrealDbStore => new SurrealDbStore( httpClient: http_client(), endpointUrl: env('SURREALDB_HOST'), diff --git a/src/ai-bundle/config/options.php b/src/ai-bundle/config/options.php index aa2e17254..b025f4a0b 100644 --- a/src/ai-bundle/config/options.php +++ b/src/ai-bundle/config/options.php @@ -487,6 +487,19 @@ ->end() ->end() ->end() + ->arrayNode('supabase') + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->scalarNode('url')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('api_key')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('table')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('vector_field')->end() + ->integerNode('vector_dimension')->end() + ->scalarNode('function_name')->end() + ->end() + ->end() + ->end() ->arrayNode('typesense') ->useAttributeAsKey('name') ->arrayPrototype() diff --git a/src/ai-bundle/src/AiBundle.php b/src/ai-bundle/src/AiBundle.php index e5f48dc47..8860b8b48 100644 --- a/src/ai-bundle/src/AiBundle.php +++ b/src/ai-bundle/src/AiBundle.php @@ -66,6 +66,7 @@ use Symfony\AI\Store\Bridge\Neo4j\Store as Neo4jStore; use Symfony\AI\Store\Bridge\Pinecone\Store as PineconeStore; use Symfony\AI\Store\Bridge\Qdrant\Store as QdrantStore; +use Symfony\AI\Store\Bridge\Supabase\Store as SupabaseStore; use Symfony\AI\Store\Bridge\SurrealDb\Store as SurrealDbStore; use Symfony\AI\Store\Bridge\Typesense\Store as TypesenseStore; use Symfony\AI\Store\Bridge\Weaviate\Store as WeaviateStore; @@ -1095,6 +1096,40 @@ private function processStoreConfig(string $type, array $stores, ContainerBuilde $container->setDefinition('ai.store.'.$type.'.'.$name, $definition); } } + + if ('supabase' === $type) { + foreach ($stores as $name => $store) { + $arguments = [ + new Reference('http_client'), + $store['url'], + $store['api_key'], + ]; + + if (\array_key_exists('table', $store)) { + $arguments[3] = $store['table']; + } + + if (\array_key_exists('vector_field', $store)) { + $arguments[4] = $store['vector_field']; + } + + if (\array_key_exists('vector_dimension', $store)) { + $arguments[5] = $store['vector_dimension']; + } + + if (\array_key_exists('function_name', $store)) { + $arguments[6] = $store['function_name']; + } + + $definition = new Definition(SupabaseStore::class); + $definition + ->addTag('ai.store') + ->setArguments($arguments); + + $container->setDefinition('ai.store.supabase.'.$name, $definition); + $container->registerAliasForArgument('ai.store.'.$name, StoreInterface::class, (new Target($name.'Store'))->getParsedName()); + } + } } /** diff --git a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php index 9864b80be..09afcfb7c 100644 --- a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php +++ b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php @@ -2181,6 +2181,16 @@ private function getFullConfig(): array 'namespaced_user' => true, ], ], + 'supabase' => [ + 'my_supabase_store' => [ + 'url' => 'https://test.supabase.co', + 'api_key' => 'supabase_test_key', + 'table' => 'my_supabase_table', + 'vector_field' => 'my_embedding', + 'vector_dimension' => 1024, + 'function_name' => 'my_match_function', + ], + ], 'typesense' => [ 'my_typesense_store' => [ 'endpoint' => 'http://localhost:8108', From f53dfe50ae07882dfe2c6c8460ffb7a50447a9be Mon Sep 17 00:00:00 2001 From: junaid farooq Date: Fri, 12 Sep 2025 17:31:04 +0530 Subject: [PATCH 3/3] docs(store): Integrate supabase into the store package - Adds documentation for the new store integration --- examples/.env | 40 +++ examples/commands/stores.php | 10 - examples/rag/supabase.php | 42 +--- src/ai-bundle/config/options.php | 7 +- src/ai-bundle/doc/index.rst | 6 +- src/ai-bundle/src/AiBundle.php | 2 +- src/store/CHANGELOG.md | 1 + src/store/composer.json | 1 + src/store/doc/bridges/supabase.rst | 231 ++++++++++++++++++ src/store/doc/index.rst | 4 + src/store/src/Bridge/Supabase/Store.php | 33 +-- src/store/tests/Bridge/Supabase/StoreTest.php | 60 +++-- 12 files changed, 334 insertions(+), 103 deletions(-) create mode 100644 src/store/doc/bridges/supabase.rst diff --git a/examples/.env b/examples/.env index da0fe1824..b658ccf35 100644 --- a/examples/.env +++ b/examples/.env @@ -112,6 +112,46 @@ LMSTUDIO_HOST_URL=http://127.0.0.1:1234 QDRANT_HOST=http://127.0.0.1:6333 QDRANT_SERVICE_API_KEY=changeMe +# SurrealDB (store) +SURREALDB_HOST=http://127.0.0.1:8000 +SURREALDB_USER=symfony +SURREALDB_PASS=symfony + +# Neo4J (store) +NEO4J_HOST=http://127.0.0.1:7474 +NEO4J_DATABASE=neo4j +NEO4J_USERNAME=neo4j +NEO4J_PASSWORD=symfonyai + +# Typesense (store) +TYPESENSE_HOST=http://127.0.0.1:8108 +TYPESENSE_API_KEY=changeMe + +# Milvus (store) +MILVUS_HOST=http://127.0.0.1:19530 +MILVUS_API_KEY=root:Milvus +MILVUS_DATABASE=symfony + +# Cloudflare (store) +CLOUDFLARE_ACCOUNT_ID= +CLOUDFLARE_API_KEY= + +# Cerebras +CEREBRAS_API_KEY= + +CHROMADB_HOST=http://127.0.0.1 +CHROMADB_PORT=8001 + +# For using Clickhouse (store) +CLICKHOUSE_HOST=http://symfony:symfony@127.0.0.1:8123 +CLICKHOUSE_DATABASE=symfony +CLICKHOUSE_TABLE=symfony + +# Weaviate (store) +WEAVIATE_HOST=http://127.0.0.1:8080 +WEAVIATE_API_KEY=symfony + +# Supabase (store) SUPABASE_URL= SUPABASE_API_KEY= SUPABASE_TABLE= diff --git a/examples/commands/stores.php b/examples/commands/stores.php index 65f3d2a34..90480d21a 100644 --- a/examples/commands/stores.php +++ b/examples/commands/stores.php @@ -24,7 +24,6 @@ use Symfony\AI\Store\Bridge\Neo4j\Store as Neo4jStore; use Symfony\AI\Store\Bridge\Postgres\Store as PostgresStore; use Symfony\AI\Store\Bridge\Qdrant\Store as QdrantStore; -use Symfony\AI\Store\Bridge\Supabase\Store as SupabaseStore; use Symfony\AI\Store\Bridge\SurrealDb\Store as SurrealDbStore; use Symfony\AI\Store\Bridge\Typesense\Store as TypesenseStore; use Symfony\AI\Store\Bridge\Weaviate\Store as WeaviateStore; @@ -89,15 +88,6 @@ env('QDRANT_SERVICE_API_KEY'), 'symfony', ), - 'supabase' => static fn (): SupabaseStore => new SupabaseStore( - http_client(), - env('SUPABASE_URL'), - env('SUPABASE_API_KEY'), - env('SUPABASE_TABLE'), - env('SUPABASE_VECTOR_FIELD'), - env('SUPABASE_VECTOR_DIMENSION'), - env('SUPABASE_MATCH_FUNCTION'), - ), 'surrealdb' => static fn (): SurrealDbStore => new SurrealDbStore( httpClient: http_client(), endpointUrl: env('SURREALDB_HOST'), diff --git a/examples/rag/supabase.php b/examples/rag/supabase.php index 9f974e043..48d6f7354 100644 --- a/examples/rag/supabase.php +++ b/examples/rag/supabase.php @@ -28,16 +28,10 @@ require_once dirname(__DIR__).'/bootstrap.php'; -echo "Make sure you've run the SQL setup from SUPABASE_SETUP.md first!\n\n"; - $store = new Store( - http: http_client(), + httpClient: http_client(), url: env('SUPABASE_URL'), apiKey: env('SUPABASE_API_KEY'), - table: env('SUPABASE_TABLE'), - vectorFieldName: env('SUPABASE_VECTOR_FIELD'), - vectorDimension: (int) env('SUPABASE_VECTOR_DIMENSION'), - functionName: env('SUPABASE_MATCH_FUNCTION') ); $documents = []; @@ -51,49 +45,25 @@ functionName: env('SUPABASE_MATCH_FUNCTION') } $platform = PlatformFactory::create( - env('OLLAMA_HOST_URL') ?? 'http://localhost:11434', + env('OLLAMA_HOST_URL'), http_client() ); -$embeddingModel = new Ollama('mxbai-embed-large'); -$vectorizer = new Vectorizer($platform, $embeddingModel); +$vectorizer = new Vectorizer($platform, new Ollama('mxbai-embed-large')); $loader = new InMemoryLoader($documents); $indexer = new Indexer($loader, $vectorizer, $store, logger: logger()); $indexer->index(); -$chatModel = new Ollama('llama3.2:3b'); - $similaritySearch = new SimilaritySearch($vectorizer, $store); $toolbox = new Toolbox([$similaritySearch], logger: logger()); $processor = new AgentProcessor($toolbox); -$agent = new Agent($platform, $chatModel, [$processor], [$processor], logger: logger()); +$agent = new Agent($platform, new Ollama('llama3.2:3b'), [$processor], [$processor], logger: logger()); $messages = new MessageBag( Message::forSystem('Please answer all user questions only using SimilaritySearch function.'), Message::ofUser('Which movie fits the theme of technology?') ); -echo "Query: Which movie fits the theme of technology?\n"; -echo "Processing...\n"; +$result = $agent->call($messages); -try { - $result = $agent->call($messages); - echo '✅ Response: '.$result->getContent()."\n\n"; -} catch (Exception $e) { - echo '❌ Error: '.$e->getMessage()."\n\n"; -} - -$messages2 = new MessageBag( - Message::forSystem('Please answer all user questions only using SimilaritySearch function.'), - Message::ofUser('What are some good action movies?') -); - -echo "Query: What are some good action movies?\n"; -echo "Processing...\n"; - -try { - $result2 = $agent->call($messages2); - echo '✅ Response: '.$result2->getContent()."\n\n"; -} catch (Exception $e) { - echo '❌ Error: '.$e->getMessage()."\n\n"; -} +echo $result->getContent().\PHP_EOL; diff --git a/src/ai-bundle/config/options.php b/src/ai-bundle/config/options.php index b025f4a0b..cff0e6a12 100644 --- a/src/ai-bundle/config/options.php +++ b/src/ai-bundle/config/options.php @@ -491,9 +491,14 @@ ->useAttributeAsKey('name') ->arrayPrototype() ->children() + ->stringNode('http_client') + ->cannotBeEmpty() + ->defaultValue('http_client') + ->info('Service ID of the HTTP client to use') + ->end() ->scalarNode('url')->isRequired()->cannotBeEmpty()->end() ->scalarNode('api_key')->isRequired()->cannotBeEmpty()->end() - ->scalarNode('table')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('table')->end() ->scalarNode('vector_field')->end() ->integerNode('vector_dimension')->end() ->scalarNode('function_name')->end() diff --git a/src/ai-bundle/doc/index.rst b/src/ai-bundle/doc/index.rst index 0d867a839..e72788540 100644 --- a/src/ai-bundle/doc/index.rst +++ b/src/ai-bundle/doc/index.rst @@ -148,7 +148,7 @@ Configuration HTTP Client Configuration ------------------------- -Each platform can be configured with a custom HTTP client service to handle API requests. +Each platform can be configured with a custom HTTP client service to handle API requests. This allows you to customize timeouts, proxy settings, SSL configurations, and other HTTP-specific options. By default, all platforms use the standard Symfony HTTP client service (``http_client``): @@ -237,7 +237,7 @@ The system prompt text will be automatically translated using the configured tra Memory Provider Configuration ----------------------------- -Memory providers allow agents to access and utilize conversation history and context from previous interactions. +Memory providers allow agents to access and utilize conversation history and context from previous interactions. This enables agents to maintain context across conversations and provide more personalized responses. **Static Memory (Simple)** @@ -292,7 +292,7 @@ Memory can work independently or alongside the system prompt: model: class: 'Symfony\AI\Platform\Bridge\OpenAi\Gpt' memory: 'You are a helpful assistant with conversation history' - + # Agent with both memory and prompt (memory prepended to prompt) memory_and_prompt_agent: model: diff --git a/src/ai-bundle/src/AiBundle.php b/src/ai-bundle/src/AiBundle.php index 8860b8b48..e75594a30 100644 --- a/src/ai-bundle/src/AiBundle.php +++ b/src/ai-bundle/src/AiBundle.php @@ -1100,7 +1100,7 @@ private function processStoreConfig(string $type, array $stores, ContainerBuilde if ('supabase' === $type) { foreach ($stores as $name => $store) { $arguments = [ - new Reference('http_client'), + isset($store['http_client']) ? new Reference($store['http_client']) : new Definition(HttpClientInterface::class), $store['url'], $store['api_key'], ]; diff --git a/src/store/CHANGELOG.md b/src/store/CHANGELOG.md index 1c3543789..374c207f5 100644 --- a/src/store/CHANGELOG.md +++ b/src/store/CHANGELOG.md @@ -45,6 +45,7 @@ CHANGELOG - Pinecone - PostgreSQL with pgvector extension - Qdrant + - Supabase - SurrealDB - Typesense - Weaviate diff --git a/src/store/composer.json b/src/store/composer.json index 2c37d7f5f..2d043ca01 100644 --- a/src/store/composer.json +++ b/src/store/composer.json @@ -17,6 +17,7 @@ "pinecone", "postgres", "qdrant", + "supabase", "surrealdb", "typesense", "weaviate" diff --git a/src/store/doc/bridges/supabase.rst b/src/store/doc/bridges/supabase.rst new file mode 100644 index 000000000..2422020ad --- /dev/null +++ b/src/store/doc/bridges/supabase.rst @@ -0,0 +1,231 @@ +Supabase Bridge +=============== + +The Supabase bridge provides vector storage capabilities using Supabase's pgvector extension through the REST API. + +.. note:: + +* Unlike the Postgres Store, the Supabase Store requires manual setup of the database schema +* because Supabase doesn't allow arbitrary SQL execution via REST API. + +Installation +------------ + +The Supabase bridge requires the pgvector extension and pre-configured database objects. + +Requirements +~~~~~~~~~~~~ + +* Supabase project with pgvector extension enabled +* Pre-configured table with vector column +* Pre-configured RPC function for similarity search + +Database Setup +-------------- + +Execute the following SQL commands in your Supabase SQL Editor: + +Enable ``pgvector`` extension +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: sql + + CREATE EXTENSION IF NOT EXISTS vector; + +Create the `documents` table +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: sql + + CREATE TABLE IF NOT EXISTS documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + embedding vector(1536) NOT NULL, + metadata JSONB + ); + +Create the similarity search function +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: sql + + CREATE OR REPLACE FUNCTION match_documents( + query_embedding vector(1536), + match_count int DEFAULT 10, + match_threshold float DEFAULT 0.0 + ) + RETURNS TABLE ( + id UUID, + embedding vector, + metadata JSONB, + score float + ) + LANGUAGE sql + AS $$ + SELECT + documents.id, + documents.embedding, + documents.metadata, + 1- (documents.embedding <=> query_embedding) AS score + FROM documents + WHERE 1- (documents.embedding <=> query_embedding) >= match_threshold + ORDER BY documents.embedding <=> query_embedding ASC + LIMIT match_count; + $$; + +Create an index for better performance +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: sql + + CREATE INDEX IF NOT EXISTS documents_embedding_idx + ON documents USING ivfflat (embedding vector_cosine_ops); + +Configuration +------------- + +Basic Configuration +~~~~~~~~~~~~~~~~~~~ + +.. code-block:: php + + use Symfony\AI\Store\Bridge\Supabase\Store; + use Symfony\Component\HttpClient\HttpClient; + + $store = new Store( + HttpClient::create(), + 'https://your-project.supabase.co', + 'your-anon-key', + 'documents', // table name + 'embedding', // vector field name + 1536, // vector dimension + 'match_documents' // function name + ); + +Bundle Configuration +~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: yaml + + # config/packages/ai.yaml + ai: + store: + supabase: + my_supabase_store: + url: 'https://your-project.supabase.co' + api_key: '%env(SUPABASE_API_KEY)%' + table: 'documents' + vector_field: 'embedding' + vector_dimension: 1536 + function_name: 'match_documents' + +Environment Variables +~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: bash + + # .env.local + SUPABASE_URL=https://your-project.supabase.co + SUPABASE_API_KEY=your-supabase-anon-key + +Usage +----- + +Adding Documents +~~~~~~~~~~~~~~~~ + +.. code-block:: php + + use Symfony\AI\Platform\Vector\Vector; + use Symfony\AI\Store\Document\Metadata; + use Symfony\AI\Store\Document\VectorDocument; + use Symfony\Component\Uid\Uuid; + + $document = new VectorDocument( + Uuid::v4(), + new Vector([0.1, 0.2, 0.3, /* ... 1536 dimensions */]), + new Metadata(['title' => 'My Document', 'category' => 'example']) + ); + + $store->add($document); + +Querying Documents +~~~~~~~~~~~~~~~~~~ + +.. code-block:: php + + $queryVector = new Vector([0.1, 0.2, 0.3, /* ... 1536 dimensions */]); + + $results = $store->query($queryVector, [ + 'max_items' => 10, + 'min_score' => 0.7 + ]); + + foreach ($results as $document) { + echo "ID: " . $document->id . "\n"; + echo "Score: " . $document->score . "\n"; + echo "Metadata: " . json_encode($document->metadata->getArrayCopy()) . "\n"; + } + +Customization +------------- + +You can customize the Supabase setup for different requirements: + +Table Name +~~~~~~~~~~ + +Change ``documents`` to your preferred table name in both the SQL setup and configuration. + +Vector Field Name +~~~~~~~~~~~~~~~~~ + +Change ``embedding`` to your preferred field name in both the SQL setup and configuration. + +Vector Dimension +~~~~~~~~~~~~~~~~ + +Change ``1536`` to match your embedding model's dimensions in both the SQL setup and configuration. + +Distance Metric +~~~~~~~~~~~~~~~ + +* Cosine: ``<=>`` (default, recommended for most embeddings) +* Euclidean: ``<->`` +* Inner Product: ``<#>`` + +Index Type +~~~~~~~~~~ + +* ``ivfflat``: Good balance of speed and accuracy +* ``hnsw``: Better for high-dimensional vectors (requires PostgreSQL 14+) + +Limitations +----------- + +* Manual schema setup required (no automatic table creation) +* Limited to Supabase's REST API capabilities +* Requires pre-configured RPC functions for complex queries +* Vector dimension must be consistent across all documents + +Performance Considerations +-------------------------- + +* Use appropriate index types based on your vector dimensions +* Consider using ``hnsw`` indexes for high-dimensional vectors +* Batch document insertions when possible (up to 200 documents per request) +* Monitor your Supabase usage limits and quotas + +Security Considerations +----------------------- + +* Use row-level security (RLS) policies if needed +* Consider using service role keys for server-side operations +* Validate vector dimensions in your application code +* Implement proper error handling for API failures + +Additional Resources +-------------------- + +* [Supabase Vector Documentation](https://supabase.com/docs/guides/ai/vector-columns) +* [pgvector Documentation](https://github.com/pgvector/pgvector) +* [Symfony AI Store Documentation](../../../README.md) diff --git a/src/store/doc/index.rst b/src/store/doc/index.rst index f4a5f20cd..1864216fe 100644 --- a/src/store/doc/index.rst +++ b/src/store/doc/index.rst @@ -50,6 +50,7 @@ You can find more advanced usage in combination with an Agent using the store fo * `Similarity Search with Symfony Cache (RAG)`_ * `Similarity Search with Typesense (RAG)`_ * `Similarity Search with Weaviate (RAG)`_ +* `Similarity Search with Supabase (RAG)`_ .. note:: @@ -72,6 +73,7 @@ Supported Stores * `Pinecone`_ (requires `probots-io/pinecone-php` as additional dependency) * `Postgres`_ (requires `ext-pdo`) * `Qdrant`_ +* `Supabase`_ (requires manual database setup) * `SurrealDB`_ * `Symfony Cache`_ * `Typesense`_ @@ -141,6 +143,7 @@ This leads to a store implementing two methods:: .. _`Similarity Search with Qdrant (RAG)`: https://github.com/symfony/ai/blob/main/examples/rag/qdrant.php .. _`Similarity Search with SurrealDB (RAG)`: https://github.com/symfony/ai/blob/main/examples/rag/surrealdb.php .. _`Similarity Search with Typesense (RAG)`: https://github.com/symfony/ai/blob/main/examples/rag/typesense.php +.. _`Similarity Search with Supabase (RAG)`: https://github.com/symfony/ai/blob/main/examples/rag/supabase.php .. _`Similarity Search with Weaviate (RAG)`: https://github.com/symfony/ai/blob/main/examples/rag/weaviate.php .. _`Azure AI Search`: https://azure.microsoft.com/products/ai-services/ai-search .. _`Chroma`: https://www.trychroma.com/ @@ -159,3 +162,4 @@ This leads to a store implementing two methods:: .. _`GitHub`: https://github.com/symfony/ai/issues/16 .. _`Symfony Cache`: https://symfony.com/doc/current/components/cache.html .. _`Weaviate`: https://weaviate.io/ +.. _`Supabase`: https://https://supabase.com/ diff --git a/src/store/src/Bridge/Supabase/Store.php b/src/store/src/Bridge/Supabase/Store.php index bfd0012c0..f1043c82d 100644 --- a/src/store/src/Bridge/Supabase/Store.php +++ b/src/store/src/Bridge/Supabase/Store.php @@ -23,24 +23,20 @@ /** * @author Junaid Farooq * - * Requires pgvector extension to be enabled and a pre-configured table/function. + * Supabase vector store implementation using REST API and pgvector. * - * @see https://github.com/pgvector/pgvector - * @see https://supabase.com/docs/guides/ai/vector-columns + * This store provides vector storage capabilities through Supabase's REST API + * with pgvector extension support. Unlike direct PostgreSQL access, this implementation + * requires manual database setup since Supabase doesn't allow arbitrary SQL execution + * via REST API. * - * Supabase store using REST & RPC. - * - * Note: Unlike Postgres Store, this store requires manual setup of: - * 1. pgvector extension - * 2. Table with vector column - * 3. RPC function for similarity search - * - * This is because Supabase doesn't allow arbitrary SQL execution via REST API. + * @see https://github.com/pgvector/pgvector pgvector extension documentation + * @see https://supabase.com/docs/guides/ai/vector-columns Supabase vector guide */ final readonly class Store implements StoreInterface { public function __construct( - private HttpClientInterface $http, + private HttpClientInterface $httpClient, private string $url, private string $apiKey, private string $table = 'documents', @@ -73,7 +69,7 @@ public function add(VectorDocument ...$documents): void $chunkSize = 200; foreach (array_chunk($rows, $chunkSize) as $chunk) { - $response = $this->http->request( + $response = $this->httpClient->request( 'POST', \sprintf('%s/rest/v1/%s', $this->url, $this->table), [ @@ -103,13 +99,13 @@ public function add(VectorDocument ...$documents): void public function query(Vector $vector, array $options = []): array { if (\count($vector->getData()) !== $this->vectorDimension) { - throw new InvalidArgumentException("Vector dimension mismatch: expected {$this->vectorDimension}"); + throw new InvalidArgumentException("Vector dimension mismatch: expected {$this->vectorDimension}."); } $matchCount = $options['max_items'] ?? ($options['limit'] ?? 10); $threshold = $options['min_score'] ?? 0.0; - $response = $this->http->request( + $response = $this->httpClient->request( 'POST', \sprintf('%s/rest/v1/rpc/%s', $this->url, $this->functionName), [ @@ -134,15 +130,12 @@ public function query(Vector $vector, array $options = []): array $documents = []; foreach ($records as $record) { - if ( - !isset($record['id'], $record[$this->vectorFieldName], $record['metadata'], $record['score']) - || !\is_string($record['id']) - ) { + if (!isset($record['id'], $record[$this->vectorFieldName], $record['metadata'], $record['score']) || !\is_string($record['id'])) { continue; } $embedding = \is_array($record[$this->vectorFieldName]) ? $record[$this->vectorFieldName] : json_decode($record[$this->vectorFieldName] ?? '{}', true, 512, \JSON_THROW_ON_ERROR); - $metadata = \is_array($record['metadata']) ? $record['metadata'] : json_decode($record['metadata'] ?? '{}', true, 512, \JSON_THROW_ON_ERROR); + $metadata = \is_array($record['metadata']) ? $record['metadata'] : json_decode($record['metadata'], true, 512, \JSON_THROW_ON_ERROR); $documents[] = new VectorDocument( id: Uuid::fromString($record['id']), diff --git a/src/store/tests/Bridge/Supabase/StoreTest.php b/src/store/tests/Bridge/Supabase/StoreTest.php index 8db3c7a60..21cb73504 100644 --- a/src/store/tests/Bridge/Supabase/StoreTest.php +++ b/src/store/tests/Bridge/Supabase/StoreTest.php @@ -17,6 +17,7 @@ use Symfony\AI\Store\Bridge\Supabase\Store as SupabaseStore; use Symfony\AI\Store\Document\Metadata; use Symfony\AI\Store\Document\VectorDocument; +use Symfony\AI\Store\Exception\RuntimeException; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\JsonMockResponse; use Symfony\Component\HttpClient\Response\MockResponse; @@ -25,7 +26,7 @@ #[CoversClass(SupabaseStore::class)] class StoreTest extends TestCase { - public function testAddThrowsExceptionOnHttpError(): void + public function testAddThrowsExceptionOnHttpError() { $httpClient = new MockHttpClient(new MockResponse('Error message', ['http_code' => 400])); $store = $this->createStore($httpClient); @@ -36,7 +37,7 @@ public function testAddThrowsExceptionOnHttpError(): void $store->add($doc); } - public function testAddEmptyDocumentsDoesNothing(): void + public function testAddEmptyDocumentsDoesNothing() { $httpClient = new MockHttpClient(); $store = $this->createStore($httpClient); @@ -46,7 +47,7 @@ public function testAddEmptyDocumentsDoesNothing(): void $this->assertSame(0, $httpClient->getRequestsCount()); } - public function testAddSingleDocument(): void + public function testAddSingleDocument() { $httpClient = new MockHttpClient(new MockResponse('', ['http_code' => 201])); $store = $this->createStore($httpClient, 3); @@ -61,66 +62,64 @@ public function testAddSingleDocument(): void $this->assertSame(1, $httpClient->getRequestsCount()); } - public function testAddMultipleDocuments(): void + public function testAddMultipleDocuments() { $httpClient = new MockHttpClient(new MockResponse('', ['http_code' => 201])); $store = $this->createStore($httpClient); - $doc1 = new VectorDocument(Uuid::v4(), new Vector([0.1, 0.2]), new Metadata(['a' => '1'])); - $doc2 = new VectorDocument(Uuid::v4(), new Vector([0.3, 0.4]), new Metadata(['b' => '2'])); - $store->add($doc1, $doc2); + $store->add( + new VectorDocument(Uuid::v4(), new Vector([0.1, 0.2]), new Metadata(['a' => '1'])), + new VectorDocument(Uuid::v4(), new Vector([0.3, 0.4]), new Metadata(['b' => '2'])), + ); $this->assertSame(1, $httpClient->getRequestsCount()); } - public function testAddSkipsDocumentsWithWrongDimension(): void + public function testAddSkipsDocumentsWithWrongDimension() { $httpClient = new MockHttpClient(new MockResponse('', ['http_code' => 201])); $store = $this->createStore($httpClient); - $validDoc = new VectorDocument(Uuid::v4(), new Vector([0.1, 0.2]), new Metadata(['valid' => true])); - $invalidDoc = new VectorDocument(Uuid::v4(), new Vector([0.1]), new Metadata(['invalid' => true])); - $store->add($validDoc, $invalidDoc); + $store->add( + new VectorDocument(Uuid::v4(), new Vector([0.1, 0.2]), new Metadata(['valid' => true])), + new VectorDocument(Uuid::v4(), new Vector([0.1]), new Metadata(['invalid' => true])), + ); $this->assertSame(1, $httpClient->getRequestsCount()); } - public function testQueryThrowsExceptionOnHttpError(): void + public function testQueryThrowsExceptionOnHttpError() { $httpClient = new MockHttpClient(new MockResponse('Query failed', ['http_code' => 500])); $store = $this->createStore($httpClient); $queryVector = new Vector([1.0, 2.0]); - $this->expectException(\RuntimeException::class); + $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Supabase query failed: Query failed'); $store->query($queryVector); } - public function testQueryWithDefaultOptions(): void + public function testQueryWithDefaultOptions() { $httpClient = new MockHttpClient(new JsonMockResponse([])); - $store = $this->createStore($httpClient, 2); - $queryVector = new Vector([1.0, 2.0]); $store = $this->createStore($httpClient); - $result = $store->query($queryVector); + $result = $store->query(new Vector([1.0, 2.0])); $this->assertSame([], $result); $this->assertSame(1, $httpClient->getRequestsCount()); } - public function testQueryHandlesLimitOption(): void + public function testQueryHandlesLimitOption() { $httpClient = new MockHttpClient(new JsonMockResponse([])); $store = $this->createStore($httpClient); - $queryVector = new Vector([1.0, 2.0]); - $store = $this->createStore($httpClient); - $result = $store->query($queryVector, ['limit' => 1]); + $result = $store->query(new Vector([1.0, 2.0]), ['limit' => 1]); $this->assertSame([], $result); $this->assertSame(1, $httpClient->getRequestsCount()); } - public function testQueryThrowsExceptionForWrongVectorDimension(): void + public function testQueryThrowsExceptionForWrongVectorDimension() { $httpClient = new MockHttpClient(new JsonMockResponse([])); $store = $this->createStore($httpClient); @@ -132,7 +131,7 @@ public function testQueryThrowsExceptionForWrongVectorDimension(): void $store->query($wrongDimensionVector); } - public function testQuerySuccess(): void + public function testQuerySuccess() { $uuid = Uuid::v4(); $expectedResponse = [ @@ -145,8 +144,7 @@ public function testQuerySuccess(): void ]; $httpClient = new MockHttpClient(new JsonMockResponse($expectedResponse)); $store = $this->createStore($httpClient, 3); - $queryVector = new Vector([1.0, 2.0, 3.0]); - $result = $store->query($queryVector, ['max_items' => 5, 'min_score' => 0.7]); + $result = $store->query(new Vector([1.0, 2.0, 3.0]), ['max_items' => 5, 'min_score' => 0.7]); $this->assertCount(1, $result); $this->assertInstanceOf(VectorDocument::class, $result[0]); @@ -157,7 +155,7 @@ public function testQuerySuccess(): void $this->assertSame(1, $httpClient->getRequestsCount()); } - public function testQueryHandlesMultipleResultsAndMultipleOptions(): void + public function testQueryHandlesMultipleResultsAndMultipleOptions() { $uuid1 = Uuid::v4(); $uuid2 = Uuid::v4(); @@ -177,9 +175,8 @@ public function testQueryHandlesMultipleResultsAndMultipleOptions(): void ]; $httpClient = new MockHttpClient(new JsonMockResponse($expectedResponse)); $store = $this->createStore($httpClient, 2); - $queryVector = new Vector([1.0, 2.0]); - $result = $store->query($queryVector, ['max_items' => 2, 'min_score' => 0.8]); + $result = $store->query(new Vector([1.0, 2.0]), ['max_items' => 2, 'min_score' => 0.8]); $this->assertCount(2, $result); $this->assertInstanceOf(VectorDocument::class, $result[0]); @@ -196,7 +193,7 @@ public function testQueryHandlesMultipleResultsAndMultipleOptions(): void $this->assertSame(1, $httpClient->getRequestsCount()); } - public function testQueryParsesComplexMetadata(): void + public function testQueryParsesComplexMetadata() { $uuid = Uuid::v4(); $expectedResponse = [ @@ -209,9 +206,8 @@ public function testQueryParsesComplexMetadata(): void ]; $httpClient = new MockHttpClient(new JsonMockResponse($expectedResponse)); $store = $this->createStore($httpClient, 3); - $queryVector = new Vector([1.0, 2.0, 3.0]); - $result = $store->query($queryVector); + $result = $store->query(new Vector([1.0, 2.0, 3.0])); $document = $result[0]; $metadata = $document->metadata->getArrayCopy(); @@ -226,7 +222,7 @@ public function testQueryParsesComplexMetadata(): void $this->assertSame(1, $httpClient->getRequestsCount()); } - private function createStore(?MockHttpClient $httpClient = null, ?int $vectorDimension = 2): SupabaseStore + private function createStore(MockHttpClient $httpClient, ?int $vectorDimension = 2): SupabaseStore { return new SupabaseStore( $httpClient,