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 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."""