Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5798cd2
test: make execution-plan assertions resilient to server drift
gkorland Aug 12, 2026
8897361
fix(helpers): reject unsafe values in Cypher parameter serialization
gkorland Aug 12, 2026
dbc1723
fix(connection): secure TLS defaults and stop mutating the live pool
gkorland Aug 12, 2026
322ab41
fix: correct schema refresh, plan parsing and model bugs
gkorland Aug 12, 2026
f7c562f
chore: ship inline types and expand lint coverage
gkorland Aug 12, 2026
cbe43a8
docs: document parameters, connection lifecycle and TLS
gkorland Aug 12, 2026
2b9b768
test: assert scan count instead of scan type in test_merge
gkorland Aug 12, 2026
785f0a4
test: cover connection argument handling and result statistics
gkorland Aug 12, 2026
cc387e5
Merge branch 'main' into fix/modernize-client-hardening
gkorland Aug 13, 2026
018e7ee
fix: close NUL and hash gaps found in review
gkorland Aug 13, 2026
0d1ff01
fix: preserve Decimal precision and narrow index-error suppression
gkorland Aug 13, 2026
727b8e9
Merge branch 'main' into fix/modernize-client-hardening
gkorland Aug 13, 2026
9a67887
fix(security): block Cypher injection through numeric subclasses
gkorland Aug 13, 2026
8552945
fix(security): normalize strings and keep probe credentials
gkorland Aug 13, 2026
14983b9
fix(security): close remaining raw Cypher interpolation sites
gkorland Aug 13, 2026
2e4eb8b
docs: note that unparameterizable names are validated
gkorland Aug 13, 2026
14afb67
fix(security): validate the value that actually reaches the query
gkorland Aug 13, 2026
82f9c11
docs: record the index identifier quoting change
gkorland Aug 13, 2026
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
7 changes: 6 additions & 1 deletion .github/wordlist.txt
Original file line number Diff line number Diff line change
@@ -1,24 +1,29 @@
aspell
async
Async
backtick
backticked
Codecov
Cypher
falkordb
FalkorDB
faq
Formatter
hostname
html
https
isort
linter
mypy
openCypher
Pre
py
pycodestyle
Pyflakes
py
pyspelling
pytest
sexualized
socio
TLS
wordlist
www
9 changes: 7 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,12 @@ falkordb/
cluster.py # Redis Cluster support
sentinel.py # Redis Sentinel support
_version.py # Package version via importlib.metadata
py.typed # PEP 561 marker — ships inline type information
asyncio/ # Async mirror (see below)
lite/ # Lightweight variant
tests/
test_*.py # Sync tests
test_async_*.py # Async tests (mirror sync tests)
plan_helpers.py # Helpers for version-agnostic execution-plan assertions
```

## Architecture Patterns
Expand All @@ -76,12 +77,16 @@ tests/
Every sync class in `falkordb/` has an async counterpart in `falkordb/asyncio/`:
| Sync | Async |
|------|-------|
| `falkordb.py` → `FalkorDB` | `asyncio/falkordb.py` → `AsyncFalkorDB` |
| `falkordb.py` → `FalkorDB` | `asyncio/falkordb.py` → `FalkorDB` (imported as `AsyncFalkorDB`) |
| `graph.py` → `Graph` | `asyncio/graph.py` → `AsyncGraph` |
| `query_result.py` → `QueryResult` | `asyncio/query_result.py` → `AsyncQueryResult` |

When modifying sync code, always check if the async counterpart needs the same change.

Known parity gaps (not yet implemented on the async side): `sentinel.py` has no
async counterpart, and `asyncio/falkordb.py` accepts fewer constructor
parameters than the sync client.

### Redis Integration
- `FalkorDB` wraps a `redis.Redis` (or `redis.asyncio.Redis`) connection
- `Graph` receives the client and delegates commands via `client.execute_command()`
Expand Down
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,60 @@ async def main():
if __name__ == "__main__":
asyncio.run(main())
```

### Query Parameters

Always pass user-supplied values as parameters rather than building query
strings — parameters are serialized safely and cannot inject Cypher.

