runic-py 0.2.0__tar.gz → 0.2.2__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.
- runic_py-0.2.2/PKG-INFO +294 -0
- runic_py-0.2.2/README.md +273 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/pyproject.toml +3 -3
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/migrate/cli.py +20 -16
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/orm/__init__.py +73 -27
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/orm/core/descriptors.py +118 -0
- runic_py-0.2.2/runic/orm/mapper/__init__.py +5 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/orm/mapper/mapper.py +75 -0
- runic_py-0.2.2/runic/orm/mapper/relationship_writer.py +145 -0
- runic_py-0.2.2/runic/orm/query/__init__.py +63 -0
- runic_py-0.2.2/runic/orm/query/builder.py +1516 -0
- runic_py-0.2.2/runic/orm/query/expressions.py +349 -0
- runic_py-0.2.2/runic/orm/query/traversal.py +144 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/orm/repository/async_repository.py +13 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/orm/repository/repository.py +19 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/orm/session/async_session.py +141 -8
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/orm/session/session.py +206 -9
- runic_py-0.2.0/PKG-INFO +0 -192
- runic_py-0.2.0/README.md +0 -171
- runic_py-0.2.0/runic/orm/session/__init__.py +0 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/.gitignore +0 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/LICENSE.md +0 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/__init__.py +0 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/migrate/__init__.py +0 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/migrate/adapters/__init__.py +0 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/migrate/adapters/falkordb.py +0 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/migrate/autogen.py +0 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/migrate/checksum.py +0 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/migrate/config.py +0 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/migrate/context.py +0 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/migrate/exceptions.py +0 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/migrate/introspect.py +0 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/migrate/manifest.py +0 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/migrate/operations.py +0 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/migrate/script.py +0 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/migrate/service.py +0 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/migrate/templates/env.py.mako +0 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/migrate/templates/script.py.mako +0 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/migrate/testing.py +0 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/migrate/version.py +0 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/orm/core/__init__.py +0 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/orm/core/metadata.py +0 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/orm/core/models.py +0 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/orm/core/types.py +0 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/orm/exceptions.py +0 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/orm/mapper/relationship_loader.py +0 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/orm/repository/__init__.py +0 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/orm/repository/cypher.py +0 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/orm/repository/pagination.py +0 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/orm/repository/protocol.py +0 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/orm/schema/__init__.py +0 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/orm/schema/index_manager.py +0 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/orm/schema/schema_manager.py +0 -0
- {runic_py-0.2.0/runic/orm/mapper → runic_py-0.2.2/runic/orm/session}/__init__.py +0 -0
- {runic_py-0.2.0 → runic_py-0.2.2}/runic/orm/session/connection_pool.py +0 -0
runic_py-0.2.2/PKG-INFO
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: runic-py
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: OGM and schema migrations for FalkorDB.
|
|
5
|
+
Project-URL: Homepage, https://runic-py.readthedocs.io/latest/
|
|
6
|
+
Project-URL: Repository, https://github.com/jenreh/runic
|
|
7
|
+
Author: Jens Rehpöhler
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE.md
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Topic :: Home Automation
|
|
15
|
+
Requires-Python: >=3.14
|
|
16
|
+
Requires-Dist: falkordb<2.0.0,>=1.6.0
|
|
17
|
+
Requires-Dist: mako<2.0.0,>=1.3.0
|
|
18
|
+
Requires-Dist: rich<16.0.0,>=15.0.0
|
|
19
|
+
Requires-Dist: typer<1.0.0,>=0.26.0
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
<div align="center">
|
|
23
|
+
<img src="https://raw.githubusercontent.com/jenreh/runic/refs/heads/main/docs/source/_static/runic.svg" width="240" alt="Runic logo">
|
|
24
|
+
|
|
25
|
+
# Runic
|
|
26
|
+
|
|
27
|
+
**Graph schema migrations and ORM for FalkorDB.**
|
|
28
|
+
|
|
29
|
+

