Graph Database (Apache AGE)
The graph module provides async access to Apache AGE, a PostgreSQL extension
for graph database queries using Cypher.
Overview
The Graph class wraps a psycopg async connection pool and exposes
high-level CRUD operations (create, delete, match, merge) that
accept Pydantic models. The cypher module generates Cypher query
templates from those models.
Basic Usage
from imbi.common import graph, models
# Open a graph connection pool
db = graph.Graph()
await db.open()
# Create a node
org = models.Organization(name="My Org", slug="my-org")
await db.create(org)
# Match nodes
orgs = await db.match(models.Organization, {"slug": "my-org"})
# Match all nodes of a type, ordered
teams = await db.match(models.Team, order_by="name")
# Upsert a node
await db.merge(org, match_on=["slug"])
# Delete a node
await db.delete(org)
# Close the connection pool
await db.close()
FastAPI Dependency Injection
Wire graph_lifespan into the application lifespan, then declare Pool
as a route parameter to receive the injected Graph instance:
import fastapi
from imbi.common import lifespan, models
from imbi.common.graph import Pool, graph_lifespan
app = fastapi.FastAPI(
lifespan=lifespan.Lifespan(graph_lifespan),
)
@app.get('/orgs/{slug}')
async def get_org(slug: str, db: Pool) -> models.Organization:
results = await db.match(models.Organization, {"slug": slug})
return results[0]
To run custom initialisation after the pool opens (e.g. schema setup),
register a startup callback before creating the app:
from imbi.common import graph
async def on_graph_ready(db: graph.Graph) -> None:
await graph.initialize()
graph.set_on_startup(on_graph_ready)
Raw Cypher
execute() runs a Cypher query. It wraps the query in the AGE
cypher() function, so you cannot send plain SQL through it. Give one
name in columns for each value in the RETURN clause. The default is
['n'].
rows = await db.execute(
'MATCH (o:Organization {{slug: {slug}}}) RETURN o.name, o.slug',
{'slug': 'my-org'},
['name', 'slug'],
)
The query is a template. Write each literal brace two times ({{ and
}}) and refer to a parameter as {name}. The client escapes the
parameter values. Set raw=True to run the text as literal Cypher with
no template pass, for example for a query that a user typed. raw
ignores params.
Values come back as AGE agtype text. Use parse_agtype() to turn a
value into Python data. For a vertex, it returns the properties.
Search and Embeddings
create, merge and delete keep the vector embeddings of a node up
to date. The embeddings are in the embeddings table (pgvector), and
the client makes them on the local machine with fastembed. The
EMBEDDINGS_* settings select the models.
search() embeds the query text and returns SearchResult rows, the
most similar first. Filter by node_label, attribute or
node_ids.
search_nodes() returns full model instances, one for each node.
embed_node() and delete_node_embeddings() are for code that writes
nodes with execute(). execute() does not see a model, so it does
not update the embeddings.
API Reference
Graph Client
Graph
Wrapper around the PostgreSQL connection pool.
Supports both Apache AGE Cypher queries and pgvector
similarity search against the embeddings table.
Source code in libraries/common/src/imbi/common/graph/client.py
| def __init__(self) -> None:
self.opened = False
self.settings = settings.Postgres()
self.pool = psycopg_pool.AsyncConnectionPool(
conninfo=str(self.settings.url),
min_size=self.settings.min_pool_size,
max_size=self.settings.max_pool_size,
configure=self._configure_connection,
open=False,
)
|
close
async
Close the connection pool and release models.
Source code in libraries/common/src/imbi/common/graph/client.py
| async def close(self) -> None:
"""Close the connection pool and release models."""
await self.pool.close()
embeddings.close()
self.opened = False
|
create
async
create(node: GraphModelT) -> GraphModelT
Create a node and its relationships in the graph.
Source code in libraries/common/src/imbi/common/graph/client.py
| async def create(
self,
node: GraphModelT,
) -> GraphModelT:
"""Create a node and its relationships in the graph."""
await self._execute_batch(cypher.create(node))
if _embeddable_descriptors(type(node)):
await self._auto_embed(node)
return node
|
delete
async
delete(node: GraphModel) -> None
Delete a node, its relationships, and embeddings.
The Cypher delete runs via AGE (requires autocommit),
then embeddings are cleaned up on the same connection.
Source code in libraries/common/src/imbi/common/graph/client.py
| async def delete(self, node: models.GraphModel) -> None:
"""Delete a node, its relationships, and embeddings.
The Cypher delete runs via AGE (requires autocommit),
then embeddings are cleaned up on the same connection.
"""
self._require_open()
stmt = cypher.delete(node)
async def run(conn: psycopg.AsyncConnection[typing.Any]) -> None:
await self._execute_on(
conn,
stmt.cypher,
stmt.params,
)
if _embeddable_descriptors(type(node)):
await self._delete_embeddings_where(
conn,
node_label=type(node).__name__,
node_id=node.id,
)
await self._run_retrying_poisoned(run)
|
delete_node_embeddings
async
delete_node_embeddings(
node_label: str, node_id: str
) -> None
Delete every embedding row for a node.
The counterpart to embed_node for callers that delete
nodes with raw Cypher instead of delete.
Source code in libraries/common/src/imbi/common/graph/client.py
| async def delete_node_embeddings(
self,
node_label: str,
node_id: str,
) -> None:
"""Delete every embedding row for a node.
The counterpart to ``embed_node`` for callers that delete
nodes with raw Cypher instead of ``delete``.
"""
self._require_open()
async with self.pool.connection() as conn:
await self._delete_embeddings_where(
conn,
node_label=node_label,
node_id=node_id,
)
|
embed_node
async
embed_node(
node: GraphModel, *, raise_on_error: bool = False
) -> None
(Re)generate the embeddings for node.
create/merge/delete keep embeddings in sync on
their own, but execute takes raw Cypher and has no model
to inspect, so callers that write nodes that way must call
this after the write.
Failures are logged and swallowed like the automatic path,
which suits a best-effort call alongside a graph write. Pass
raise_on_error when the embedding is the job (a reindex)
and a silent failure would be reported as success.
Source code in libraries/common/src/imbi/common/graph/client.py
| async def embed_node(
self,
node: models.GraphModel,
*,
raise_on_error: bool = False,
) -> None:
"""(Re)generate the embeddings for *node*.
``create``/``merge``/``delete`` keep embeddings in sync on
their own, but ``execute`` takes raw Cypher and has no model
to inspect, so callers that write nodes that way must call
this after the write.
Failures are logged and swallowed like the automatic path,
which suits a best-effort call alongside a graph write. Pass
``raise_on_error`` when the embedding *is* the job (a reindex)
and a silent failure would be reported as success.
"""
if raise_on_error:
await self._embed_fields(node)
else:
await self._auto_embed(node)
|
execute
async
execute(
query_template: str,
params: dict[str, Any] | None = None,
columns: list[str] | None = None,
raw: bool = False,
) -> list[dict[str, typing.Any]]
Wrap a Cypher query in SQL and execute it.
Parameters in params are serialized via
_cypher_param() using Cypher-compatible escaping
and interpolated into query_template via
sql.SQL.format(). Templated callers double their
literal braces ({{/}}) and reference params as
{name}.
Pass raw True to execute query_template as
literal Cypher with no format() pass — required for
arbitrary user-entered queries whose map literals use
single braces. params is ignored when raw is set.
The Cypher query is wrapped in AGE's cypher()
function. columns defines the AS (...) clause
— pass one name per value in the Cypher RETURN
clause. Defaults to ['n'] for single-column
returns.
Source code in libraries/common/src/imbi/common/graph/client.py
| async def execute(
self,
query_template: str,
params: dict[str, typing.Any] | None = None,
columns: list[str] | None = None,
raw: bool = False,
) -> list[dict[str, typing.Any]]:
"""Wrap a Cypher query in SQL and execute it.
Parameters in *params* are serialized via
``_cypher_param()`` using Cypher-compatible escaping
and interpolated into *query_template* via
``sql.SQL.format()``. Templated callers double their
literal braces (``{{``/``}}``) and reference params as
``{name}``.
Pass *raw* ``True`` to execute *query_template* as
literal Cypher with no ``format()`` pass — required for
arbitrary user-entered queries whose map literals use
single braces. *params* is ignored when *raw* is set.
The Cypher query is wrapped in AGE's ``cypher()``
function. *columns* defines the ``AS (...)`` clause
— pass one name per value in the Cypher ``RETURN``
clause. Defaults to ``['n']`` for single-column
returns.
"""
self._require_open()
async def run(
conn: psycopg.AsyncConnection[typing.Any],
) -> list[dict[str, typing.Any]]:
return await self._execute_on(
conn,
query_template,
params,
columns,
raw,
)
return await self._run_retrying_poisoned(run)
|
locked
async
locked(
key: str,
work: Callable[[CypherExecute], Awaitable[RunResultT]],
) -> RunResultT
Run work in one transaction under an advisory lock on key.
work gets an execute function with the arguments of
:meth:execute. Its queries run in the transaction. The lock
is transaction-scoped (pg_advisory_xact_lock), so calls with
the same key run one at a time. Each statement after the lock
sees the commits of the calls before it. An exception in work
rolls the transaction back and releases the lock.
Source code in libraries/common/src/imbi/common/graph/client.py
| async def locked(
self,
key: str,
work: collections.abc.Callable[
[CypherExecute], collections.abc.Awaitable[RunResultT]
],
) -> RunResultT:
"""Run *work* in one transaction under an advisory lock on *key*.
*work* gets an ``execute`` function with the arguments of
:meth:`execute`. Its queries run in the transaction. The lock
is transaction-scoped (``pg_advisory_xact_lock``), so calls with
the same *key* run one at a time. Each statement after the lock
sees the commits of the calls before it. An exception in *work*
rolls the transaction back and releases the lock.
"""
self._require_open()
async def run(conn: psycopg.AsyncConnection[typing.Any]) -> RunResultT:
async with conn.transaction():
await conn.execute(
'SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))',
(key,),
)
return await work(functools.partial(self._execute_on, conn))
return await self._run_retrying_poisoned(run)
|
match
async
match(
node_type: type[ModelT],
params: dict[str, Any] | None = None,
order_by: str | None = None,
) -> list[ModelT]
Match nodes and return model instances.
Deserialization prefers model_validate (so field
validators run) and falls back to model_construct
when validation fails.
Source code in libraries/common/src/imbi/common/graph/client.py
| async def match(
self,
node_type: type[ModelT],
params: dict[str, typing.Any] | None = None,
order_by: str | None = None,
) -> list[ModelT]:
"""Match nodes and return model instances.
Deserialization prefers ``model_validate`` (so field
validators run) and falls back to ``model_construct``
when validation fails.
"""
stmt = cypher.match(node_type, params, order_by)
raw_rows = await self.execute(
stmt.cypher,
stmt.params,
)
results: list[ModelT] = []
for row in raw_rows:
for value in row.values():
props = parse_agtype(value)
if isinstance(props, dict):
results.append(
self._row_to_model(node_type, props),
)
return results
|
merge
async
merge(
node: GraphModelT, match_on: list[str] | None = None
) -> GraphModelT
Upsert a node and its relationships in the graph.
Source code in libraries/common/src/imbi/common/graph/client.py
| async def merge(
self,
node: GraphModelT,
match_on: list[str] | None = None,
) -> GraphModelT:
"""Upsert a node and its relationships in the graph."""
await self._execute_batch(
cypher.merge(node, match_on),
)
if _embeddable_descriptors(type(node)):
await self._auto_embed(node)
return node
|
open
async
Open the connection pool.
Source code in libraries/common/src/imbi/common/graph/client.py
| async def open(self) -> None:
"""Open the connection pool."""
await self.pool.open()
self.opened = True
|
search
async
search(
query: str,
*,
model_name: str = 'text',
node_label: str | None = None,
attribute: str | None = None,
node_ids: Collection[str] | None = None,
limit: int = 10,
distance_threshold: float | None = None,
) -> list[SearchResult]
Search for nodes by semantic similarity.
Embeds query using the specified model, then
performs a cosine similarity search against the
embeddings table. Results are ordered by
distance (ascending = most similar).
When node_ids is provided the search is restricted to those
node ids in SQL (node_id = ANY(...)), so callers can scope
results (e.g. to a single organization) without fetching and
post-filtering a wider result set. An empty collection matches
nothing.
Source code in libraries/common/src/imbi/common/graph/client.py
| async def search(
self,
query: str,
*,
model_name: str = 'text',
node_label: str | None = None,
attribute: str | None = None,
node_ids: collections.abc.Collection[str] | None = None,
limit: int = 10,
distance_threshold: float | None = None,
) -> list[SearchResult]:
"""Search for nodes by semantic similarity.
Embeds *query* using the specified model, then
performs a cosine similarity search against the
``embeddings`` table. Results are ordered by
distance (ascending = most similar).
When *node_ids* is provided the search is restricted to those
node ids in SQL (``node_id = ANY(...)``), so callers can scope
results (e.g. to a single organization) without fetching and
post-filtering a wider result set. An empty collection matches
nothing.
"""
self._require_open()
vector = await embeddings.aembed_one(
query,
model_name,
)
vec = sql.Placeholder('vec')
dims = embeddings.get_dimensions(model_name)
distance = sql.SQL(
'(embedding::vector({dims})) <=> ({vec}::vector({dims}))',
).format(
dims=sql.Literal(dims),
vec=vec,
)
params: dict[str, typing.Any] = {
'vec': vector,
'model': model_name,
'limit': limit,
}
query_sql = sql.SQL(
'SELECT node_label, node_id, attribute,'
' chunk_text,'
' {distance} AS distance'
' FROM public.embeddings'
' WHERE model_name = {model}'
).format(
distance=distance,
model=sql.Placeholder('model'),
)
if node_label is not None:
params['label'] = node_label
query_sql += sql.SQL(
' AND node_label = {label}',
).format(label=sql.Placeholder('label'))
if attribute is not None:
params['attribute'] = attribute
query_sql += sql.SQL(
' AND attribute = {attribute}',
).format(attribute=sql.Placeholder('attribute'))
if node_ids is not None:
params['node_ids'] = list(node_ids)
query_sql += sql.SQL(
' AND node_id = ANY({node_ids})',
).format(node_ids=sql.Placeholder('node_ids'))
if distance_threshold is not None:
params['threshold'] = distance_threshold
query_sql += sql.SQL(
' AND {distance} <= {threshold}',
).format(
distance=distance,
threshold=sql.Placeholder('threshold'),
)
query_sql += sql.SQL(
' ORDER BY {distance} LIMIT {limit}',
).format(
distance=distance,
limit=sql.Placeholder('limit'),
)
async with self.pool.connection() as conn:
async with conn.cursor(
row_factory=rows.dict_row,
) as cur:
await cur.execute(query_sql, params)
result_rows = await cur.fetchall()
return [
SearchResult(
node_label=r['node_label'],
node_id=r['node_id'],
attribute=r['attribute'],
chunk_text=r['chunk_text'],
distance=r['distance'],
)
for r in result_rows
]
|
search_nodes
async
search_nodes(
node_type: type[ModelT],
query: str,
*,
model_name: str = 'text',
limit: int = 10,
) -> list[ModelT]
Search and return full node instances.
Combines vector search with graph node retrieval.
Results are deduplicated by id (multiple chunks
from the same node may match).
Source code in libraries/common/src/imbi/common/graph/client.py
| async def search_nodes(
self,
node_type: type[ModelT],
query: str,
*,
model_name: str = 'text',
limit: int = 10,
) -> list[ModelT]:
"""Search and return full node instances.
Combines vector search with graph node retrieval.
Results are deduplicated by ``id`` (multiple chunks
from the same node may match).
"""
# Over-fetch embedding rows so deduplication still
# yields enough distinct nodes for the requested limit.
chunk_multiplier = 5
results = await self.search(
query,
model_name=model_name,
node_label=node_type.__name__,
limit=limit * chunk_multiplier,
)
node_ids = list(
dict.fromkeys(r.node_id for r in results),
)[:limit]
if not node_ids:
return []
label = node_type.__name__
id_list = ', '.join(f'{{{f"id{i}"}}}' for i in range(len(node_ids)))
cypher_q = f'MATCH (n:{label}) WHERE n.id IN [{id_list}] RETURN n'
params: dict[str, typing.Any] = {
f'id{i}': nid for i, nid in enumerate(node_ids)
}
raw_rows = await self.execute(cypher_q, params)
# Re-order to match the ranking from search()
by_id: dict[str, ModelT] = {}
for row in raw_rows:
for value in row.values():
props = parse_agtype(value)
if isinstance(props, dict):
nid = props.get('id')
node = self._row_to_model(node_type, props)
if nid is not None:
by_id[nid] = node
return [by_id[nid] for nid in node_ids if nid in by_id]
|
graph_lifespan
async
graph_lifespan() -> abc.AsyncIterator[Graph]
Source code in libraries/common/src/imbi/common/graph/__init__.py
| @contextlib.asynccontextmanager
async def graph_lifespan() -> abc.AsyncIterator[Graph]:
await initialize()
graph = Graph()
await graph.open()
try:
if _on_startup is not None:
await _on_startup(graph)
yield graph
finally:
await graph.close()
|
Pool
module-attribute
Pool = Annotated[Graph, Depends(_inject_graph)]
initialize
async
Initialize the graph database based on schemata.toml.
Source code in libraries/common/src/imbi/common/graph/initializer.py
| async def initialize() -> None:
"""Initialize the graph database based on schemata.toml."""
postgres = settings.Postgres()
path = pathlib.Path(__file__).parent / 'schemata.toml'
schemata = tomllib.loads(path.read_text())
async with await psycopg.AsyncConnection.connect(
str(postgres.url),
autocommit=True,
) as conn:
await conn.execute(
'SET search_path = ag_catalog, "$user", public',
)
async with conn.cursor() as cursor:
await _create_extensions(cursor, schemata)
await _create_graph(cursor, postgres.graph_name)
await _create_vlabels(
cursor,
postgres.graph_name,
schemata,
)
await _create_vlabel_indexes(
cursor,
postgres.graph_name,
schemata,
)
await _create_embeddings_table(
cursor,
schemata,
)
await _create_functions(cursor, schemata)
|
parse_agtype
parse_agtype(value: Any) -> typing.Any
Parse a single agtype value into a Python dict.
AGE returns vertices as {...}::vertex strings. This
strips the suffix, parses the JSON, and extracts the
properties dict when present.
Source code in libraries/common/src/imbi/common/graph/client.py
| def parse_agtype(value: typing.Any) -> typing.Any:
"""Parse a single agtype value into a Python dict.
AGE returns vertices as ``{...}::vertex`` strings. This
strips the suffix, parses the JSON, and extracts the
``properties`` dict when present.
"""
if value is None or not isinstance(value, str):
return value
cleaned = _AGTYPE_SUFFIX.sub('', value)
try:
parsed = json.loads(cleaned)
except (json.JSONDecodeError, TypeError):
return value
if isinstance(parsed, dict) and 'properties' in parsed:
return parsed['properties']
return parsed
|
SearchResult
dataclass
SearchResult(
node_label: str,
node_id: str,
attribute: str,
chunk_text: str,
distance: float,
)
A vector search result with distance score.
StatementMatchedNothing
Bases: Exception
A batch statement that must return rows returned none.
embeddable_node_types
cached
embeddable_node_types() -> tuple[
type[models.GraphModel], ...
]
Every graph model that declares at least one Embeddable field.
Derived from the model definitions rather than hand-listed, so a
model that gains an Embeddable field is picked up by the
reindex paths without a second place to update. Node is
excluded: it declares the shared name/description fields
its subclasses inherit but is never itself a vertex label.
Ordered by label for stable enumeration.
Source code in libraries/common/src/imbi/common/graph/client.py
| @functools.cache
def embeddable_node_types() -> tuple[type[models.GraphModel], ...]:
"""Every graph model that declares at least one ``Embeddable`` field.
Derived from the model definitions rather than hand-listed, so a
model that gains an ``Embeddable`` field is picked up by the
reindex paths without a second place to update. ``Node`` is
excluded: it declares the shared ``name``/``description`` fields
its subclasses inherit but is never itself a vertex label.
Ordered by label for stable enumeration.
"""
seen: dict[str, type[models.GraphModel]] = {}
queue: list[type[models.GraphModel]] = [models.GraphModel]
while queue:
for subclass in queue.pop().__subclasses__():
if subclass.__name__ in seen:
continue
queue.append(subclass)
if subclass is models.Node:
continue
if _embeddable_descriptors(subclass):
seen[subclass.__name__] = subclass
return tuple(seen[label] for label in sorted(seen))
|
Cypher Query Generation
Statement
Bases: NamedTuple
A Cypher query template paired with its parameter values.
When expect_rows is true, a batch that runs the statement
fails and rolls back if the statement returns no rows.
create
create(node: GraphModel) -> list[Statement]
Generate CREATE statements for node and its edges.
Returns a list where the first entry creates the node and
subsequent entries create each relationship.
Source code in libraries/common/src/imbi/common/graph/cypher.py
| def create(node: models.GraphModel) -> list[Statement]:
"""Generate ``CREATE`` statements for *node* and its edges.
Returns a list where the first entry creates the node and
subsequent entries create each relationship.
"""
props = _node_properties(node)
cypher = f'CREATE (n:{_label(node)} {_props_template(props)}) RETURN n'
statements = [Statement(cypher=cypher, params=props)]
statements.extend(_edge_statements(node))
return statements
|
delete
delete(node: GraphModel) -> Statement
Generate a DETACH DELETE statement for node.
Source code in libraries/common/src/imbi/common/graph/cypher.py
| def delete(node: models.GraphModel) -> Statement:
"""Generate a ``DETACH DELETE`` statement for *node*."""
key, val = _identity(node)
return Statement(
cypher=(
f'MATCH (n:{_label(node)} {{{{{key}: {{key}}}}}}) '
f'DETACH DELETE n RETURN n'
),
params={'key': val},
)
|
match
match(
node_type: type[BaseModel],
params: dict[str, Any] | None = None,
order_by: str | None = None,
) -> Statement
Generate a MATCH statement for node_type.
When params is provided the matched nodes are filtered by
those properties; otherwise all nodes of the label are returned.
order_by, when given, appends ORDER BY n.<field>.
Source code in libraries/common/src/imbi/common/graph/cypher.py
| def match(
node_type: type[pydantic.BaseModel],
params: dict[str, typing.Any] | None = None,
order_by: str | None = None,
) -> Statement:
"""Generate a ``MATCH`` statement for *node_type*.
When *params* is provided the matched nodes are filtered by
those properties; otherwise all nodes of the label are returned.
*order_by*, when given, appends ``ORDER BY n.<field>``.
"""
params = dict(params) if params else {}
label = _label(node_type)
edge_names = {n for n, _, _ in _edge_fields(node_type)}
scalar = set(node_type.model_fields) - edge_names
if params:
bad = [k for k in params if k not in scalar]
if bad:
raise ValueError(f'Unknown field(s) for {label}: {", ".join(bad)}')
cypher = f'MATCH (n:{label} {_props_template(params)}) RETURN n'
else:
cypher = f'MATCH (n:{label}) RETURN n'
if order_by:
if order_by not in scalar:
raise ValueError(f'Unknown order_by field for {label}: {order_by}')
cypher += f' ORDER BY n.{order_by}'
return Statement(cypher=cypher, params=params)
|
merge
merge(
node: GraphModel, match_on: list[str] | None = None
) -> list[Statement]
Generate MERGE statements for node and its edges.
match_on lists the property names used to identify the node
for the MERGE clause. Defaults to ['slug'] for
Node subclasses (stable business key) and ['id'] for
plain GraphModel subclasses. All other non-None scalar
properties appear in the SET clause.
id and created_at use COALESCE so they are written
on first creation but preserved on subsequent merges (Apache
AGE does not support ON CREATE SET / ON MATCH SET).
Properties whose value is None are omitted so that existing
graph values are preserved rather than being deleted.
Source code in libraries/common/src/imbi/common/graph/cypher.py
| def merge(
node: models.GraphModel,
match_on: list[str] | None = None,
) -> list[Statement]:
"""Generate ``MERGE`` statements for *node* and its edges.
*match_on* lists the property names used to identify the node
for the ``MERGE`` clause. Defaults to ``['slug']`` for
``Node`` subclasses (stable business key) and ``['id']`` for
plain ``GraphModel`` subclasses. All other non-None scalar
properties appear in the ``SET`` clause.
``id`` and ``created_at`` use ``COALESCE`` so they are written
on first creation but preserved on subsequent merges (Apache
AGE does not support ``ON CREATE SET`` / ``ON MATCH SET``).
Properties whose value is ``None`` are omitted so that existing
graph values are preserved rather than being deleted.
"""
if match_on is None:
match_on = [_identity(node)[0]]
if not match_on:
raise ValueError('match_on must contain at least one key')
props = _node_properties(node)
bad = [k for k in match_on if k not in props]
if bad:
raise ValueError(
f'Unknown merge key(s) for {_label(node)}: {", ".join(bad)}'
)
match_props = {k: props[k] for k in match_on}
set_props = {
k: v for k, v in props.items() if k not in match_on and v is not None
}
cypher = f'MERGE (n:{_label(node)} {_props_template(match_props)})'
# Build SET assignments. ``id`` and ``created_at`` use
# COALESCE so the first MERGE persists them but subsequent
# merges preserve the original values (Apache AGE lacks
# ``ON CREATE SET`` / ``ON MATCH SET``).
once_only = {'id', 'created_at'}
assignments: list[str] = []
for k in set_props:
if k in once_only:
assignments.append(
f'n.{k} = coalesce(n.{k}, {{{k}}})',
)
else:
assignments.append(f'n.{k} = {{{k}}}')
if assignments:
cypher += ' SET ' + ', '.join(assignments)
cypher += ' RETURN n'
statements = [Statement(cypher=cypher, params=props)]
statements.extend(_edge_statements(node, verb='MERGE'))
return statements
|