```python
g.query("MATCH (p:Person) WHERE p.name = $name RETURN p", {"name": name})
```

Supported parameter types: `str`, `bytes`, `bool`, `int`, `float`, `Decimal`,
`None`, `list`, `tuple`, `dict`, `datetime`, `date` and `time`. Any other type
raises `TypeError` instead of being coerced with `str()`.

Names that cannot be parameterized because they are part of the query itself —
the procedure and `YIELD` names given to `call_procedure`, and index option
names — are validated instead, and raise `ValueError` if they are not plain
identifiers.

Labels, relationship types and property names passed to the index methods are
quoted, so names containing spaces, punctuation or non-ASCII characters are now
accepted where they previously failed to parse. A name containing a backtick
raises `ValueError`, since FalkorDB has no way to escape one. If you previously
worked around the parser by passing an already-backticked name such as
``"`My Label`"``, pass `"My Label"` instead.

### Connection Management

Both clients are context managers and release their connection pool on exit:

```python
with FalkorDB(host="localhost", port=6379) as db:
g = db.select_graph("social")
g.query("MATCH (n) RETURN count(n)")
```

The async client (`from falkordb.asyncio import FalkorDB`) supports the async
form:

```python
async with FalkorDB(host="localhost", port=6379) as db:
g = db.select_graph("social")
await g.query("MATCH (n) RETURN count(n)")
```

You can also call `db.close()` (or `await db.aclose()`) explicitly.

### TLS

```python
db = FalkorDB(host="my-instance.falkordb.cloud", port=6379, password="...", ssl=True)
```

TLS connections verify the server certificate and hostname by default. Set
`ssl_check_hostname=False` only when connecting to an instance whose
certificate does not match its hostname.

33 changes: 30 additions & 3 deletions falkordb/asyncio/cluster.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ def Is_Cluster(conn: redis.Redis):
if pool.connection_class is redis.UnixDomainSocketConnection:
kwargs["unix_socket_path"] = kwargs.pop("path")

# redis.asyncio.retry.Retry and the connect hook are asyncio-specific: they
# are valid parameter *names* on the sync client, so the signature filter
# below keeps them, but the sync client would call them and get back an
# un-awaited coroutine. credential_provider is deliberately NOT dropped --
# redis-py has a single CredentialProvider class whose get_credentials() is
# synchronous, and removing it would leave the probe unauthenticated
# because username/password are None whenever a provider is in use.
for async_only in ("retry", "redis_connect_func"):
kwargs.pop(async_only, None)

# Keep only the parameters the synchronous constructor actually accepts.
# redis-py stores internal state in ``connection_kwargs`` that is not part
# of the ``Redis.__init__`` signature — redis 8.1.0 added ``himport_registry``
Expand All @@ -32,7 +42,11 @@ def Is_Cluster(conn: redis.Redis):

# Create a synchronous Redis client with the same parameters
# as the connection pool just to keep Is_Cluster synchronous
info = sync_redis.Redis(**kwargs).info(section="server")
probe = sync_redis.Redis(**kwargs)
try:
info = probe.info(section="server")
finally:
probe.close()

return "redis_mode" in info and info["redis_mode"] == "cluster"

Expand All @@ -47,7 +61,10 @@ def Cluster_Conn(
reinitialize_steps=5,
read_from_replicas=False,
address_remap=None,
load_balancing_strategy=None,
):
# copy, popping from the live pool dict would strip host/port/credentials
# from a pool the caller may still be using
connection_kwargs = conn.connection_pool.connection_kwargs.copy()
host = connection_kwargs.pop("host")
port = connection_kwargs.pop("port")
Expand All @@ -65,6 +82,17 @@ def Cluster_Conn(
redis_exceptions.ConnectionError,
],
)

# redis-py deprecated these and warns for every one it receives, only
# forward them when the caller actually diverged from the default
optional: dict = {}
if cluster_error_retry_attempts != 3:
optional["cluster_error_retry_attempts"] = cluster_error_retry_attempts
if read_from_replicas:
optional["read_from_replicas"] = read_from_replicas
if load_balancing_strategy is not None:
optional["load_balancing_strategy"] = load_balancing_strategy

