diff --git a/graphiti_core/driver/arcadedb_driver.py b/graphiti_core/driver/arcadedb_driver.py index 1b2ab7b3ea..13f370ece8 100644 --- a/graphiti_core/driver/arcadedb_driver.py +++ b/graphiti_core/driver/arcadedb_driver.py @@ -164,7 +164,7 @@ async def execute_query(self, cypher_query_: LiteralString, **kwargs: Any) -> Ea params = kwargs.pop('params', None) if params is None: params = {} - params.setdefault('database_', self._database) + kwargs.setdefault('database_', self._database) try: result = await self.client.execute_query(cypher_query_, parameters_=params, **kwargs) diff --git a/graphiti_core/graph_queries.py b/graphiti_core/graph_queries.py index 3e36182717..7411d47c54 100644 --- a/graphiti_core/graph_queries.py +++ b/graphiti_core/graph_queries.py @@ -27,57 +27,46 @@ def get_range_indices(provider: GraphProvider) -> list[LiteralString]: if provider == GraphProvider.ARCADEDB: - # ArcadeDB uses SQL DDL for index creation. The Bolt protocol accepts SQL statements. - # Type creation is needed before index creation. - from typing import cast - - return cast( - list[LiteralString], - [ - # Create vertex and edge types - 'CREATE VERTEX TYPE Entity IF NOT EXISTS', - 'CREATE VERTEX TYPE Episodic IF NOT EXISTS', - 'CREATE VERTEX TYPE Community IF NOT EXISTS', - 'CREATE VERTEX TYPE Saga IF NOT EXISTS', - 'CREATE EDGE TYPE RELATES_TO IF NOT EXISTS', - 'CREATE EDGE TYPE MENTIONS IF NOT EXISTS', - 'CREATE EDGE TYPE HAS_MEMBER IF NOT EXISTS', - 'CREATE EDGE TYPE HAS_EPISODE IF NOT EXISTS', - 'CREATE EDGE TYPE NEXT_EPISODE IF NOT EXISTS', - # Entity indexes - 'CREATE INDEX IF NOT EXISTS ON Entity (uuid) NOTUNIQUE', - 'CREATE INDEX IF NOT EXISTS ON Entity (group_id) NOTUNIQUE', - 'CREATE INDEX IF NOT EXISTS ON Entity (name) NOTUNIQUE', - 'CREATE INDEX IF NOT EXISTS ON Entity (created_at) NOTUNIQUE', - # Episodic indexes - 'CREATE INDEX IF NOT EXISTS ON Episodic (uuid) NOTUNIQUE', - 'CREATE INDEX IF NOT EXISTS ON Episodic (group_id) NOTUNIQUE', - 'CREATE INDEX IF NOT EXISTS ON Episodic (created_at) NOTUNIQUE', - 'CREATE INDEX IF NOT EXISTS ON Episodic (valid_at) NOTUNIQUE', - # Community indexes - 'CREATE INDEX IF NOT EXISTS ON Community (uuid) NOTUNIQUE', - 'CREATE INDEX IF NOT EXISTS ON Community (group_id) NOTUNIQUE', - # Saga indexes - 'CREATE INDEX IF NOT EXISTS ON Saga (uuid) NOTUNIQUE', - 'CREATE INDEX IF NOT EXISTS ON Saga (group_id) NOTUNIQUE', - 'CREATE INDEX IF NOT EXISTS ON Saga (name) NOTUNIQUE', - # Edge indexes - 'CREATE INDEX IF NOT EXISTS ON RELATES_TO (uuid) NOTUNIQUE', - 'CREATE INDEX IF NOT EXISTS ON RELATES_TO (group_id) NOTUNIQUE', - 'CREATE INDEX IF NOT EXISTS ON RELATES_TO (name) NOTUNIQUE', - 'CREATE INDEX IF NOT EXISTS ON RELATES_TO (created_at) NOTUNIQUE', - 'CREATE INDEX IF NOT EXISTS ON RELATES_TO (expired_at) NOTUNIQUE', - 'CREATE INDEX IF NOT EXISTS ON RELATES_TO (valid_at) NOTUNIQUE', - 'CREATE INDEX IF NOT EXISTS ON RELATES_TO (invalid_at) NOTUNIQUE', - 'CREATE INDEX IF NOT EXISTS ON MENTIONS (uuid) NOTUNIQUE', - 'CREATE INDEX IF NOT EXISTS ON MENTIONS (group_id) NOTUNIQUE', - 'CREATE INDEX IF NOT EXISTS ON HAS_MEMBER (uuid) NOTUNIQUE', - 'CREATE INDEX IF NOT EXISTS ON HAS_EPISODE (uuid) NOTUNIQUE', - 'CREATE INDEX IF NOT EXISTS ON HAS_EPISODE (group_id) NOTUNIQUE', - 'CREATE INDEX IF NOT EXISTS ON NEXT_EPISODE (uuid) NOTUNIQUE', - 'CREATE INDEX IF NOT EXISTS ON NEXT_EPISODE (group_id) NOTUNIQUE', - ], - ) + # Corregido (03/07/2026): la version original generaba DDL en sintaxis SQL + # de ArcadeDB (CREATE VERTEX TYPE ..., CREATE INDEX ... ON X (prop) NOTUNIQUE) + # pero la enviaba por el canal Cypher/Bolt, que la rechaza integramente con + # "Syntax error ... mismatched input 'TYPE'/'ON'" -- verificado que las 28 + # sentencias originales fallaban al 100%. No hace falta declarar tipos de + # vertice/arista explicitamente: ArcadeDB los crea de forma implicita al + # primer MERGE/CREATE (comportamiento schemaless ya verificado en esta + # migracion). Los indices SI son creables via Cypher estandar (ArcadeDB + # soporta indices "standard, RANGE y TEXT" via Bolt, solo rechaza FULLTEXT + # -- ver get_fulltext_indices), asi que se reescriben con la misma sintaxis + # Cypher que ya usa el caso por defecto (Neo4j) mas abajo en este archivo. + return [ + 'CREATE INDEX entity_uuid IF NOT EXISTS FOR (n:Entity) ON (n.uuid)', + 'CREATE INDEX episode_uuid IF NOT EXISTS FOR (n:Episodic) ON (n.uuid)', + 'CREATE INDEX community_uuid IF NOT EXISTS FOR (n:Community) ON (n.uuid)', + 'CREATE INDEX saga_uuid IF NOT EXISTS FOR (n:Saga) ON (n.uuid)', + 'CREATE INDEX relation_uuid IF NOT EXISTS FOR ()-[e:RELATES_TO]-() ON (e.uuid)', + 'CREATE INDEX mention_uuid IF NOT EXISTS FOR ()-[e:MENTIONS]-() ON (e.uuid)', + 'CREATE INDEX has_member_uuid IF NOT EXISTS FOR ()-[e:HAS_MEMBER]-() ON (e.uuid)', + 'CREATE INDEX has_episode_uuid IF NOT EXISTS FOR ()-[e:HAS_EPISODE]-() ON (e.uuid)', + 'CREATE INDEX next_episode_uuid IF NOT EXISTS FOR ()-[e:NEXT_EPISODE]-() ON (e.uuid)', + 'CREATE INDEX entity_group_id IF NOT EXISTS FOR (n:Entity) ON (n.group_id)', + 'CREATE INDEX episode_group_id IF NOT EXISTS FOR (n:Episodic) ON (n.group_id)', + 'CREATE INDEX community_group_id IF NOT EXISTS FOR (n:Community) ON (n.group_id)', + 'CREATE INDEX saga_group_id IF NOT EXISTS FOR (n:Saga) ON (n.group_id)', + 'CREATE INDEX relation_group_id IF NOT EXISTS FOR ()-[e:RELATES_TO]-() ON (e.group_id)', + 'CREATE INDEX mention_group_id IF NOT EXISTS FOR ()-[e:MENTIONS]-() ON (e.group_id)', + 'CREATE INDEX has_episode_group_id IF NOT EXISTS FOR ()-[e:HAS_EPISODE]-() ON (e.group_id)', + 'CREATE INDEX next_episode_group_id IF NOT EXISTS FOR ()-[e:NEXT_EPISODE]-() ON (e.group_id)', + 'CREATE INDEX name_entity_index IF NOT EXISTS FOR (n:Entity) ON (n.name)', + 'CREATE INDEX saga_name IF NOT EXISTS FOR (n:Saga) ON (n.name)', + 'CREATE INDEX created_at_entity_index IF NOT EXISTS FOR (n:Entity) ON (n.created_at)', + 'CREATE INDEX created_at_episodic_index IF NOT EXISTS FOR (n:Episodic) ON (n.created_at)', + 'CREATE INDEX valid_at_episodic_index IF NOT EXISTS FOR (n:Episodic) ON (n.valid_at)', + 'CREATE INDEX name_edge_index IF NOT EXISTS FOR ()-[e:RELATES_TO]-() ON (e.name)', + 'CREATE INDEX created_at_edge_index IF NOT EXISTS FOR ()-[e:RELATES_TO]-() ON (e.created_at)', + 'CREATE INDEX expired_at_edge_index IF NOT EXISTS FOR ()-[e:RELATES_TO]-() ON (e.expired_at)', + 'CREATE INDEX valid_at_edge_index IF NOT EXISTS FOR ()-[e:RELATES_TO]-() ON (e.valid_at)', + 'CREATE INDEX invalid_at_edge_index IF NOT EXISTS FOR ()-[e:RELATES_TO]-() ON (e.invalid_at)', + ] if provider == GraphProvider.FALKORDB: return [ @@ -137,18 +126,15 @@ def get_range_indices(provider: GraphProvider) -> list[LiteralString]: def get_fulltext_indices(provider: GraphProvider) -> list[LiteralString]: if provider == GraphProvider.ARCADEDB: - from typing import cast - - # ArcadeDB uses Lucene-based fulltext indexes via SQL DDL - return cast( - list[LiteralString], - [ - 'CREATE INDEX IF NOT EXISTS ON Episodic (content, source, source_description, group_id) FULL_TEXT ENGINE LUCENE', - 'CREATE INDEX IF NOT EXISTS ON Entity (name, summary, group_id) FULL_TEXT ENGINE LUCENE', - 'CREATE INDEX IF NOT EXISTS ON Community (name, group_id) FULL_TEXT ENGINE LUCENE', - 'CREATE INDEX IF NOT EXISTS ON RELATES_TO (name, fact, group_id) FULL_TEXT ENGINE LUCENE', - ], - ) + # Corregido (03/07/2026): igual que get_range_indices, la sintaxis SQL + # 'FULL_TEXT ENGINE LUCENE' enviada por Cypher/Bolt falla siempre (ArcadeDB + # solo acepta CREATE INDEX estandar/RANGE/TEXT via Bolt, no FULLTEXT -- + # verificado repetidamente en esta migracion). graphiti-core ya se usa con + # BM25/fulltext desactivado (search_config sin EdgeSearchMethod.bm25 / + # NodeSearchMethod.bm25 -- ver parche de compatibilidad en main.py), asi + # que no hace falta ningun indice fulltext real; devolver lista vacia, + # igual que ya hace el caso KUZU mas abajo en este archivo. + return [] if provider == GraphProvider.FALKORDB: from typing import cast diff --git a/tests/driver/test_arcadedb_driver.py b/tests/driver/test_arcadedb_driver.py index 71beb72efc..d3d334481b 100644 --- a/tests/driver/test_arcadedb_driver.py +++ b/tests/driver/test_arcadedb_driver.py @@ -101,7 +101,17 @@ def test_default_database(self): @pytest.mark.asyncio @unittest.skipIf(not HAS_ARCADEDB, 'ArcadeDB driver dependencies not available') async def test_execute_query_success(self): - """Test successful query execution.""" + """Test successful query execution. + + Regression test: `database_` must be passed as a real kwarg to the + underlying `neo4j` driver call (`execute_query(..., database_=...)`), + NOT stuffed inside `parameters_` (a Cypher query parameter, which the + neo4j driver's own `execute_query()` never reads to route the request). + The previous version of this test asserted the buggy behaviour + (`parameters_={'database_': 'graphiti'}`), which caused a real + cross-tenant data leak: `execute_query()` always hit the driver's + default database regardless of `with_database(...)`. + """ mock_result = MagicMock() self.mock_client.execute_query = AsyncMock(return_value=mock_result) @@ -109,7 +119,8 @@ async def test_execute_query_success(self): self.mock_client.execute_query.assert_called_once_with( 'MATCH (n) RETURN n', - parameters_={'database_': 'graphiti'}, + parameters_={}, + database_='graphiti', param1='value1', ) assert result is mock_result @@ -117,7 +128,13 @@ async def test_execute_query_success(self): @pytest.mark.asyncio @unittest.skipIf(not HAS_ARCADEDB, 'ArcadeDB driver dependencies not available') async def test_execute_query_with_params(self): - """Test query execution with explicit params dict.""" + """Test query execution with explicit params dict. + + Regression test: query parameters (`params`) must stay in + `parameters_` (they are real Cypher bind variables), while + `database_` must be passed separately as a kwarg -- see + `test_execute_query_success` for the full explanation. + """ mock_result = MagicMock() self.mock_client.execute_query = AsyncMock(return_value=mock_result) @@ -128,7 +145,26 @@ async def test_execute_query_with_params(self): self.mock_client.execute_query.assert_called_once_with( 'MATCH (n) WHERE n.uuid = $uuid RETURN n', - parameters_={'uuid': 'test-uuid', 'database_': 'graphiti'}, + parameters_={'uuid': 'test-uuid'}, + database_='graphiti', + ) + + @pytest.mark.asyncio + @unittest.skipIf(not HAS_ARCADEDB, 'ArcadeDB driver dependencies not available') + async def test_execute_query_routes_to_cloned_database(self): + """Regression test for the cross-tenant leak: a driver obtained via + `with_database(tenant_db)` must route execute_query() to that + database, not to the original driver's database.""" + mock_result = MagicMock() + self.mock_client.execute_query = AsyncMock(return_value=mock_result) + + tenant_driver = self.driver.with_database('tenant_b') + await tenant_driver.execute_query('MATCH (n) RETURN n') + + self.mock_client.execute_query.assert_called_once_with( + 'MATCH (n) RETURN n', + parameters_={}, + database_='tenant_b', ) @pytest.mark.asyncio @@ -229,6 +265,38 @@ async def execute_with_errors(*args, **kwargs): # Should not raise despite some queries failing await self.driver.build_indices_and_constraints() + @unittest.skipIf(not HAS_ARCADEDB, 'ArcadeDB driver dependencies not available') + def test_range_indices_use_valid_cypher_syntax(self): + """Regression test: get_range_indices(ARCADEDB) previously generated + ArcadeDB SQL DDL (`CREATE VERTEX TYPE ... IF NOT EXISTS`, + `CREATE INDEX ... NOTUNIQUE`) sent over the Cypher/Bolt channel, which + rejects it entirely (verified: all 28 original statements failed with + 'mismatched input TYPE/ON' syntax errors against a real ArcadeDB + instance). Cypher's `CREATE INDEX IF NOT EXISTS FOR (n:Label) + ON (n.prop)` syntax is what ArcadeDB's Bolt plugin actually accepts.""" + from graphiti_core.driver.driver import GraphProvider + from graphiti_core.graph_queries import get_range_indices + + indices = get_range_indices(GraphProvider.ARCADEDB) + + assert len(indices) > 0 + for query in indices: + assert 'VERTEX TYPE' not in query + assert 'EDGE TYPE' not in query + assert 'NOTUNIQUE' not in query + assert 'FOR (' in query or 'FOR ()-' in query + + @unittest.skipIf(not HAS_ARCADEDB, 'ArcadeDB driver dependencies not available') + def test_fulltext_indices_empty_for_arcadedb(self): + """ArcadeDB does not support FULLTEXT indexes via the Bolt/Cypher + channel regardless of syntax (verified). graphiti-core already runs + without BM25/fulltext search enabled, so no fulltext index DDL should + be generated at all -- avoids doomed-to-fail statements.""" + from graphiti_core.driver.driver import GraphProvider + from graphiti_core.graph_queries import get_fulltext_indices + + assert get_fulltext_indices(GraphProvider.ARCADEDB) == [] + @unittest.skipIf(not HAS_ARCADEDB, 'ArcadeDB driver dependencies not available') def test_operations_properties(self): """Test that all 11 operation properties are available."""