post-graph 0.1.0__tar.gz → 0.1.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.
@@ -0,0 +1,65 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v[0-9]+.[0-9]+.[0-9]+*"
7
+ - "v*"
8
+
9
+ jobs:
10
+ publish:
11
+ name: Build & Publish post-graph to PyPI
12
+ runs-on: ubuntu-latest
13
+ permissions:
14
+ id-token: write # Required for PyPI Trusted Publisher OIDC
15
+ contents: write
16
+
17
+ steps:
18
+ - name: Checkout repository
19
+ uses: actions/checkout@v4
20
+
21
+ - name: Set up Python
22
+ uses: actions/setup-python@v5
23
+ with:
24
+ python-version: "3.11"
25
+
26
+ - name: Update module and package version from git tag
27
+ run: |
28
+ # Extract version (e.g. v1.2.3 -> 1.2.3)
29
+ TAG_NAME="${GITHUB_REF#refs/tags/}"
30
+ export VERSION="${TAG_NAME#v}"
31
+ echo "Release version: $VERSION"
32
+
33
+ # Update version in pyproject.toml and post_graph/__init__.py
34
+ python -c "
35
+ import os, re
36
+ ver = os.environ['VERSION']
37
+
38
+ with open('pyproject.toml', 'r') as f:
39
+ content = f.read()
40
+ updated = re.sub(r'version\s*=\s*\"[^\"]+\"', 'version = \"' + ver + '\"', content, count=1)
41
+ with open('pyproject.toml', 'w') as f:
42
+ f.write(updated)
43
+
44
+ with open('post_graph/__init__.py', 'r') as f:
45
+ content = f.read()
46
+ updated = re.sub(r'__version__\s*=\s*\"[^\"]+\"', '__version__ = \"' + ver + '\"', content, count=1)
47
+ with open('post_graph/__init__.py', 'w') as f:
48
+ f.write(updated)
49
+ "
50
+
51
+ echo "Updated pyproject.toml and post_graph/__init__.py to version ${VERSION}"
52
+
53
+ - name: Install uv
54
+ uses: astral-sh/setup-uv@v3
55
+ with:
56
+ enable-cache: false
57
+
58
+ - name: Build package artifacts
59
+ run: uv build
60
+
61
+ - name: Publish to PyPI
62
+ uses: pypa/gh-action-pypi-publish@release/v1
63
+ with:
64
+ password: ${{ secrets.PYPI_API_TOKEN }}
65
+ skip-existing: true
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chandan Rajah
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.
@@ -0,0 +1,469 @@
1
+ Metadata-Version: 2.4
2
+ Name: post-graph
3
+ Version: 0.1.2
4
+ Summary: High-performance PostgreSQL-backed graph database library supporting multi-tenant realms, schema-per-realm, shadow auditing, and append-only data history.
5
+ Project-URL: Homepage, https://github.com/crajah/post-graph
6
+ Project-URL: Repository, https://github.com/crajah/post-graph
7
+ Author-email: Chandan Rajah <chandan.rajah@gmail.com>
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: asyncpg,database,graph,postgres,postgresql,sqlalchemy
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Database
20
+ Requires-Python: >=3.9
21
+ Requires-Dist: asyncpg>=0.29.0
22
+ Provides-Extra: all
23
+ Requires-Dist: sqlalchemy>=2.0.0; extra == 'all'
24
+ Provides-Extra: sqlalchemy
25
+ Requires-Dist: sqlalchemy>=2.0.0; extra == 'sqlalchemy'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # post-graph: PostgreSQL-Backed Graph Database Library
29
+
30
+ [![PyPI version](https://img.shields.io/pypi/v/post-graph.svg)](https://pypi.org/project/post-graph/)
31
+ [![Python Versions](https://img.shields.io/pypi/pyversions/post-graph.svg)](https://pypi.org/project/post-graph/)
32
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
33
+
34
+ A generic, high-performance library for using PostgreSQL as a graph database in Python. It supports **multi-tenancy (realms)**, automatic **shadow audit logging**, **append-only history tables**, and high-performance **graph traversals (recursive CTEs)**.
35
+
36
+ ---
37
+
38
+ ## Features
39
+
40
+ - **Table-Per-Vertex & Table-Per-Edge Architecture**: Maps graph elements directly to relational tables, taking advantage of PostgreSQL's foreign keys, indexes, and constraints.
41
+ - **Dual Isolation Modes**:
42
+ - **Single-Schema Multi-Tenancy**: Logical isolation using a `realm` column partition.
43
+ - **Schema-Per-Realm**: Physical isolation creating dedicated PostgreSQL schema namespaces per tenant (`CREATE SCHEMA IF NOT EXISTS "realm_name"`).
44
+ - **Autogenerated `BIGSERIAL` Primary Keys & Computed `fqid`**:
45
+ - Vertex FQID: `{realm}/{table_name}/{id}`
46
+ - Edge FQID: `{realm}/{from_table}-{to_table}/{id}` (using hyphen separator)
47
+ - Automatically populated at the PostgreSQL level via `GENERATED ALWAYS AS ... STORED NOT NULL`.
48
+ - **Append-Only History Tables (`{table_name}_data`)**:
49
+ - Automatically created alongside vertex and edge tables.
50
+ - Stores timestamped JSONB payload updates (`data_id`, `realm`, `id`, `payload`, `timestamp`).
51
+ - Cascades deletion when main vertex/edge is deleted (`ON DELETE CASCADE`).
52
+ - **Shadow Audit Tables (`{table_name}_audit`)**:
53
+ - Operates via PostgreSQL triggers to capture all `INSERT`, `UPDATE`, and `DELETE` events.
54
+ - Logs old/new row state and the initiating `user_id` passed via session parameters (`app.current_user_id`).
55
+ - **Advanced Graph Traversals**:
56
+ - Recursive CTE queries for neighbor exploration, path discovery, and cycle-free shortest path calculation.
57
+ - Direct object-oriented traversal APIs (`vertex.to()`, `vertex.from_()`, `step.vertex()`, `step.add_edge_to()`).
58
+ - **Multiple Async Client Drivers**:
59
+ - Raw high-speed `asyncpg` client (`AsyncPostGraph`).
60
+ - `SQLAlchemy` v2.0 async client (`SQLAlchemyPostGraph`).
61
+
62
+ ---
63
+
64
+ ## Installation
65
+
66
+ Install `post-graph` from PyPI:
67
+
68
+ ```bash
69
+ # Basic installation (includes asyncpg)
70
+ pip install post-graph
71
+
72
+ # Installation with SQLAlchemy support
73
+ pip install "post-graph[sqlalchemy]"
74
+
75
+ # Installation with all optional dependencies
76
+ pip install "post-graph[all]"
77
+ ```
78
+
79
+ Using `uv`:
80
+ ```bash
81
+ uv add post-graph
82
+ # or with SQLAlchemy support:
83
+ uv add "post-graph[sqlalchemy]"
84
+ ```
85
+
86
+ ---
87
+
88
+ ## Architecture Schema
89
+
90
+ ```mermaid
91
+ erDiagram
92
+ users {
93
+ text realm PK
94
+ bigserial id PK
95
+ text fqid
96
+ jsonb payload
97
+ timestamptz created_at
98
+ timestamptz updated_at
99
+ }
100
+ posts {
101
+ text realm PK
102
+ bigserial id PK
103
+ text fqid
104
+ jsonb payload
105
+ timestamptz created_at
106
+ timestamptz updated_at
107
+ }
108
+ likes {
109
+ text realm PK, FK
110
+ bigserial id PK
111
+ text fqid
112
+ bigint from_id FK
113
+ bigint to_id FK
114
+ text relation_type
115
+ jsonb payload
116
+ timestamptz created_at
117
+ timestamptz updated_at
118
+ }
119
+ users_audit {
120
+ bigint audit_id PK
121
+ text realm
122
+ text action
123
+ text changed_by
124
+ timestamptz changed_at
125
+ jsonb old_row
126
+ jsonb new_row
127
+ }
128
+ users_data {
129
+ bigserial data_id PK
130
+ text realm FK
131
+ bigint id FK
132
+ jsonb payload
133
+ timestamptz timestamp
134
+ }
135
+
136
+ users ||--o{ likes : "from_id"
137
+ posts ||--o{ likes : "to_id"
138
+ users ||--o{ users_audit : "logs changes"
139
+ users ||--o{ users_data : "appends records"
140
+ ```
141
+
142
+ ---
143
+
144
+ ## Quick Start Examples
145
+
146
+ ### 1. Using `AsyncPostGraph` (`asyncpg`)
147
+
148
+ ```python
149
+ import asyncio
150
+ from post_graph import AsyncPostGraph
151
+
152
+ async def main():
153
+ client = AsyncPostGraph(dsn="postgresql://postgres:postgres@localhost:5432/postgres")
154
+ await client.connect()
155
+
156
+ # 1. Create schema tables
157
+ await client.create_vertex_table("users")
158
+ await client.create_vertex_table("posts")
159
+ await client.create_edge_table("likes", from_vertex_table="users", to_vertex_table="posts")
160
+
161
+ # 2. Add vertices (id is autogenerated if omitted)
162
+ alice = await client.add_vertex("users", realm="realm_a", payload={"name": "Alice"})
163
+ post = await client.add_vertex("posts", realm="realm_a", payload={"title": "Hello PostGraph!"})
164
+
165
+ # 3. Add edge connecting Alice -> Post
166
+ edge = await client.add_edge(
167
+ table_name="likes",
168
+ realm="realm_a",
169
+ from_id=alice.id,
170
+ to_id=post.id,
171
+ relation_type="like",
172
+ payload={"reaction": "heart"}
173
+ )
174
+
175
+ # 4. Append data history record
176
+ await alice.add_data({"status": "active", "login_count": 1})
177
+ history = await alice.get_data()
178
+ print(f"Alice history count: {len(history)}, last: {history[0].payload}")
179
+
180
+ # 5. Object-oriented traversal
181
+ steps = await alice.to("likes")
182
+ for step in steps:
183
+ target_post = step.vertex()
184
+ print(f"Alice liked post: {target_post.payload['title']}")
185
+
186
+ await client.close()
187
+
188
+ asyncio.run(main())
189
+ ```
190
+
191
+ ### 2. Using `SQLAlchemyPostGraph`
192
+
193
+ ```python
194
+ import asyncio
195
+ from sqlalchemy.ext.asyncio import create_async_engine
196
+ from post_graph import SQLAlchemyPostGraph
197
+
198
+ async def main():
199
+ engine = create_async_engine("postgresql+asyncpg://postgres:postgres@localhost:5432/postgres")
200
+ client = SQLAlchemyPostGraph(engine)
201
+
202
+ # DDL & CRUD methods share identical signatures
203
+ await client.create_vertex_table("users")
204
+ await client.create_edge_table("follows", from_vertex_table="users", to_vertex_table="users")
205
+
206
+ v1 = await client.add_vertex("users", realm="default", payload={"name": "Bob"})
207
+ v2 = await client.add_vertex("users", realm="default", payload={"name": "Charlie"})
208
+ e = await client.add_edge("follows", realm="default", from_id=v1.id, to_id=v2.id, relation_type="follow")
209
+
210
+ await engine.dispose()
211
+
212
+ asyncio.run(main())
213
+ ```
214
+
215
+ ---
216
+
217
+ ## Detailed API Reference
218
+
219
+ ### Client Initialization & Connection
220
+
221
+ ```python
222
+ AsyncPostGraph(
223
+ dsn: Optional[str] = None,
224
+ connection_or_pool: Optional[Union[asyncpg.Pool, asyncpg.Connection]] = None,
225
+ schema_per_realm: bool = False
226
+ )
227
+ ```
228
+
229
+ ```python
230
+ SQLAlchemyPostGraph(
231
+ engine_or_connection: Union[AsyncEngine, AsyncConnection],
232
+ schema_per_realm: bool = False
233
+ )
234
+ ```
235
+
236
+ - **`schema_per_realm`**: If `True`, creates physically isolated schema namespaces per `realm` (e.g. `"realm_a"."users"`). If `False` (default), uses single-schema logical multi-tenancy with a `realm` column partition.
237
+
238
+ ---
239
+
240
+ ### Schema DDL Operations
241
+
242
+ #### `create_vertex_table`
243
+ ```python
244
+ await client.create_vertex_table(table_name="users", realm=None)
245
+ ```
246
+ Creates:
247
+ 1. `{table_name}` main vertex table with `(realm, id)` primary key.
248
+ 2. `{table_name}_audit` shadow audit log table.
249
+ 3. `{table_name}_data` append-only history table with foreign key `(realm, id) ON DELETE CASCADE`.
250
+ 4. Triggers for `updated_at` timestamps and audit logging.
251
+
252
+ #### `create_edge_table`
253
+ ```python
254
+ await client.create_edge_table(
255
+ table_name="likes", # Optional; defaults to "{from_table}TO{to_table}" if empty
256
+ from_vertex_table="users",
257
+ to_vertex_table="posts",
258
+ realm=None,
259
+ cascade_delete_from=False, # If True, deleting the edge deletes source vertex
260
+ cascade_delete_to=False # If True, deleting the edge deletes target vertex
261
+ )
262
+ ```
263
+ Creates main edge table, shadow audit table `{table_name}_audit`, and append-only history table `{table_name}_data`. Foreign keys reference `(realm, from_id)` and `(realm, to_id)` on vertex tables with `ON DELETE CASCADE`.
264
+
265
+ ---
266
+
267
+ ### Vertex Operations
268
+
269
+ #### `add_vertex`
270
+ ```python
271
+ vertex = await client.add_vertex(
272
+ table_name="users",
273
+ realm="tenant_1",
274
+ vertex_id=None, # Optional. If omitted, BIGSERIAL autogenerates integer ID.
275
+ payload={"name": "Alice"}, # Dict payload stored as JSONB.
276
+ user_id="admin_1" # Optional user attribution for audit log.
277
+ )
278
+ ```
279
+
280
+ #### `get_vertex`
281
+ ```python
282
+ vertex = await client.get_vertex(table_name="users", realm="tenant_1", vertex_id=1)
283
+ ```
284
+
285
+ #### `update_vertex`
286
+ ```python
287
+ updated = await client.update_vertex(
288
+ table_name="users",
289
+ realm="tenant_1",
290
+ vertex_id=1,
291
+ payload={"name": "Alice Smith"}, # Replaces JSONB payload
292
+ user_id="admin_1"
293
+ )
294
+ ```
295
+
296
+ #### `delete_vertex`
297
+ ```python
298
+ deleted = await client.delete_vertex(table_name="users", realm="tenant_1", vertex_id=1, user_id="admin_1")
299
+ ```
300
+ Automatically cascade deletes all connected edges in the database.
301
+
302
+ ---
303
+
304
+ ### Edge Operations
305
+
306
+ #### `add_edge`
307
+ ```python
308
+ edge = await client.add_edge(
309
+ table_name="follows",
310
+ realm="tenant_1",
311
+ edge_id=None, # Optional autogenerated BIGSERIAL ID.
312
+ from_id=1,
313
+ to_id=2,
314
+ relation_type="friend",
315
+ payload={"since": "2026-01-01"},
316
+ user_id="admin_1",
317
+ check_cycle=False # Set True to raise CyclicReferenceError if a cycle is created
318
+ )
319
+ ```
320
+
321
+ #### `get_edge`
322
+ ```python
323
+ edge = await client.get_edge(table_name="follows", realm="tenant_1", edge_id=100)
324
+ ```
325
+
326
+ #### `delete_edge`
327
+ ```python
328
+ deleted = await client.delete_edge(table_name="follows", realm="tenant_1", edge_id=100, user_id="admin_1")
329
+ ```
330
+
331
+ ---
332
+
333
+ ### Multi-Tenant Realm Operations
334
+
335
+ #### `delete_realm`
336
+ ```python
337
+ rows_deleted = await client.delete_realm(realm="tenant_to_remove")
338
+ ```
339
+ Completely removes all vertices, edges, audit logs, and data history records belonging to the target realm across all tables.
340
+
341
+ ---
342
+
343
+ ### Append-Only Data History (`{table_name}_data`)
344
+
345
+ Vertices and edges support appending timestamped historical data snapshots.
346
+
347
+ #### Client Data APIs
348
+ ```python
349
+ # Add historical record
350
+ record = await client.add_vertex_data(
351
+ table_name="users",
352
+ realm="realm_a",
353
+ vertex_id=1,
354
+ payload={"status": "active", "login_count": 10},
355
+ timestamp=None # Optional; defaults to CURRENT_TIMESTAMP
356
+ )
357
+
358
+ # Fetch history records (sorted timestamp DESC)
359
+ history = await client.get_vertex_data(table_name="users", realm="realm_a", vertex_id=1, limit=5)
360
+ for rec in history:
361
+ print(rec.data_id, rec.timestamp, rec.payload)
362
+ ```
363
+ *(Direct `add_edge_data` and `get_edge_data` methods operate identically for edge tables).*
364
+
365
+ #### Model Convenience Methods
366
+ ```python
367
+ # Vertex/Edge instances expose add_data() and get_data() directly
368
+ rec = await vertex.add_data({"status": "active"})
369
+ history = await vertex.get_data(limit=10)
370
+
371
+ rec_edge = await edge.add_data({"event": "click"})
372
+ history_edge = await edge.get_data()
373
+ ```
374
+
375
+ ---
376
+
377
+ ### Object-Oriented Graph Traversal
378
+
379
+ Loaded `Vertex` objects allow seamless graph navigation:
380
+
381
+ ```python
382
+ alice = await client.get_vertex("users", "realm_a", 1)
383
+
384
+ # 1. Forward Traversal (.to)
385
+ steps = await alice.to(edge_table="follows")
386
+ for step in steps:
387
+ edge = step.edge
388
+ neighbor = step.vertex()
389
+ print(f"Alice --[{edge.relation_type}]--> {neighbor.payload['name']}")
390
+
391
+ # 2. Reverse Traversal (.from_)
392
+ bob = await client.get_vertex("users", "realm_a", 2)
393
+ inbound_steps = await bob.from_(edge_table="follows")
394
+ for step in inbound_steps:
395
+ origin_user = step.vertex()
396
+ print(f"Followed by: {origin_user.payload['name']}")
397
+
398
+ # 3. Add Edge via Traversal Step
399
+ new_edge = await inbound_steps[0].add_edge_to(to_id=3, edge_table="follows", relation_type="knows")
400
+
401
+ # 4. Delete Vertex directly
402
+ await bob.delete(user_id="admin_1")
403
+ ```
404
+
405
+ ---
406
+
407
+ ### High-Performance Recursive CTE Queries
408
+
409
+ #### `traverse`
410
+ Explores all reachable paths starting from a vertex up to `max_depth`:
411
+
412
+ ```python
413
+ paths = await client.traverse(
414
+ realm="realm_a",
415
+ start_table="users",
416
+ start_id=1,
417
+ edge_tables=["follows", "likes"],
418
+ max_depth=3
419
+ )
420
+ for p in paths:
421
+ print(f"Depth: {p['depth']} | Path: {' -> '.join(p['path'])}")
422
+ ```
423
+
424
+ #### `shortest_path`
425
+ Calculates the shortest path between two nodes, with built-in loop avoidance:
426
+
427
+ ```python
428
+ sp = await client.shortest_path(
429
+ realm="realm_a",
430
+ start_table="users",
431
+ start_id=1,
432
+ target_table="posts",
433
+ target_id=42,
434
+ edge_tables=["follows", "likes"],
435
+ max_depth=5
436
+ )
437
+ if sp:
438
+ print(f"Shortest path found at depth {sp['depth']}: {sp['path']}")
439
+ ```
440
+
441
+ ---
442
+
443
+ ### Transaction Support
444
+
445
+ #### `AsyncPostGraph` (raw asyncpg)
446
+ ```python
447
+ async with pool.acquire() as conn:
448
+ async with conn.transaction():
449
+ # Bind transaction connection to client wrapper
450
+ tx_client = AsyncPostGraph(connection_or_pool=conn)
451
+ await tx_client.add_vertex("users", "realm_a", payload={"name": "Tx Node"})
452
+ await tx_client.add_edge("follows", "realm_a", from_id=1, to_id=2, relation_type="knows")
453
+ # Commits automatically on block exit; rolls back on exception.
454
+ ```
455
+
456
+ #### `SQLAlchemyPostGraph`
457
+ ```python
458
+ async with engine.connect() as conn:
459
+ async with conn.begin():
460
+ tx_client = SQLAlchemyPostGraph(conn)
461
+ await tx_client.add_vertex("sa_users", "default", payload={"name": "Tx Node"})
462
+ await tx_client.add_edge("sa_follows", "default", from_id=1, to_id=2, relation_type="knows")
463
+ ```
464
+
465
+ ---
466
+
467
+ ## License
468
+
469
+ This project is licensed under the [MIT License](LICENSE).