High-performance PostgreSQL-backed graph database library supporting multi-tenant realms, schema-per-realm, shadow auditing, and append-only data history.
Project description
post-graph: PostgreSQL-Backed Graph Database Library
A high-performance Python library for using PostgreSQL as a native graph database. It supports multi-tenant realms, pgvector similarity search across main & data history tables, automatic shadow audit logging, append-only history tables, and high-speed recursive graph traversals (CTEs).
🌟 Key Features
- Table-Per-Vertex & Table-Per-Edge Architecture: Maps graph elements directly to relational tables, taking advantage of PostgreSQL's foreign keys, indexes, and constraints.
- pgvector Similarity Search (
vector_search):- Native HNSW vector indexing (
vector(dim)) for vertex embeddings. - Cosine distance (
<=>), Euclidean L2 (<->), and Inner Product (<#>). - Multi-scope searching across main tables, associated data history tables, or both combined (
search_scope="both").
- Native HNSW vector indexing (
- Dual Isolation Multi-Tenancy:
- Single-Schema Multi-Tenancy: Logical isolation using a
realmcolumn partition. - Schema-Per-Realm: Physical isolation creating dedicated PostgreSQL schema namespaces per tenant (
CREATE SCHEMA IF NOT EXISTS "realm_name").
- Single-Schema Multi-Tenancy: Logical isolation using a
- Autogenerated
BIGSERIALPrimary Keys & Computedfqid:- Vertex FQID:
{realm}/{table_name}/{id} - Edge FQID:
{realm}/{from_table}-{to_table}/{id}(using hyphen separator) - Automatically populated at PostgreSQL level via
GENERATED ALWAYS AS ... STORED NOT NULL.
- Vertex FQID:
- Append-Only History Tables (
{table_name}_data):- Automatically created alongside vertex and edge tables.
- Stores timestamped JSONB payload updates (
data_id,realm,id,payload,timestamp,embedding). - Supports vector embeddings on data records for historical semantic matching.
- Cascades deletion when main vertex/edge is deleted (
ON DELETE CASCADE).
- Shadow Audit Logging (
{table_name}_audit):- Operates via PostgreSQL triggers to capture all
INSERT,UPDATE, andDELETEevents. - Logs old/new row state and the initiating
user_idpassed via session parameters (app.current_user_id).
- Operates via PostgreSQL triggers to capture all
- Advanced Graph Traversals & Cycle Detection:
- Recursive CTE queries for neighbor exploration, path discovery, and cycle-free shortest path calculation.
- Optional
check_cycle=TrueraisingCyclicReferenceErrorduring edge creation. - Direct object-oriented traversal APIs (
vertex.to(),vertex.from_(),step.vertex(),step.add_edge_to()).
- Multiple Async Client Drivers:
- High-speed
asyncpgclient (AsyncPostGraph). SQLAlchemyv2.0 async client (SQLAlchemyPostGraph).
- High-speed
📦 Installation
Install post-graph from PyPI:
# Basic installation (includes asyncpg)
pip install post-graph
# Installation with SQLAlchemy support
pip install "post-graph[sqlalchemy]"
# Installation with all optional dependencies
pip install "post-graph[all]"
Using uv:
uv add post-graph
# or with SQLAlchemy support:
uv add "post-graph[sqlalchemy]"
PostgreSQL Setup
To enable pgvector similarity search, ensure the pgvector extension is enabled in PostgreSQL:
CREATE EXTENSION IF NOT EXISTS vector;
🏗️ Database Architecture
erDiagram
users {
text realm PK
bigserial id PK
text fqid
jsonb payload
vector embedding
timestamptz created_at
timestamptz updated_at
}
posts {
text realm PK
bigserial id PK
text fqid
jsonb payload
vector embedding
timestamptz created_at
timestamptz updated_at
}
likes {
text realm PK, FK
bigserial id PK
text fqid
bigint from_id FK
bigint to_id FK
text relation_type
jsonb payload
timestamptz created_at
timestamptz updated_at
}
users_audit {
bigint audit_id PK
text realm
text action
text changed_by
timestamptz changed_at
jsonb old_row
jsonb new_row
}
users_data {
bigserial data_id PK
text realm FK
bigint id FK
jsonb payload
vector embedding
timestamptz timestamp
}
users ||--o{ likes : "from_id"
posts ||--o{ likes : "to_id"
users ||--o{ users_audit : "logs changes"
users ||--o{ users_data : "appends records & embeddings"
🚀 Quick Start Examples
1. Basic Graph Storage & Traversals (asyncpg)
import asyncio
from post_graph import AsyncPostGraph
async def main():
client = AsyncPostGraph(dsn="postgresql://postgres:postgres@localhost:5432/postgres")
await client.connect()
# 1. Create schema tables
await client.create_vertex_table("users")
await client.create_vertex_table("posts")
await client.create_edge_table("likes", from_vertex_table="users", to_vertex_table="posts")
# 2. Add vertices
alice = await client.add_vertex("users", realm="realm_a", payload={"name": "Alice"})
post = await client.add_vertex("posts", realm="realm_a", payload={"title": "Hello PostGraph!"})
# 3. Add edge connecting Alice -> Post
edge = await client.add_edge(
table_name="likes",
realm="realm_a",
from_id=alice.id,
to_id=post.id,
relation_type="like",
payload={"reaction": "heart"}
)
# 4. Append data history record
await alice.add_data({"status": "active", "login_count": 1})
history = await alice.get_data()
print(f"Alice history count: {len(history)}, last: {history[0].payload}")
# 5. Object-oriented traversal
steps = await alice.to("likes")
for step in steps:
target_post = step.vertex()
print(f"Alice liked post: {target_post.payload['title']}")
await client.close()
asyncio.run(main())
2. pgvector Similarity Search (main, data, or both scopes)
import asyncio
from post_graph import AsyncPostGraph
async def main():
client = AsyncPostGraph(dsn="postgresql://postgres:postgres@localhost:5432/postgres")
await client.connect()
# 1. Create vertex table with 4D embeddings
await client.create_vertex_table("documents", realm="tech_realm", vector_dim=4)
# 2. Add vertex with embedding to MAIN table
doc1 = await client.add_vertex(
"documents",
realm="tech_realm",
payload={"title": "PostgreSQL Overview"},
embedding=[1.0, 0.0, 0.0, 0.0]
)
# 3. Add historical entry with embedding to DATA table
await doc1.add_data(
payload={"version": "v2.0", "notes": "Added pgvector support"},
embedding=[0.0, 1.0, 0.0, 0.0]
)
# 4. Vector search on MAIN table
results_main = await client.vector_search(
"documents",
realm="tech_realm",
query_vector=[0.9, 0.1, 0.0, 0.0],
top_k=3,
search_scope="main"
)
print("Main Search Hit:", results_main[0][0].payload['title'], "Dist:", results_main[0][1])
# 5. Vector search on BOTH main & data history tables combined
results_both = await client.vector_search(
"documents",
realm="tech_realm",
query_vector=[0.05, 0.95, 0.0, 0.0],
top_k=3,
search_scope="both"
)
print("Combined Scope Best Hit:", results_both[0][0].payload['title'], "Dist:", results_both[0][1])
await client.close()
asyncio.run(main())
3. Using SQLAlchemyPostGraph
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine
from post_graph import SQLAlchemyPostGraph
async def main():
engine = create_async_engine("postgresql+asyncpg://postgres:postgres@localhost:5432/postgres")
client = SQLAlchemyPostGraph(engine)
# DDL & CRUD methods share identical signatures
await client.create_vertex_table("users")
await client.create_edge_table("follows", from_vertex_table="users", to_vertex_table="users")
v1 = await client.add_vertex("users", realm="default", payload={"name": "Bob"})
v2 = await client.add_vertex("users", realm="default", payload={"name": "Charlie"})
e = await client.add_edge("follows", realm="default", from_id=v1.id, to_id=v2.id, relation_type="follow")
await engine.dispose()
asyncio.run(main())
📖 Detailed API Reference
Client Initialization
AsyncPostGraph(
dsn: Optional[str] = None,
connection_or_pool: Optional[Union[asyncpg.Pool, asyncpg.Connection]] = None,
schema_per_realm: bool = False
)
SQLAlchemyPostGraph(
engine_or_connection: Union[AsyncEngine, AsyncConnection],
schema_per_realm: bool = False
)
schema_per_realm: IfTrue, creates physically isolated schema namespaces perrealm(e.g."realm_a"."users"). IfFalse(default), uses single-schema logical multi-tenancy with arealmcolumn partition.
Schema DDL Operations
create_vertex_table
await client.create_vertex_table(
table_name="users",
realm=None,
vector_dim=None # Optional integer (e.g. 1536, 4096) for pgvector HNSW index
)
Creates:
{table_name}main vertex table with(realm, id)primary key.{table_name}_auditshadow audit log table.{table_name}_dataappend-only history table with foreign key(realm, id) ON DELETE CASCADE.- If
vector_dimis provided, createsembedding vector(dim)column and HNSW index on both{table_name}and{table_name}_data.
create_edge_table
await client.create_edge_table(
table_name="likes", # Optional; defaults to "{from_table}TO{to_table}" if empty
from_vertex_table="users",
to_vertex_table="posts",
realm=None,
cascade_delete_from=False, # If True, deleting edge deletes source vertex
cascade_delete_to=False # If True, deleting edge deletes target vertex
)
Vector Similarity Search (vector_search)
results = await client.vector_search(
table_name="documents",
realm="tech_realm",
query_vector=[0.1, 0.2, 0.3, 0.4],
top_k=5,
distance_metric="cosine", # "cosine" (<=>), "l2" (<->), or "inner_product" (<#>)
search_data_table=False, # Deprecated alias; set search_scope="data" instead
search_scope="main" # "main", "data", or "both"
)
search_scope="main": Searches vector column on main vertex table{table_name}.search_scope="data": Searches vector column on associated data table{table_name}_datajoined with main table{table_name}.search_scope="both": Performs a unified CTE vector distance query across main and associated data tables, returning distinct matchingVertexobjects sorted by minimum distance.
Vertex Operations
add_vertex
vertex = await client.add_vertex(
table_name="users",
realm="tenant_1",
vertex_id=None, # Optional autogenerated BIGSERIAL ID
payload={"name": "Alice"}, # Dict payload stored as JSONB
embedding=[0.1, 0.2, 0.3, 0.4], # Optional vector embedding
user_id="admin_1" # Optional user attribution for audit log
)
get_vertex
vertex = await client.get_vertex(table_name="users", realm="tenant_1", vertex_id=1)
# Seamlessly accepts integer ID, FQID ("tenant_1/users/1"), or UUID string ("550e8400-e29b-41d4-a716-446655440000")
get_vertex_by_uuid
vertex = await client.get_vertex_by_uuid(
table_name="users",
realm="tenant_1",
uuid="550e8400-e29b-41d4-a716-446655440000"
)
Performs fast $O(1)$ indexed lookup on the automatically assigned uuid column.
get_edge_by_uuid
edge = await client.get_edge_by_uuid(
table_name="likes",
realm="tenant_1",
uuid="463bf3f0-33f7-47cf-9fa9-46ab83035033"
)
Performs fast indexed lookup for edge records by UUID.
update_vertex
updated = await client.update_vertex(
table_name="users",
realm="tenant_1",
vertex_id=1,
payload={"name": "Alice Smith"},
user_id="admin_1"
)
delete_vertex
deleted = await client.delete_vertex(table_name="users", realm="tenant_1", vertex_id=1, user_id="admin_1")
Automatically cascade deletes all connected edges in the database.
Edge Operations
add_edge
edge = await client.add_edge(
table_name="follows",
realm="tenant_1",
edge_id=None, # Optional autogenerated BIGSERIAL ID
from_id=1,
to_id=2,
relation_type="friend",
payload={"since": "2026-01-01"},
user_id="admin_1",
check_cycle=True # Raises CyclicReferenceError if edge creates a cycle
)
delete_edge
deleted = await client.delete_edge(table_name="follows", realm="tenant_1", edge_id=100, user_id="admin_1")
Append-Only Data History ({table_name}_data)
Vertices and edges support appending timestamped historical data snapshots with optional vector embeddings.
Client Data APIs
record = await client.add_vertex_data(
table_name="users",
realm="realm_a",
vertex_id=1,
payload={"status": "active", "login_count": 10},
timestamp=None, # Optional; defaults to CURRENT_TIMESTAMP
embedding=[0.1, 0.2, 0.3, 0.4] # Optional vector embedding
)
history = await client.get_vertex_data(table_name="users", realm="realm_a", vertex_id=1, limit=5)
Model Instance Methods
rec = await vertex.add_data({"status": "active"}, embedding=[0.1, 0.2, 0.3, 0.4])
history = await vertex.get_data(limit=10)
Multi-Tenant Realm Deletion
rows_deleted = await client.delete_realm(realm="tenant_to_remove")
Completely removes all vertices, edges, audit logs, and data history records belonging to the target realm across all tables.
Graph Traversals & Shortest Path (CTEs)
traverse
paths = await client.traverse(
realm="realm_a",
start_table="users",
start_id=1,
edge_tables=["follows", "likes"],
max_depth=3
)
for p in paths:
print(f"Depth: {p['depth']} | Path: {' -> '.join(p['path'])}")
shortest_path
sp = await client.shortest_path(
realm="realm_a",
start_table="users",
start_id=1,
target_table="posts",
target_id=42,
edge_tables=["follows", "likes"],
max_depth=5
)
if sp:
print(f"Shortest path found at depth {sp['depth']}: {sp['path']}")
📄 License
This project is licensed under the MIT License.
Developed by Chandan Rajah (chandan.rajah@gmail.com).
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file post_graph-0.1.5.tar.gz.
File metadata
- Download URL: post_graph-0.1.5.tar.gz
- Upload date:
- Size: 42.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
be8b04064292cf5c96c5efd74614c7a9449a3ed994c27a3eb8fe183d1258d06c
|
|
| MD5 |
6cdc9bbb2b66f4801c9e94f6492ae8fc
|
|
| BLAKE2b-256 |
dca3de7984a58bf4d16cd9619fc6b436d796dcbb888b63cb1fdd92eaa4edee57
|
File details
Details for the file post_graph-0.1.5-py3-none-any.whl.
File metadata
- Download URL: post_graph-0.1.5-py3-none-any.whl
- Upload date:
- Size: 29.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5dddbc3785a9671e7a0d2c61899d9aa067c8b33376cb244cbc83d36802f7b778
|
|
| MD5 |
ed4f607c1c2352835fc30bc6d33b0967
|
|
| BLAKE2b-256 |
a2b70e47f62192d17f4dc34a37670618a7d9da532408f63bb33a6d8870188cfe
|