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
2 changes: 1 addition & 1 deletion graphiti_core/driver/arcadedb_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
112 changes: 49 additions & 63 deletions graphiti_core/graph_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down Expand Up @@ -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
Expand Down
76 changes: 72 additions & 4 deletions tests/driver/test_arcadedb_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,23 +101,40 @@ 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)

result = await self.driver.execute_query('MATCH (n) RETURN n', param1='value1')

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

@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)

Expand All @@ -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
Expand Down Expand Up @@ -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 <name> 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."""
Expand Down
Loading