age-orm 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- age_orm/__init__.py +24 -0
- age_orm/database.py +201 -0
- age_orm/event.py +31 -0
- age_orm/exceptions.py +29 -0
- age_orm/graph.py +792 -0
- age_orm/models/__init__.py +4 -0
- age_orm/models/base.py +267 -0
- age_orm/models/edge.py +51 -0
- age_orm/models/vertex.py +21 -0
- age_orm/query/__init__.py +3 -0
- age_orm/query/builder.py +450 -0
- age_orm/references.py +74 -0
- age_orm/utils/__init__.py +0 -0
- age_orm/utils/serialization.py +199 -0
- age_orm-0.1.0.dist-info/METADATA +187 -0
- age_orm-0.1.0.dist-info/RECORD +18 -0
- age_orm-0.1.0.dist-info/WHEEL +4 -0
- age_orm-0.1.0.dist-info/licenses/LICENSE +21 -0
age_orm/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""age-orm: A Python ORM for Apache AGE graph database."""
|
|
2
|
+
|
|
3
|
+
from .models import Vertex, Edge
|
|
4
|
+
from .graph import Graph, AsyncGraph
|
|
5
|
+
from .database import Database, AsyncDatabase
|
|
6
|
+
from .query import Query, AsyncQuery
|
|
7
|
+
from .references import relationship
|
|
8
|
+
from .event import listen, listens_for
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Vertex",
|
|
14
|
+
"Edge",
|
|
15
|
+
"Graph",
|
|
16
|
+
"AsyncGraph",
|
|
17
|
+
"Database",
|
|
18
|
+
"AsyncDatabase",
|
|
19
|
+
"Query",
|
|
20
|
+
"AsyncQuery",
|
|
21
|
+
"relationship",
|
|
22
|
+
"listen",
|
|
23
|
+
"listens_for",
|
|
24
|
+
]
|
age_orm/database.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Database connection and pool management for Apache AGE."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from psycopg import Connection
|
|
9
|
+
from psycopg_pool import ConnectionPool, AsyncConnectionPool
|
|
10
|
+
|
|
11
|
+
from age_orm.exceptions import GraphNotFoundError, GraphExistsError
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from age_orm.graph import Graph, AsyncGraph
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _configure_age_connection(conn: Connection) -> None:
|
|
20
|
+
"""Configure a connection for AGE: load extension and set search path.
|
|
21
|
+
|
|
22
|
+
Uses autocommit to avoid leaving the connection in INTRANS state,
|
|
23
|
+
which would cause psycopg_pool to discard the connection.
|
|
24
|
+
"""
|
|
25
|
+
conn.autocommit = True
|
|
26
|
+
conn.execute("LOAD 'age'")
|
|
27
|
+
conn.execute('SET search_path = ag_catalog, "$user", public')
|
|
28
|
+
conn.autocommit = False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Database:
|
|
32
|
+
"""Synchronous database connection manager for Apache AGE.
|
|
33
|
+
|
|
34
|
+
Manages a connection pool and provides graph-level operations.
|
|
35
|
+
|
|
36
|
+
Usage:
|
|
37
|
+
db = Database("postgresql://user:pass@localhost:5433/agedb")
|
|
38
|
+
graph = db.graph("my_graph", create=True)
|
|
39
|
+
# ... use graph ...
|
|
40
|
+
db.close()
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, dsn: str, **pool_kwargs):
|
|
44
|
+
self._dsn = dsn
|
|
45
|
+
pool_kwargs.setdefault("min_size", 1)
|
|
46
|
+
pool_kwargs.setdefault("max_size", 10)
|
|
47
|
+
self._pool = ConnectionPool(
|
|
48
|
+
dsn,
|
|
49
|
+
configure=_configure_age_connection,
|
|
50
|
+
**pool_kwargs,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def graph(self, name: str, create: bool = False) -> "Graph":
|
|
54
|
+
"""Get a Graph handle for the named graph.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
name: The graph name.
|
|
58
|
+
create: If True, create the graph if it doesn't exist.
|
|
59
|
+
"""
|
|
60
|
+
from age_orm.graph import Graph
|
|
61
|
+
|
|
62
|
+
if create and not self.graph_exists(name):
|
|
63
|
+
return self.create_graph(name)
|
|
64
|
+
|
|
65
|
+
if not self.graph_exists(name):
|
|
66
|
+
raise GraphNotFoundError(f"Graph '{name}' does not exist")
|
|
67
|
+
|
|
68
|
+
return Graph(name=name, db=self)
|
|
69
|
+
|
|
70
|
+
def create_graph(self, name: str) -> "Graph":
|
|
71
|
+
"""Create a new graph and return a Graph handle."""
|
|
72
|
+
from age_orm.graph import Graph
|
|
73
|
+
|
|
74
|
+
if self.graph_exists(name):
|
|
75
|
+
raise GraphExistsError(f"Graph '{name}' already exists")
|
|
76
|
+
|
|
77
|
+
with self._pool.connection() as conn:
|
|
78
|
+
conn.execute("SELECT create_graph(%s)", (name,))
|
|
79
|
+
log.info("Created graph: %s", name)
|
|
80
|
+
return Graph(name=name, db=self)
|
|
81
|
+
|
|
82
|
+
def drop_graph(self, name: str, cascade: bool = True) -> None:
|
|
83
|
+
"""Drop a graph."""
|
|
84
|
+
if not self.graph_exists(name):
|
|
85
|
+
raise GraphNotFoundError(f"Graph '{name}' does not exist")
|
|
86
|
+
|
|
87
|
+
with self._pool.connection() as conn:
|
|
88
|
+
conn.execute("SELECT drop_graph(%s, %s)", (name, cascade))
|
|
89
|
+
log.info("Dropped graph: %s", name)
|
|
90
|
+
|
|
91
|
+
def graph_exists(self, name: str) -> bool:
|
|
92
|
+
"""Check if a graph exists."""
|
|
93
|
+
with self._pool.connection() as conn:
|
|
94
|
+
result = conn.execute(
|
|
95
|
+
"SELECT 1 FROM ag_catalog.ag_graph WHERE name = %s", (name,)
|
|
96
|
+
).fetchone()
|
|
97
|
+
return result is not None
|
|
98
|
+
|
|
99
|
+
def list_graphs(self) -> list[str]:
|
|
100
|
+
"""List all graph names."""
|
|
101
|
+
with self._pool.connection() as conn:
|
|
102
|
+
rows = conn.execute(
|
|
103
|
+
"SELECT name FROM ag_catalog.ag_graph"
|
|
104
|
+
).fetchall()
|
|
105
|
+
return [row[0] for row in rows]
|
|
106
|
+
|
|
107
|
+
def close(self) -> None:
|
|
108
|
+
"""Close the connection pool."""
|
|
109
|
+
self._pool.close()
|
|
110
|
+
|
|
111
|
+
def __enter__(self) -> "Database":
|
|
112
|
+
return self
|
|
113
|
+
|
|
114
|
+
def __exit__(self, *args) -> None:
|
|
115
|
+
self.close()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class AsyncDatabase:
|
|
119
|
+
"""Asynchronous database connection manager for Apache AGE.
|
|
120
|
+
|
|
121
|
+
Usage:
|
|
122
|
+
async with AsyncDatabase("postgresql://...") as db:
|
|
123
|
+
graph = await db.graph("my_graph", create=True)
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def __init__(self, dsn: str, **pool_kwargs):
|
|
127
|
+
self._dsn = dsn
|
|
128
|
+
pool_kwargs.setdefault("min_size", 1)
|
|
129
|
+
pool_kwargs.setdefault("max_size", 10)
|
|
130
|
+
self._pool = AsyncConnectionPool(
|
|
131
|
+
dsn,
|
|
132
|
+
configure=self._configure_connection,
|
|
133
|
+
**pool_kwargs,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
async def _configure_connection(conn) -> None:
|
|
138
|
+
"""Configure a connection for AGE (async version)."""
|
|
139
|
+
await conn.set_autocommit(True)
|
|
140
|
+
await conn.execute("LOAD 'age'")
|
|
141
|
+
await conn.execute('SET search_path = ag_catalog, "$user", public')
|
|
142
|
+
await conn.set_autocommit(False)
|
|
143
|
+
|
|
144
|
+
async def graph(self, name: str, create: bool = False) -> "AsyncGraph":
|
|
145
|
+
"""Get an AsyncGraph handle for the named graph."""
|
|
146
|
+
from age_orm.graph import AsyncGraph
|
|
147
|
+
|
|
148
|
+
if create and not await self.graph_exists(name):
|
|
149
|
+
return await self.create_graph(name)
|
|
150
|
+
|
|
151
|
+
if not await self.graph_exists(name):
|
|
152
|
+
raise GraphNotFoundError(f"Graph '{name}' does not exist")
|
|
153
|
+
|
|
154
|
+
return AsyncGraph(name=name, db=self)
|
|
155
|
+
|
|
156
|
+
async def create_graph(self, name: str) -> "AsyncGraph":
|
|
157
|
+
"""Create a new graph and return an AsyncGraph handle."""
|
|
158
|
+
from age_orm.graph import AsyncGraph
|
|
159
|
+
|
|
160
|
+
if await self.graph_exists(name):
|
|
161
|
+
raise GraphExistsError(f"Graph '{name}' already exists")
|
|
162
|
+
|
|
163
|
+
async with self._pool.connection() as conn:
|
|
164
|
+
await conn.execute("SELECT create_graph(%s)", (name,))
|
|
165
|
+
log.info("Created graph: %s", name)
|
|
166
|
+
return AsyncGraph(name=name, db=self)
|
|
167
|
+
|
|
168
|
+
async def drop_graph(self, name: str, cascade: bool = True) -> None:
|
|
169
|
+
"""Drop a graph."""
|
|
170
|
+
if not await self.graph_exists(name):
|
|
171
|
+
raise GraphNotFoundError(f"Graph '{name}' does not exist")
|
|
172
|
+
|
|
173
|
+
async with self._pool.connection() as conn:
|
|
174
|
+
await conn.execute("SELECT drop_graph(%s, %s)", (name, cascade))
|
|
175
|
+
log.info("Dropped graph: %s", name)
|
|
176
|
+
|
|
177
|
+
async def graph_exists(self, name: str) -> bool:
|
|
178
|
+
"""Check if a graph exists."""
|
|
179
|
+
async with self._pool.connection() as conn:
|
|
180
|
+
result = await conn.execute(
|
|
181
|
+
"SELECT 1 FROM ag_catalog.ag_graph WHERE name = %s", (name,)
|
|
182
|
+
)
|
|
183
|
+
row = await result.fetchone()
|
|
184
|
+
return row is not None
|
|
185
|
+
|
|
186
|
+
async def list_graphs(self) -> list[str]:
|
|
187
|
+
"""List all graph names."""
|
|
188
|
+
async with self._pool.connection() as conn:
|
|
189
|
+
result = await conn.execute("SELECT name FROM ag_catalog.ag_graph")
|
|
190
|
+
rows = await result.fetchall()
|
|
191
|
+
return [row[0] for row in rows]
|
|
192
|
+
|
|
193
|
+
async def close(self) -> None:
|
|
194
|
+
"""Close the connection pool."""
|
|
195
|
+
await self._pool.close()
|
|
196
|
+
|
|
197
|
+
async def __aenter__(self) -> "AsyncDatabase":
|
|
198
|
+
return self
|
|
199
|
+
|
|
200
|
+
async def __aexit__(self, *args) -> None:
|
|
201
|
+
await self.close()
|
age_orm/event.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Event system for pre/post hooks on graph operations."""
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
|
|
5
|
+
_registrars: dict = defaultdict(lambda: defaultdict(list))
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def dispatch(target, event: str, *args, **kwargs):
|
|
9
|
+
"""Fire given event for all registered handlers matching the target's type."""
|
|
10
|
+
by_event = _registrars[event]
|
|
11
|
+
for target_class in by_event:
|
|
12
|
+
if isinstance(target, target_class):
|
|
13
|
+
for fn in by_event[target_class]:
|
|
14
|
+
fn(target, event, *args, **kwargs)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def listen(target, event: str | list[str], fn):
|
|
18
|
+
"""Register fn to listen for event(s) on target class."""
|
|
19
|
+
events = [event] if isinstance(event, str) else event
|
|
20
|
+
for ev in events:
|
|
21
|
+
_registrars[ev][target].append(fn)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def listens_for(target, event: str | list[str]):
|
|
25
|
+
"""Decorator to register fn to listen for event(s) on target class."""
|
|
26
|
+
|
|
27
|
+
def decorator(fn):
|
|
28
|
+
listen(target, event, fn)
|
|
29
|
+
return fn
|
|
30
|
+
|
|
31
|
+
return decorator
|
age_orm/exceptions.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Custom exceptions for age-orm."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AgeORMError(Exception):
|
|
5
|
+
"""Base exception for all age-orm errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GraphNotFoundError(AgeORMError):
|
|
9
|
+
"""Raised when a graph does not exist."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GraphExistsError(AgeORMError):
|
|
13
|
+
"""Raised when trying to create a graph that already exists."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LabelNotFoundError(AgeORMError):
|
|
17
|
+
"""Raised when a vertex/edge label does not exist."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DetachedInstanceError(AgeORMError):
|
|
21
|
+
"""Raised when accessing a relationship on an entity not bound to a database."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class EntityNotFoundError(AgeORMError):
|
|
25
|
+
"""Raised when an entity lookup returns no results."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MultipleResultsError(AgeORMError):
|
|
29
|
+
"""Raised when a single-result query returns multiple results."""
|