|
|
30
|
+
[](https://www.python.org)
|
|
31
|
+
[](LICENSE.md)
|
|
32
|
+
|
|
33
|
+
[Features](#features) • [Installation](#installation) • [Migrations](#migrations) • [ORM](#runicorm) • [Documentation](#documentation)
|
|
34
|
+
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
**Runic** is a Python toolkit for [FalkorDB](https://falkordb.com/) that covers two layers:
|
|
40
|
+
|
|
41
|
+
- **`runic.migrate`** — Alembic-style schema migrations with revision tracking, a CLI, and rollback snapshots.
|
|
42
|
+
- **`runic.orm`** — A lightweight graph ORM: declare `Node` and `Edge` models, manage sessions, traverse relationships, and sync indexes — all using FalkorDB-native Cypher under the hood.
|
|
43
|
+
|
|
44
|
+
## Features
|
|
45
|
+
|
|
46
|
+
### Migration CLI
|
|
47
|
+
|
|
48
|
+
- **Alembic-Style Workflow** — Familiar CLI verbs: `init`, `revision`, `upgrade`, `downgrade`, `current`, `baseline`.
|
|
49
|
+
- **Graph-Native** — Migration state stored inside dedicated graph nodes (`:_FalkorMigrateVersion`).
|
|
50
|
+
- **Idempotent Cypher** — Explicit, guarded migration steps; safe to replay on an empty graph.
|
|
51
|
+
- **Offline & Dry Run** — Review generated Cypher scripts before running them in production.
|
|
52
|
+
- **Rollback Snapshots** — Uses `GRAPH.COPY` for high-risk, non-reversible migrations.
|
|
53
|
+
|
|
54
|
+
### Graph ORM
|
|
55
|
+
|
|
56
|
+
- **Declarative Models** — `Node` and `Edge` subclasses with typed `Field` descriptors; no metaclass magic.
|
|
57
|
+
- **Session & Repository** — Unit-of-work session with change tracking; typed `Repository` for queries and pagination.
|
|
58
|
+
- **Relationships** — `Relation` field for INCOMING / OUTGOING edges; lazy and eager loading; edge property models.
|
|
59
|
+
- **Schema Management** — `IndexManager` and `SchemaManager` to create, validate, and sync RANGE, FULLTEXT, and UNIQUE indexes.
|
|
60
|
+
- **Native FalkorDB Types** — First-class `Vector` (vecf32), `GeoLocation` (point), interned strings, and auto-converters for `datetime` and `Enum`.
|
|
61
|
+
- **Async Support** — `AsyncSession`, `AsyncRepository`, and `AsyncConnectionManager` for async-first applications.
|
|
62
|
+
|
|
63
|
+
## Installation
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
uv pip install runic
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Or add it to an existing project:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
uv add runic
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
> [!NOTE]
|
|
76
|
+
> Runic requires Python 3.14+ and is optimized for the latest FalkorDB clients.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Migrations
|
|
81
|
+
|
|
82
|
+
Initialize your project and generate a new revision:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
runic init
|
|
86
|
+
runic revision -m "create user index"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Open the generated file in `runic/versions/` and define your upgrade and downgrade:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
revision = "1975ea83b712"
|
|
93
|
+
down_revision = None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def upgrade(op) -> None:
|
|
97
|
+
op.create_range_index("User", "email")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def downgrade(op) -> None:
|
|
101
|
+
op.drop_range_index("User", "email")
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Apply or roll back:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
runic upgrade # apply all pending revisions
|
|
108
|
+
runic downgrade # roll back one step
|
|
109
|
+
runic downgrade 1975e # roll back to a specific revision (prefix is enough)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Baselining an existing graph
|
|
113
|
+
|
|
114
|
+
Bring an unmanaged FalkorDB graph under runic control without re-running anything:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
runic baseline -m "baseline" # introspect, generate root revision, stamp it
|
|
118
|
+
runic current # verify it is now tracked
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The generated revision recreates all indexes from scratch — safe to replay on a fresh graph (CI, cloning, new tenants):
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
runic upgrade head # rebuilds full schema on an empty graph
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Programmatic SDK
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from pathlib import Path
|
|
131
|
+
from runic import Runic, init
|
|
132
|
+
from runic.migrate.adapters import create_adapter
|
|
133
|
+
|
|
134
|
+
init(Path("runic/"))
|
|
135
|
+
|
|
136
|
+
adapter = create_adapter(
|
|
137
|
+
"falkordb", url="falkor://localhost:6379", graph_name="my_graph"
|
|
138
|
+
)
|
|
139
|
+
runic = Runic(adapter, script_location=Path("runic/"))
|
|
140
|
+
runic.migrate.upgrade("head")
|
|
141
|
+
|
|
142
|
+
print("current:", runic.migrate.current())
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## runic.orm
|
|
148
|
+
|
|
149
|
+
### Defining models
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
from runic.orm import Field, Node, Edge, Relation
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class User(Node, labels=["User"]):
|
|
156
|
+
id: str
|
|
157
|
+
email: str = Field(unique=True)
|
|
158
|
+
name: str
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class Post(Node, labels=["Post"]):
|
|
162
|
+
id: str
|
|
163
|
+
title: str = Field(index_type="FULLTEXT")
|
|
164
|
+
published: bool = False
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class AuthoredEdge(Edge, type="AUTHORED"):
|
|
168
|
+
created_at: str # ISO-8601
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class Author(Node, labels=["Author"]):
|
|
172
|
+
id: str
|
|
173
|
+
name: str
|
|
174
|
+
posts: list[Post] = Relation(
|
|
175
|
+
relationship="AUTHORED",
|
|
176
|
+
direction="OUTGOING",
|
|
177
|
+
target="Post",
|
|
178
|
+
edge_model=AuthoredEdge,
|
|
179
|
+
)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### Session-based CRUD
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
from runic.orm import Session, Repository
|
|
186
|
+
|
|
187
|
+
with Session(graph) as session:
|
|
188
|
+
session.add_all([
|
|
189
|
+
User(id="alice", email="alice@example.com", name="Alice"),
|
|
190
|
+
User(id="bob", email="bob@example.com", name="Bob"),
|
|
191
|
+
])
|
|
192
|
+
session.commit()
|
|
193
|
+
|
|
194
|
+
with Session(graph) as session:
|
|
195
|
+
repo = Repository(session, User)
|
|
196
|
+
alice = session.get(User, "alice")
|
|
197
|
+
alice.name = "Alice Smith" # change tracking — no explicit dirty flag
|
|
198
|
+
session.commit()
|
|
199
|
+
|
|
200
|
+
with Session(graph) as session:
|
|
201
|
+
user = session.get(User, "bob")
|
|
202
|
+
session.delete(user)
|
|
203
|
+
session.commit()
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### Relationships
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
# Lazy load (default) — triggers a query on first access
|
|
210
|
+
with Session(graph) as session:
|
|
211
|
+
author = session.get(Author, "alice")
|
|
212
|
+
posts = author.posts # query executed here
|
|
213
|
+
|
|
214
|
+
# Eager load — single round-trip
|
|
215
|
+
with Session(graph) as session:
|
|
216
|
+
author = session.get(Author, "alice", fetch=["posts"])
|
|
217
|
+
posts = author.posts # already loaded, no extra query
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### Pagination and custom queries
|
|
221
|
+
|
|
222
|
+
```python
|
|
223
|
+
from runic.orm import Pageable, Repository
|
|
224
|
+
|
|
225
|
+
with Session(graph) as session:
|
|
226
|
+
repo = Repository(session, User)
|
|
227
|
+
page = repo.find_all_paginated(Pageable(page=0, size=20, sort_by="name"))
|
|
228
|
+
print(f"{len(list(page))} of {page.total_elements} total")
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Extend `Repository` to add typed Cypher helpers:
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
class UserRepository(Repository[User]):
|
|
235
|
+
def find_by_email(self, email: str) -> User | None:
|
|
236
|
+
return self.cypher_one(
|
|
237
|
+
"MATCH (u:User {email: $email}) RETURN u",
|
|
238
|
+
{"email": email},
|
|
239
|
+
returns=User,
|
|
240
|
+
)
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### Schema management
|
|
244
|
+
|
|
245
|
+
Declare indexes inline on `Field`, then let `SchemaManager` keep the live graph in sync:
|
|
246
|
+
|
|
247
|
+
```python
|
|
248
|
+
from runic.orm import Field, IndexManager, Node, SchemaManager
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class Place(Node, labels=["Place"]):
|
|
252
|
+
id: str
|
|
253
|
+
name: str = Field(index_type="FULLTEXT")
|
|
254
|
+
slug: str = Field(unique=True)
|
|
255
|
+
lat: float = Field(index=True)
|
|
256
|
+
lon: float = Field(index=True)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
schema = SchemaManager(graph)
|
|
260
|
+
schema.sync_schema([Place], drop_extra=False) # create missing; leave extras alone
|
|
261
|
+
|
|
262
|
+
result = schema.validate_schema([Place])
|
|
263
|
+
print("valid:", result.is_valid)
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### Native FalkorDB types
|
|
267
|
+
|
|
268
|
+
`Vector`, `GeoLocation`, `datetime`, and `Enum` fields get their converters assigned automatically — no `converter=` argument needed:
|
|
269
|
+
|
|
270
|
+
```python
|
|
271
|
+
from datetime import UTC, datetime
|
|
272
|
+
from enum import StrEnum
|
|
273
|
+
from runic.orm import Field, GeoLocation, Node, Vector
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class Status(StrEnum):
|
|
277
|
+
DRAFT = "draft"
|
|
278
|
+
PUBLISHED = "published"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class Article(Node, labels=["Article"]):
|
|
282
|
+
id: str = Field(primary_key=True)
|
|
283
|
+
category: str = Field(interned=True) # intern() deduplication
|
|
284
|
+
status: Status # EnumConverter auto-assigned
|
|
285
|
+
published_at: datetime | None = None # DatetimeConverter auto-assigned
|
|
286
|
+
embedding: Vector | None = None # VectorConverter → vecf32()
|
|
287
|
+
origin: GeoLocation | None = None # GeoLocationConverter → point()
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
## Documentation
|
|
293
|
+
|
|
294
|
+
Full conceptual overview, async usage, advanced CLI flags, and API reference at the complete [Runic Documentation](https://runic-py.readthedocs.io/latest/).
|
runic_py-0.2.2/README.md
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/jenreh/runic/refs/heads/main/docs/source/_static/runic.svg" width="240" alt="Runic logo">
|
|
3
|
+
|
|
4
|
+
# Runic
|
|
5
|
+
|
|
6
|
+
**Graph schema migrations and ORM for FalkorDB.**
|
|
7
|
+
|
|
8
|
+

|
|
9
|
+
[](https://www.python.org)
|
|
10
|
+
[](LICENSE.md)
|
|
11
|
+
|
|
12
|
+
[Features](#features) • [Installation](#installation) • [Migrations](#migrations) • [ORM](#runicorm) • [Documentation](#documentation)
|
|
13
|
+
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
**Runic** is a Python toolkit for [FalkorDB](https://falkordb.com/) that covers two layers:
|
|
19
|
+
|
|
20
|
+
- **`runic.migrate`** — Alembic-style schema migrations with revision tracking, a CLI, and rollback snapshots.
|
|
21
|
+
- **`runic.orm`** — A lightweight graph ORM: declare `Node` and `Edge` models, manage sessions, traverse relationships, and sync indexes — all using FalkorDB-native Cypher under the hood.
|
|
22
|
+
|
|
23
|
+
## Features
|
|
24
|
+
|
|
25
|
+
### Migration CLI
|
|
26
|
+
|
|
27
|
+
- **Alembic-Style Workflow** — Familiar CLI verbs: `init`, `revision`, `upgrade`, `downgrade`, `current`, `baseline`.
|
|
28
|
+
- **Graph-Native** — Migration state stored inside dedicated graph nodes (`:_FalkorMigrateVersion`).
|
|
29
|
+
- **Idempotent Cypher** — Explicit, guarded migration steps; safe to replay on an empty graph.
|
|
30
|
+
- **Offline & Dry Run** — Review generated Cypher scripts before running them in production.
|
|
31
|
+
- **Rollback Snapshots** — Uses `GRAPH.COPY` for high-risk, non-reversible migrations.
|
|
32
|
+
|
|
33
|
+
### Graph ORM
|
|
34
|
+
|
|
35
|
+
- **Declarative Models** — `Node` and `Edge` subclasses with typed `Field` descriptors; no metaclass magic.
|
|
36
|
+
- **Session & Repository** — Unit-of-work session with change tracking; typed `Repository` for queries and pagination.
|
|
37
|
+
- **Relationships** — `Relation` field for INCOMING / OUTGOING edges; lazy and eager loading; edge property models.
|
|
38
|
+
- **Schema Management** — `IndexManager` and `SchemaManager` to create, validate, and sync RANGE, FULLTEXT, and UNIQUE indexes.
|
|
39
|
+
- **Native FalkorDB Types** — First-class `Vector` (vecf32), `GeoLocation` (point), interned strings, and auto-converters for `datetime` and `Enum`.
|
|
40
|
+
- **Async Support** — `AsyncSession`, `AsyncRepository`, and `AsyncConnectionManager` for async-first applications.
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
uv pip install runic
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Or add it to an existing project:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
uv add runic
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
> [!NOTE]
|
|
55
|
+
> Runic requires Python 3.14+ and is optimized for the latest FalkorDB clients.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Migrations
|
|
60
|
+
|
|
61
|
+
Initialize your project and generate a new revision:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
runic init
|
|
65
|
+
runic revision -m "create user index"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Open the generated file in `runic/versions/` and define your upgrade and downgrade:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
revision = "1975ea83b712"
|
|
72
|
+
down_revision = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def upgrade(op) -> None:
|
|
76
|
+
op.create_range_index("User", "email")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def downgrade(op) -> None:
|
|
80
|
+
op.drop_range_index("User", "email")
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Apply or roll back:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
runic upgrade # apply all pending revisions
|
|
87
|
+
runic downgrade # roll back one step
|
|
88
|
+
runic downgrade 1975e # roll back to a specific revision (prefix is enough)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Baselining an existing graph
|
|
92
|
+
|
|
93
|
+
Bring an unmanaged FalkorDB graph under runic control without re-running anything:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
runic baseline -m "baseline" # introspect, generate root revision, stamp it
|
|
97
|
+
runic current # verify it is now tracked
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The generated revision recreates all indexes from scratch — safe to replay on a fresh graph (CI, cloning, new tenants):
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
runic upgrade head # rebuilds full schema on an empty graph
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Programmatic SDK
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from pathlib import Path
|
|
110
|
+
from runic import Runic, init
|
|
111
|
+
from runic.migrate.adapters import create_adapter
|
|
112
|
+
|
|
113
|
+
init(Path("runic/"))
|
|
114
|
+
|
|
115
|
+
adapter = create_adapter(
|
|
116
|
+
"falkordb", url="falkor://localhost:6379", graph_name="my_graph"
|
|
117
|
+
)
|
|
118
|
+
runic = Runic(adapter, script_location=Path("runic/"))
|
|
119
|
+
runic.migrate.upgrade("head")
|
|
120
|
+
|
|
121
|
+
print("current:", runic.migrate.current())
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## runic.orm
|
|
127
|
+
|
|
128
|
+
### Defining models
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from runic.orm import Field, Node, Edge, Relation
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class User(Node, labels=["User"]):
|
|
135
|
+
id: str
|
|
136
|
+
email: str = Field(unique=True)
|
|
137
|
+
name: str
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class Post(Node, labels=["Post"]):
|
|
141
|
+
id: str
|
|
142
|
+
title: str = Field(index_type="FULLTEXT")
|
|
143
|
+
published: bool = False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class AuthoredEdge(Edge, type="AUTHORED"):
|
|
147
|
+
created_at: str # ISO-8601
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class Author(Node, labels=["Author"]):
|
|
151
|
+
id: str
|
|
152
|
+
name: str
|
|
153
|
+
posts: list[Post] = Relation(
|
|
154
|
+
relationship="AUTHORED",
|
|
155
|
+
direction="OUTGOING",
|
|
156
|
+
target="Post",
|
|
157
|
+
edge_model=AuthoredEdge,
|
|
158
|
+
)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Session-based CRUD
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
from runic.orm import Session, Repository
|
|
165
|
+
|
|
166
|
+
with Session(graph) as session:
|
|
167
|
+
session.add_all([
|
|
168
|
+
User(id="alice", email="alice@example.com", name="Alice"),
|
|
169
|
+
User(id="bob", email="bob@example.com", name="Bob"),
|
|
170
|
+
])
|
|
171
|
+
session.commit()
|
|
172
|
+
|
|
173
|
+
with Session(graph) as session:
|
|
174
|
+
repo = Repository(session, User)
|
|
175
|
+
alice = session.get(User, "alice")
|
|
176
|
+
alice.name = "Alice Smith" # change tracking — no explicit dirty flag
|
|
177
|
+
session.commit()
|
|
178
|
+
|
|
179
|
+
with Session(graph) as session:
|
|
180
|
+
user = session.get(User, "bob")
|
|
181
|
+
session.delete(user)
|
|
182
|
+
session.commit()
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Relationships
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
# Lazy load (default) — triggers a query on first access
|
|
189
|
+
with Session(graph) as session:
|
|
190
|
+
author = session.get(Author, "alice")
|
|
191
|
+
posts = author.posts # query executed here
|
|
192
|
+
|
|
193
|
+
# Eager load — single round-trip
|
|
194
|
+
with Session(graph) as session:
|
|
195
|
+
author = session.get(Author, "alice", fetch=["posts"])
|
|
196
|
+
posts = author.posts # already loaded, no extra query
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### Pagination and custom queries
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
from runic.orm import Pageable, Repository
|
|
203
|
+
|
|
204
|
+
with Session(graph) as session:
|
|
205
|
+
repo = Repository(session, User)
|
|
206
|
+
page = repo.find_all_paginated(Pageable(page=0, size=20, sort_by="name"))
|
|
207
|
+
print(f"{len(list(page))} of {page.total_elements} total")
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Extend `Repository` to add typed Cypher helpers:
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
class UserRepository(Repository[User]):
|
|
214
|
+
def find_by_email(self, email: str) -> User | None:
|
|
215
|
+
return self.cypher_one(
|
|
216
|
+
"MATCH (u:User {email: $email}) RETURN u",
|
|
217
|
+
{"email": email},
|
|
218
|
+
returns=User,
|
|
219
|
+
)
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### Schema management
|
|
223
|
+
|
|
224
|
+
Declare indexes inline on `Field`, then let `SchemaManager` keep the live graph in sync:
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
from runic.orm import Field, IndexManager, Node, SchemaManager
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class Place(Node, labels=["Place"]):
|
|
231
|
+
id: str
|
|
232
|
+
name: str = Field(index_type="FULLTEXT")
|
|
233
|
+
slug: str = Field(unique=True)
|
|
234
|
+
lat: float = Field(index=True)
|
|
235
|
+
lon: float = Field(index=True)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
schema = SchemaManager(graph)
|
|
239
|
+
schema.sync_schema([Place], drop_extra=False) # create missing; leave extras alone
|
|
240
|
+
|
|
241
|
+
result = schema.validate_schema([Place])
|
|
242
|
+
print("valid:", result.is_valid)
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Native FalkorDB types
|
|
246
|
+
|
|
247
|
+
`Vector`, `GeoLocation`, `datetime`, and `Enum` fields get their converters assigned automatically — no `converter=` argument needed:
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
from datetime import UTC, datetime
|
|
251
|
+
from enum import StrEnum
|
|
252
|
+
from runic.orm import Field, GeoLocation, Node, Vector
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class Status(StrEnum):
|
|
256
|
+
DRAFT = "draft"
|
|
257
|
+
PUBLISHED = "published"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class Article(Node, labels=["Article"]):
|
|
261
|
+
id: str = Field(primary_key=True)
|
|
262
|
+
category: str = Field(interned=True) # intern() deduplication
|
|
263
|
+
status: Status # EnumConverter auto-assigned
|
|
264
|
+
published_at: datetime | None = None # DatetimeConverter auto-assigned
|
|
265
|
+
embedding: Vector | None = None # VectorConverter → vecf32()
|
|
266
|
+
origin: GeoLocation | None = None # GeoLocationConverter → point()
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
---
|
|
270
|
+
|
|
271
|
+
## Documentation
|
|
272
|
+
|
|
273
|
+
Full conceptual overview, async usage, advanced CLI flags, and API reference at the complete [Runic Documentation](https://runic-py.readthedocs.io/latest/).
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "runic-py"
|
|
3
|
-
version = "0.2.
|
|
4
|
-
description = "
|
|
3
|
+
version = "0.2.2"
|
|
4
|
+
description = "OGM and schema migrations for FalkorDB."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.14"
|
|
7
7
|
license = { text = "MIT" }
|
|
@@ -76,7 +76,7 @@ exclude = [
|
|
|
76
76
|
|
|
77
77
|
[tool.bumpversion]
|
|
78
78
|
commit = false
|
|
79
|
-
current_version = "0.2.
|
|
79
|
+
current_version = "0.2.2"
|
|
80
80
|
tag = false
|
|
81
81
|
allow-dirty = false
|
|
82
82
|
|
|
@@ -41,11 +41,13 @@ def _resolve_config(config: Path) -> Path:
|
|
|
41
41
|
return config
|
|
42
42
|
|
|
43
43
|
|
|
44
|
-
_DB_CONNECTION_ERROR_NAMES = frozenset(
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
_DB_CONNECTION_ERROR_NAMES = frozenset(
|
|
45
|
+
{
|
|
46
|
+
"AuthenticationError",
|
|
47
|
+
"ConnectionError",
|
|
48
|
+
"TimeoutError",
|
|
49
|
+
}
|
|
50
|
+
)
|
|
49
51
|
|
|
50
52
|
|
|
51
53
|
def _exec_env(config: Path, preview: bool = False) -> dict: # noqa: ARG001
|
|
@@ -358,17 +360,19 @@ def history(
|
|
|
358
360
|
from runic.migrate.script import RevisionInfo
|
|
359
361
|
|
|
360
362
|
items = list(
|
|
361
|
-
reversed(
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
363
|
+
reversed(
|
|
364
|
+
[
|
|
365
|
+
RevisionInfo(
|
|
366
|
+
revision=r.revision,
|
|
367
|
+
down_revision=r.down_revision,
|
|
368
|
+
message=r.message,
|
|
369
|
+
create_date=r.create_date,
|
|
370
|
+
is_head=False,
|
|
371
|
+
is_branch_point=r.revision in bp_set,
|
|
372
|
+
)
|
|
373
|
+
for r in sd.walk_revisions(start, end, direction="up")
|
|
374
|
+
]
|
|
375
|
+
)
|
|
372
376
|
)
|
|
373
377
|
else:
|
|
374
378
|
items = list(reversed(sd.revision_history()))
|