age-orm 0.1.0__tar.gz
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-0.1.0/.envrc +2 -0
- age_orm-0.1.0/LICENSE +21 -0
- age_orm-0.1.0/PKG-INFO +187 -0
- age_orm-0.1.0/README.md +156 -0
- age_orm-0.1.0/age_orm/__init__.py +24 -0
- age_orm-0.1.0/age_orm/database.py +201 -0
- age_orm-0.1.0/age_orm/event.py +31 -0
- age_orm-0.1.0/age_orm/exceptions.py +29 -0
- age_orm-0.1.0/age_orm/graph.py +792 -0
- age_orm-0.1.0/age_orm/models/__init__.py +4 -0
- age_orm-0.1.0/age_orm/models/base.py +267 -0
- age_orm-0.1.0/age_orm/models/edge.py +51 -0
- age_orm-0.1.0/age_orm/models/vertex.py +21 -0
- age_orm-0.1.0/age_orm/query/__init__.py +3 -0
- age_orm-0.1.0/age_orm/query/builder.py +450 -0
- age_orm-0.1.0/age_orm/references.py +74 -0
- age_orm-0.1.0/age_orm/utils/__init__.py +0 -0
- age_orm-0.1.0/age_orm/utils/serialization.py +199 -0
- age_orm-0.1.0/pyproject.toml +56 -0
- age_orm-0.1.0/tests/__init__.py +0 -0
- age_orm-0.1.0/tests/conftest.py +71 -0
- age_orm-0.1.0/tests/test_database.py +40 -0
- age_orm-0.1.0/tests/test_event.py +91 -0
- age_orm-0.1.0/tests/test_integration.py +482 -0
- age_orm-0.1.0/tests/test_models.py +124 -0
- age_orm-0.1.0/tests/test_query.py +208 -0
- age_orm-0.1.0/tests/test_references.py +67 -0
- age_orm-0.1.0/tests/test_serialization.py +198 -0
- age_orm-0.1.0/tutorial-practice/quick_example.py +33 -0
- age_orm-0.1.0/uv.lock +603 -0
age_orm-0.1.0/.envrc
ADDED
age_orm-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026 Kashif Iftikhar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
age_orm-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: age-orm
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Python ORM for Apache AGE graph database
|
|
5
|
+
Project-URL: Homepage, https://age-forge.github.io/projects/age-orm
|
|
6
|
+
Project-URL: Documentation, https://age-forge.github.io/docs/
|
|
7
|
+
Project-URL: Repository, https://github.com/age-forge/age-orm
|
|
8
|
+
Project-URL: Issues, https://github.com/age-forge/age-orm/issues
|
|
9
|
+
Author-email: Kashif Iftikhar <kashif@compulife.com.pk>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Database
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.12
|
|
22
|
+
Requires-Dist: psycopg[binary,pool]>=3.2
|
|
23
|
+
Requires-Dist: pydantic>=2.3.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: ipython>=9.10.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# age-orm
|
|
33
|
+
|
|
34
|
+
A Python ORM for Apache AGE, providing SQLAlchemy-like abstractions for graph database operations.
|
|
35
|
+
|
|
36
|
+
**Status:** v0.1.0 — Core implemented (models, CRUD, query builder, relationships, async)
|
|
37
|
+
|
|
38
|
+
## Features
|
|
39
|
+
|
|
40
|
+
### Core
|
|
41
|
+
- [x] Pydantic v2-based model definitions for vertices and edges
|
|
42
|
+
- [x] Graph, Vertex, and Edge abstractions
|
|
43
|
+
- [x] Automatic schema creation (labels, indexes)
|
|
44
|
+
- [x] CRUD operations (create, read, update, delete)
|
|
45
|
+
- [x] Connection pooling via psycopg3
|
|
46
|
+
|
|
47
|
+
### Query Building
|
|
48
|
+
- [x] Fluent Cypher query builder (filter, sort, limit, skip)
|
|
49
|
+
- [x] Safe parameter substitution
|
|
50
|
+
- [x] Raw Cypher support
|
|
51
|
+
- [x] Bulk mutations (update/delete via query)
|
|
52
|
+
|
|
53
|
+
### Advanced
|
|
54
|
+
- [x] Sync + Async support (psycopg3's unified API)
|
|
55
|
+
- [x] Relationship descriptors with lazy loading
|
|
56
|
+
- [x] Event system (pre/post hooks for add, update, delete)
|
|
57
|
+
- [x] Bulk import (direct SQL INSERT for performance)
|
|
58
|
+
- [x] Graph traversal helpers (expand, traverse)
|
|
59
|
+
- [x] Dirty tracking (only update changed fields)
|
|
60
|
+
- [ ] Migrations and schema versioning
|
|
61
|
+
- [ ] Integration with AGEFreighter for bulk loads
|
|
62
|
+
|
|
63
|
+
## Installation
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# With uv
|
|
67
|
+
uv add age-orm
|
|
68
|
+
|
|
69
|
+
# With pip
|
|
70
|
+
pip install age-orm
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Requires Python 3.12+ and a running Apache AGE instance.
|
|
74
|
+
|
|
75
|
+
## Quick Start
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from age_orm import Database, Vertex, Edge, relationship
|
|
79
|
+
|
|
80
|
+
# Define models
|
|
81
|
+
class Person(Vertex):
|
|
82
|
+
__label__ = "Person"
|
|
83
|
+
name: str
|
|
84
|
+
age: int
|
|
85
|
+
email: str | None = None
|
|
86
|
+
|
|
87
|
+
class Knows(Edge):
|
|
88
|
+
__label__ = "KNOWS"
|
|
89
|
+
since: int
|
|
90
|
+
relationship_type: str = "friend"
|
|
91
|
+
|
|
92
|
+
# Connect
|
|
93
|
+
db = Database("postgresql://ageuser:agepassword@localhost:5433/agedb")
|
|
94
|
+
graph = db.graph("social", create=True)
|
|
95
|
+
|
|
96
|
+
# Create vertices
|
|
97
|
+
alice = Person(name="Alice", age=30)
|
|
98
|
+
bob = Person(name="Bob", age=25)
|
|
99
|
+
graph.add(alice)
|
|
100
|
+
graph.add(bob)
|
|
101
|
+
|
|
102
|
+
# Create edge
|
|
103
|
+
knows = Knows(since=2020)
|
|
104
|
+
graph.connect(alice, knows, bob)
|
|
105
|
+
|
|
106
|
+
# Query
|
|
107
|
+
people = graph.query(Person).filter("n.age > $min_age", min_age=20).sort("n.name").all()
|
|
108
|
+
alice = graph.query(Person).filter_by(name="Alice").one()
|
|
109
|
+
|
|
110
|
+
# Traverse
|
|
111
|
+
friends = graph.traverse(alice, "KNOWS", depth=2, target_class=Person)
|
|
112
|
+
|
|
113
|
+
# Raw Cypher
|
|
114
|
+
results = graph.cypher(
|
|
115
|
+
"MATCH (n:Person)-[:KNOWS]->(m) RETURN n.name, m.name",
|
|
116
|
+
columns=["a", "b"]
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Cleanup
|
|
120
|
+
db.close()
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Async Usage
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
from age_orm import AsyncDatabase
|
|
127
|
+
|
|
128
|
+
async with AsyncDatabase("postgresql://...") as db:
|
|
129
|
+
graph = await db.graph("social", create=True)
|
|
130
|
+
alice = Person(name="Alice", age=30)
|
|
131
|
+
await graph.add(alice)
|
|
132
|
+
|
|
133
|
+
q = await graph.query(Person)
|
|
134
|
+
people = await q.filter("n.age > $min", min=20).all()
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Relationships
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
class Person(Vertex):
|
|
141
|
+
__label__ = "Person"
|
|
142
|
+
name: str
|
|
143
|
+
friends: list["Person"] = relationship("Person", "KNOWS", direction="outbound")
|
|
144
|
+
employer: "Company" = relationship("Company", "WORKS_AT", uselist=False)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Relationships are lazy-loaded on access when the entity is bound to a graph.
|
|
148
|
+
|
|
149
|
+
## Event Hooks
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
from age_orm import listen, listens_for
|
|
153
|
+
|
|
154
|
+
@listens_for(Person, "pre_add")
|
|
155
|
+
def validate_person(target, event, **kwargs):
|
|
156
|
+
if target.age < 0:
|
|
157
|
+
raise ValueError("Age cannot be negative")
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Dependencies
|
|
161
|
+
|
|
162
|
+
- `psycopg[binary,pool] >= 3.2` — PostgreSQL driver with connection pooling
|
|
163
|
+
- `pydantic >= 2.3` — Data validation and models
|
|
164
|
+
|
|
165
|
+
## Project Structure
|
|
166
|
+
|
|
167
|
+
```
|
|
168
|
+
age_orm/
|
|
169
|
+
├── __init__.py # Public API exports
|
|
170
|
+
├── exceptions.py # Custom exceptions
|
|
171
|
+
├── event.py # Event system (pre/post hooks)
|
|
172
|
+
├── database.py # Connection + pool management
|
|
173
|
+
├── graph.py # Graph class + CRUD + traversal
|
|
174
|
+
├── references.py # Relationship descriptors
|
|
175
|
+
├── models/
|
|
176
|
+
│ ├── base.py # AgeModel base class
|
|
177
|
+
│ ├── vertex.py # Vertex model
|
|
178
|
+
│ └── edge.py # Edge model
|
|
179
|
+
├── query/
|
|
180
|
+
│ └── builder.py # Cypher query builder
|
|
181
|
+
└── utils/
|
|
182
|
+
└── serialization.py # Agtype serialization helpers
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## License
|
|
186
|
+
|
|
187
|
+
MIT
|
age_orm-0.1.0/README.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# age-orm
|
|
2
|
+
|
|
3
|
+
A Python ORM for Apache AGE, providing SQLAlchemy-like abstractions for graph database operations.
|
|
4
|
+
|
|
5
|
+
**Status:** v0.1.0 — Core implemented (models, CRUD, query builder, relationships, async)
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
### Core
|
|
10
|
+
- [x] Pydantic v2-based model definitions for vertices and edges
|
|
11
|
+
- [x] Graph, Vertex, and Edge abstractions
|
|
12
|
+
- [x] Automatic schema creation (labels, indexes)
|
|
13
|
+
- [x] CRUD operations (create, read, update, delete)
|
|
14
|
+
- [x] Connection pooling via psycopg3
|
|
15
|
+
|
|
16
|
+
### Query Building
|
|
17
|
+
- [x] Fluent Cypher query builder (filter, sort, limit, skip)
|
|
18
|
+
- [x] Safe parameter substitution
|
|
19
|
+
- [x] Raw Cypher support
|
|
20
|
+
- [x] Bulk mutations (update/delete via query)
|
|
21
|
+
|
|
22
|
+
### Advanced
|
|
23
|
+
- [x] Sync + Async support (psycopg3's unified API)
|
|
24
|
+
- [x] Relationship descriptors with lazy loading
|
|
25
|
+
- [x] Event system (pre/post hooks for add, update, delete)
|
|
26
|
+
- [x] Bulk import (direct SQL INSERT for performance)
|
|
27
|
+
- [x] Graph traversal helpers (expand, traverse)
|
|
28
|
+
- [x] Dirty tracking (only update changed fields)
|
|
29
|
+
- [ ] Migrations and schema versioning
|
|
30
|
+
- [ ] Integration with AGEFreighter for bulk loads
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# With uv
|
|
36
|
+
uv add age-orm
|
|
37
|
+
|
|
38
|
+
# With pip
|
|
39
|
+
pip install age-orm
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Requires Python 3.12+ and a running Apache AGE instance.
|
|
43
|
+
|
|
44
|
+
## Quick Start
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from age_orm import Database, Vertex, Edge, relationship
|
|
48
|
+
|
|
49
|
+
# Define models
|
|
50
|
+
class Person(Vertex):
|
|
51
|
+
__label__ = "Person"
|
|
52
|
+
name: str
|
|
53
|
+
age: int
|
|
54
|
+
email: str | None = None
|
|
55
|
+
|
|
56
|
+
class Knows(Edge):
|
|
57
|
+
__label__ = "KNOWS"
|
|
58
|
+
since: int
|
|
59
|
+
relationship_type: str = "friend"
|
|
60
|
+
|
|
61
|
+
# Connect
|
|
62
|
+
db = Database("postgresql://ageuser:agepassword@localhost:5433/agedb")
|
|
63
|
+
graph = db.graph("social", create=True)
|
|
64
|
+
|
|
65
|
+
# Create vertices
|
|
66
|
+
alice = Person(name="Alice", age=30)
|
|
67
|
+
bob = Person(name="Bob", age=25)
|
|
68
|
+
graph.add(alice)
|
|
69
|
+
graph.add(bob)
|
|
70
|
+
|
|
71
|
+
# Create edge
|
|
72
|
+
knows = Knows(since=2020)
|
|
73
|
+
graph.connect(alice, knows, bob)
|
|
74
|
+
|
|
75
|
+
# Query
|
|
76
|
+
people = graph.query(Person).filter("n.age > $min_age", min_age=20).sort("n.name").all()
|
|
77
|
+
alice = graph.query(Person).filter_by(name="Alice").one()
|
|
78
|
+
|
|
79
|
+
# Traverse
|
|
80
|
+
friends = graph.traverse(alice, "KNOWS", depth=2, target_class=Person)
|
|
81
|
+
|
|
82
|
+
# Raw Cypher
|
|
83
|
+
results = graph.cypher(
|
|
84
|
+
"MATCH (n:Person)-[:KNOWS]->(m) RETURN n.name, m.name",
|
|
85
|
+
columns=["a", "b"]
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Cleanup
|
|
89
|
+
db.close()
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Async Usage
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from age_orm import AsyncDatabase
|
|
96
|
+
|
|
97
|
+
async with AsyncDatabase("postgresql://...") as db:
|
|
98
|
+
graph = await db.graph("social", create=True)
|
|
99
|
+
alice = Person(name="Alice", age=30)
|
|
100
|
+
await graph.add(alice)
|
|
101
|
+
|
|
102
|
+
q = await graph.query(Person)
|
|
103
|
+
people = await q.filter("n.age > $min", min=20).all()
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Relationships
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
class Person(Vertex):
|
|
110
|
+
__label__ = "Person"
|
|
111
|
+
name: str
|
|
112
|
+
friends: list["Person"] = relationship("Person", "KNOWS", direction="outbound")
|
|
113
|
+
employer: "Company" = relationship("Company", "WORKS_AT", uselist=False)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Relationships are lazy-loaded on access when the entity is bound to a graph.
|
|
117
|
+
|
|
118
|
+
## Event Hooks
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from age_orm import listen, listens_for
|
|
122
|
+
|
|
123
|
+
@listens_for(Person, "pre_add")
|
|
124
|
+
def validate_person(target, event, **kwargs):
|
|
125
|
+
if target.age < 0:
|
|
126
|
+
raise ValueError("Age cannot be negative")
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Dependencies
|
|
130
|
+
|
|
131
|
+
- `psycopg[binary,pool] >= 3.2` — PostgreSQL driver with connection pooling
|
|
132
|
+
- `pydantic >= 2.3` — Data validation and models
|
|
133
|
+
|
|
134
|
+
## Project Structure
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
age_orm/
|
|
138
|
+
├── __init__.py # Public API exports
|
|
139
|
+
├── exceptions.py # Custom exceptions
|
|
140
|
+
├── event.py # Event system (pre/post hooks)
|
|
141
|
+
├── database.py # Connection + pool management
|
|
142
|
+
├── graph.py # Graph class + CRUD + traversal
|
|
143
|
+
├── references.py # Relationship descriptors
|
|
144
|
+
├── models/
|
|
145
|
+
│ ├── base.py # AgeModel base class
|
|
146
|
+
│ ├── vertex.py # Vertex model
|
|
147
|
+
│ └── edge.py # Edge model
|
|
148
|
+
├── query/
|
|
149
|
+
│ └── builder.py # Cypher query builder
|
|
150
|
+
└── utils/
|
|
151
|
+
└── serialization.py # Agtype serialization helpers
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## License
|
|
155
|
+
|
|
156
|
+
MIT
|
|
@@ -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
|
+
]
|
|
@@ -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()
|
|
@@ -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
|
|
@@ -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."""
|