return RedisCluster(
host=host,
port=port,
Expand All @@ -76,8 +104,7 @@ def Cluster_Conn(
retry_on_error=retry_on_error,
require_full_coverage=require_full_coverage,
reinitialize_steps=reinitialize_steps,
read_from_replicas=read_from_replicas,
address_remap=address_remap,
startup_nodes=startup_nodes,
cluster_error_retry_attempts=cluster_error_retry_attempts,
**optional,
)
Comment on lines 106 to 110
39 changes: 23 additions & 16 deletions falkordb/asyncio/falkordb.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import List, Optional, Union
import contextlib

import redis.asyncio as redis # type: ignore[import-not-found]
from redis.driver_info import DriverInfo
Expand Down Expand Up @@ -57,7 +57,7 @@ def __init__(
ssl_cert_reqs="required",
ssl_ca_certs=None,
ssl_ca_data=None,
ssl_check_hostname=False,
ssl_check_hostname=True,
max_connections=None,
single_connection_client=False,
health_check_interval=0,
Expand All @@ -76,6 +76,7 @@ def __init__(
reinitialize_steps=5,
read_from_replicas=False,
address_remap=None,
load_balancing_strategy=None,
):

conn = redis.Redis(
Expand Down Expand Up @@ -119,12 +120,15 @@ def __init__(
conn = Cluster_Conn(
conn,
ssl,
cluster_error_retry_attempts,
startup_nodes,
require_full_coverage,
reinitialize_steps,
read_from_replicas,
address_remap,
# keyword arguments, the sync and async helpers do not take the
# same positional order
cluster_error_retry_attempts=cluster_error_retry_attempts,
startup_nodes=startup_nodes,
require_full_coverage=require_full_coverage,
reinitialize_steps=reinitialize_steps,
read_from_replicas=read_from_replicas,
address_remap=address_remap,
load_balancing_strategy=load_balancing_strategy,
)

self.connection = conn
Expand Down Expand Up @@ -160,7 +164,12 @@ def from_url(cls, url: str, **kwargs) -> "FalkorDB":
kwargs["decode_responses"] = True
conn = redis.from_url(url, **kwargs)

return cls(connection_pool=conn.connection_pool)
# carry TLS through, otherwise a cluster topology would be
# re-dialed in plaintext by Cluster_Conn
pool = conn.connection_pool
ssl = pool.connection_class is redis.SSLConnection

return cls(connection_pool=pool, ssl=ssl)

def select_graph(self, graph_id: str) -> AsyncGraph:
"""
Expand All @@ -179,7 +188,7 @@ def select_graph(self, graph_id: str) -> AsyncGraph:

return AsyncGraph(self, graph_id)

async def list_graphs(self) -> List[str]:
async def list_graphs(self) -> list[str]:
"""
Lists all graph names.
See: https://docs.falkordb.com/commands/graph.list.html
Expand All @@ -191,7 +200,7 @@ async def list_graphs(self) -> List[str]:

return await self.connection.execute_command(LIST_CMD)

async def config_get(self, name: str) -> Union[int, str]:
async def config_get(self, name: str) -> int | str:
"""
Retrieve a DB level configuration.
For a list of available configurations see: https://docs.falkordb.com/configuration.html#falkordb-configuration-parameters
Expand Down Expand Up @@ -228,11 +237,9 @@ async def aclose(self) -> None:
Close the underlying connection(s).
"""

try:
# best-effort close, don't raise on Redis errors
with contextlib.suppress(RedisError):
await self.connection.aclose()
except RedisError:
# best-effort close — don't raise on Redis errors
pass

async def __aenter__(self) -> "FalkorDB":
"""Return self to support async with-statement usage."""
Expand Down Expand Up @@ -274,7 +281,7 @@ async def udf_load(self, name: str, script: str, replace: bool = False):
return resp

# GRAPH.UDF LIST [LIBRARYNAME] [WITHCODE]
async def udf_list(self, lib: Optional[str] = None, with_code: bool = False):
async def udf_list(self, lib: str | None = None, with_code: bool = False):
"""
List User Defined Function (UDF) libraries.

Expand Down
Loading