diff --git a/examples/.env b/examples/.env index e789184a8..b658ccf35 100644 --- a/examples/.env +++ b/examples/.env @@ -150,3 +150,11 @@ 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= +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..48d6f7354 --- /dev/null +++ b/examples/rag/supabase.php @@ -0,0 +1,69 @@ + + * + * 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'; + +$store = new Store( + httpClient: http_client(), + url: env('SUPABASE_URL'), + apiKey: env('SUPABASE_API_KEY'), +); + +$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_client() +); + +$vectorizer = new Vectorizer($platform, new Ollama('mxbai-embed-large')); +$loader = new InMemoryLoader($documents); +$indexer = new Indexer($loader, $vectorizer, $store, logger: logger()); +$indexer->index(); + +$similaritySearch = new SimilaritySearch($vectorizer, $store); +$toolbox = new Toolbox([$similaritySearch], logger: logger()); +$processor = new AgentProcessor($toolbox); +$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?') +); + +$result = $agent->call($messages); + +echo $result->getContent().\PHP_EOL; diff --git a/src/ai-bundle/config/options.php b/src/ai-bundle/config/options.php index aa2e17254..cff0e6a12 100644 --- a/src/ai-bundle/config/options.php +++ b/src/ai-bundle/config/options.php @@ -487,6 +487,24 @@ ->end() ->end() ->end() + ->arrayNode('supabase') + ->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')->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/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 e5f48dc47..e75594a30 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 = [ + isset($store['http_client']) ? new Reference($store['http_client']) : new Definition(HttpClientInterface::class), + $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', 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 new file mode 100644 index 000000000..f1043c82d --- /dev/null +++ b/src/store/src/Bridge/Supabase/Store.php @@ -0,0 +1,150 @@ + + * + * 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 + * + * Supabase vector store implementation using REST API and pgvector. + * + * 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. + * + * @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 $httpClient, + 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->httpClient->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->httpClient->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..21cb73504 --- /dev/null +++ b/src/store/tests/Bridge/Supabase/StoreTest.php @@ -0,0 +1,237 @@ + + * + * 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\AI\Store\Exception\RuntimeException; +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() + { + $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() + { + $httpClient = new MockHttpClient(); + $store = $this->createStore($httpClient); + + $store->add(); + + $this->assertSame(0, $httpClient->getRequestsCount()); + } + + public function testAddSingleDocument() + { + $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() + { + $httpClient = new MockHttpClient(new MockResponse('', ['http_code' => 201])); + $store = $this->createStore($httpClient); + + $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() + { + $httpClient = new MockHttpClient(new MockResponse('', ['http_code' => 201])); + $store = $this->createStore($httpClient); + + $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() + { + $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() + { + $httpClient = new MockHttpClient(new JsonMockResponse([])); + $store = $this->createStore($httpClient); + $result = $store->query(new Vector([1.0, 2.0])); + + $this->assertSame([], $result); + $this->assertSame(1, $httpClient->getRequestsCount()); + } + + public function testQueryHandlesLimitOption() + { + $httpClient = new MockHttpClient(new JsonMockResponse([])); + $store = $this->createStore($httpClient); + $result = $store->query(new Vector([1.0, 2.0]), ['limit' => 1]); + + $this->assertSame([], $result); + $this->assertSame(1, $httpClient->getRequestsCount()); + } + + public function testQueryThrowsExceptionForWrongVectorDimension() + { + $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() + { + $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); + $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]); + $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() + { + $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); + + $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]); + $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() + { + $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); + + $result = $store->query(new Vector([1.0, 2.0, 3.0])); + + $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, ?int $vectorDimension = 2): SupabaseStore + { + return new SupabaseStore( + $httpClient, + 'https://test.supabase.co', + 'test-api-key', + 'documents', + 'embedding', + $vectorDimension, + 'match_documents' + ); + } +}