Skip to main content

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

PyPI version Python Versions License: MIT

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


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.
  • Dual Isolation Modes:
    • Single-Schema Multi-Tenancy: Logical isolation using a realm column partition.
    • Schema-Per-Realm: Physical isolation creating dedicated PostgreSQL schema namespaces per tenant (CREATE SCHEMA IF NOT EXISTS "realm_name").
  • Autogenerated BIGSERIAL Primary Keys & Computed fqid:
    • Vertex FQID: {realm}/{table_name}/{id}
    • Edge FQID: {realm}/{from_table}-{to_table}/{id} (using hyphen separator)
    • Automatically populated at the PostgreSQL level via GENERATED ALWAYS AS ... STORED NOT NULL.
  • 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).
    • Cascades deletion when main vertex/edge is deleted (ON DELETE CASCADE).
  • Shadow Audit Tables ({table_name}_audit):
    • Operates via PostgreSQL triggers to capture all INSERT, UPDATE, and DELETE events.
    • Logs old/new row state and the initiating user_id passed via session parameters (app.current_user_id).
  • Advanced Graph Traversals:
    • Recursive CTE queries for neighbor exploration, path discovery, and cycle-free shortest path calculation.
    • Direct object-oriented traversal APIs (vertex.to(), vertex.from_(), step.vertex(), step.add_edge_to()).
  • Multiple Async Client Drivers:
    • Raw high-speed asyncpg client (AsyncPostGraph).
    • SQLAlchemy v2.0 async client (SQLAlchemyPostGraph).

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

Architecture Schema

erDiagram
    users {
        text realm PK
        bigserial id PK
        text fqid
        jsonb payload
        timestamptz created_at
        timestamptz updated_at
    }
    posts {
        text realm PK
        bigserial id PK
        text fqid
        jsonb payload
        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
        timestamptz timestamp
    }

    users ||--o{ likes : "from_id"
    posts ||--o{ likes : "to_id"
    users ||--o{ users_audit : "logs changes"
    users ||--o{ users_data : "appends records"

Quick Start Examples

1. Using AsyncPostGraph (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 (id is autogenerated if omitted)
    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. 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 & Connection

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

Schema DDL Operations

create_vertex_table

await client.create_vertex_table(table_name="users", realm=None)

Creates:

  1. {table_name} main vertex table with (realm, id) primary key.
  2. {table_name}_audit shadow audit log table.
  3. {table_name}_data append-only history table with foreign key (realm, id) ON DELETE CASCADE.
  4. Triggers for updated_at timestamps and audit logging.

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 the edge deletes source vertex
    cascade_delete_to=False          # If True, deleting the edge deletes target vertex
)

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.


Vertex Operations

add_vertex

vertex = await client.add_vertex(
    table_name="users",
    realm="tenant_1",
    vertex_id=None,                 # Optional. If omitted, BIGSERIAL autogenerates integer ID.
    payload={"name": "Alice"},       # Dict payload stored as JSONB.
    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)

update_vertex

updated = await client.update_vertex(
    table_name="users",
    realm="tenant_1",
    vertex_id=1,
    payload={"name": "Alice Smith"}, # Replaces JSONB payload
    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=False               # Set True to raise CyclicReferenceError if a cycle is created
)

get_edge

edge = await client.get_edge(table_name="follows", realm="tenant_1", edge_id=100)

delete_edge

deleted = await client.delete_edge(table_name="follows", realm="tenant_1", edge_id=100, user_id="admin_1")

Multi-Tenant Realm Operations

delete_realm

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.


Append-Only Data History ({table_name}_data)

Vertices and edges support appending timestamped historical data snapshots.

Client Data APIs

# Add historical record
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
)

# Fetch history records (sorted timestamp DESC)
history = await client.get_vertex_data(table_name="users", realm="realm_a", vertex_id=1, limit=5)
for rec in history:
    print(rec.data_id, rec.timestamp, rec.payload)

(Direct add_edge_data and get_edge_data methods operate identically for edge tables).

Model Convenience Methods

# Vertex/Edge instances expose add_data() and get_data() directly
rec = await vertex.add_data({"status": "active"})
history = await vertex.get_data(limit=10)

rec_edge = await edge.add_data({"event": "click"})
history_edge = await edge.get_data()

Object-Oriented Graph Traversal

Loaded Vertex objects allow seamless graph navigation:

alice = await client.get_vertex("users", "realm_a", 1)

# 1. Forward Traversal (.to)
steps = await alice.to(edge_table="follows")
for step in steps:
    edge = step.edge
    neighbor = step.vertex()
    print(f"Alice --[{edge.relation_type}]--> {neighbor.payload['name']}")

# 2. Reverse Traversal (.from_)
bob = await client.get_vertex("users", "realm_a", 2)
inbound_steps = await bob.from_(edge_table="follows")
for step in inbound_steps:
    origin_user = step.vertex()
    print(f"Followed by: {origin_user.payload['name']}")

# 3. Add Edge via Traversal Step
new_edge = await inbound_steps[0].add_edge_to(to_id=3, edge_table="follows", relation_type="knows")

# 4. Delete Vertex directly
await bob.delete(user_id="admin_1")

High-Performance Recursive CTE Queries

traverse

Explores all reachable paths starting from a vertex up to max_depth:

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

Calculates the shortest path between two nodes, with built-in loop avoidance:

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']}")

Transaction Support

AsyncPostGraph (raw asyncpg)

async with pool.acquire() as conn:
    async with conn.transaction():
        # Bind transaction connection to client wrapper
        tx_client = AsyncPostGraph(connection_or_pool=conn)
        await tx_client.add_vertex("users", "realm_a", payload={"name": "Tx Node"})
        await tx_client.add_edge("follows", "realm_a", from_id=1, to_id=2, relation_type="knows")
        # Commits automatically on block exit; rolls back on exception.

SQLAlchemyPostGraph

async with engine.connect() as conn:
    async with conn.begin():
        tx_client = SQLAlchemyPostGraph(conn)
        await tx_client.add_vertex("sa_users", "default", payload={"name": "Tx Node"})
        await tx_client.add_edge("sa_follows", "default", from_id=1, to_id=2, relation_type="knows")

License

This project is licensed under the MIT License.

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

post_graph-0.1.2.tar.gz (36.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

post_graph-0.1.2-py3-none-any.whl (25.5 kB view details)

Uploaded Python 3

File details

Details for the file post_graph-0.1.2.tar.gz.

File metadata

  • Download URL: post_graph-0.1.2.tar.gz
  • Upload date:
  • Size: 36.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for post_graph-0.1.2.tar.gz
Algorithm Hash digest
SHA256 d336bcfa0734a952d8ca9a3b603c98904d5e3bd56c9f720f67b62bba59c889f4
MD5 1028083aac92297546f878e145896591
BLAKE2b-256 0c9558f25576ce0095f1aa50d079192cc025450af8259782ff4cd85d4c54c62e

See more details on using hashes here.

File details

Details for the file post_graph-0.1.2-py3-none-any.whl.

File metadata

  • Download URL: post_graph-0.1.2-py3-none-any.whl
  • Upload date:
  • Size: 25.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for post_graph-0.1.2-py3-none-any.whl
Algorithm Hash digest
SHA256 54bbe8b88e884011bbdc5fd927d2e423421e34630b99b9e2dab392e7496aa91d
MD5 cc4c1d44886535b071e46cb6a9e51122
BLAKE2b-256 25e9004cb26c9b05863a6220c693b287ea46133a7d63181681f2ccd6ff3ae8a8

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page